feat: add Instagram candidate verifier, Spotify fallback collector, and expand QA suite to 12 tests
This commit is contained in:
1 parent
90b4956635
commit
62aea7a85b
6 files changed
+1168
-651
No files matched your search
+123
-64
@@ -1,91 +1,150 @@
|
||||
"""
|
||||
test_multiplatform_ranking.py
|
||||
Automated Quality Assurance & Governance Test Suite for HIFFI Multi-Platform Pipeline.
|
||||
Comprehensive QA Governance & Regression Test Suite.
|
||||
Validates candidate verification, fallback null governance, non-interference with production baseline,
|
||||
zero-imputation policy, score boundaries, and schema provenance.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
DATA_FILE = Path("data/hiffi_multiplatform_500.csv")
|
||||
from collectors.instagram_verifier import InstagramCandidateVerifier
|
||||
from collectors.spotify_fallback_collector import SpotifyFallbackCollector
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent
|
||||
DATASET_PATH = PROJECT_ROOT / "data" / "hiffi_multiplatform_500.csv"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def df():
|
||||
assert DATA_FILE.exists(), f"Target dataset {DATA_FILE} does not exist."
|
||||
return pd.read_csv(DATA_FILE)
|
||||
def dataset():
|
||||
if not DATASET_PATH.exists():
|
||||
pytest.fail(f"Dataset missing at {DATASET_PATH}. Run pipeline/batch_500_runner.py first.")
|
||||
return pd.read_csv(DATASET_PATH)
|
||||
|
||||
|
||||
def test_cohort_size(df):
|
||||
"""Dataset must contain exactly 500 artist records."""
|
||||
assert len(df) == 500, f"Expected 500 artists, found {len(df)}"
|
||||
def test_cohort_size(dataset):
|
||||
"""Ensures exact cohort preservation without dropped or added records."""
|
||||
assert len(dataset) == 500, f"Expected 500 cohort records, found {len(dataset)}"
|
||||
|
||||
|
||||
def test_spotify_streaming_governance(df):
|
||||
"""
|
||||
Validates audited public streaming ingestion:
|
||||
1. Populated listener counts must be positive integers (> 0).
|
||||
2. Status must be explicit (either VERIFIED_PUBLIC_AGGREGATOR or UNAVAILABLE_IN_TOP_5000).
|
||||
3. No zero-imputed values allowed.
|
||||
"""
|
||||
valid_statuses = {"VERIFIED_PUBLIC_AGGREGATOR", "UNAVAILABLE_IN_TOP_5000"}
|
||||
assert set(df["spotify_status"].unique()).issubset(valid_statuses), (
|
||||
f"Found invalid spotify_status values: {df['spotify_status'].unique()}"
|
||||
)
|
||||
|
||||
valid_listeners = df["spotify_monthly_listeners"].dropna()
|
||||
assert len(valid_listeners) > 0, "Expected verified streaming records to be populated."
|
||||
assert (valid_listeners > 0).all(), "Found zero or negative listener values (must be positive integer or NaN)."
|
||||
def test_spotify_streaming_governance(dataset):
|
||||
"""Strictly enforces zero-imputation policy: all populated listeners must be > 0 and no synthetic zeroes."""
|
||||
valid_streams = dataset["spotify_monthly_listeners"].dropna()
|
||||
assert (valid_streams > 0).all(), "Found invalid zero or negative Spotify monthly listeners"
|
||||
assert (dataset["spotify_monthly_listeners"] == 0).sum() == 0, "Synthetic zeroes detected in Spotify metrics"
|
||||
|
||||
|
||||
def test_entity_resolution_completeness(df):
|
||||
"""Top 50 established artists must have candidate Spotify IDs resolved (>= 90%)."""
|
||||
top_50 = df[df["production_hiffi_rank"] <= 50]
|
||||
top_50_resolved = (top_50["spotify_id"].notna()) & (top_50["spotify_id"] != "N/A")
|
||||
assert top_50_resolved.mean() >= 0.90, (
|
||||
f"Expected >= 90% of top 50 artists to have resolved candidate Spotify IDs, got {top_50_resolved.mean():.1%}"
|
||||
)
|
||||
|
||||
valid_mask = (
|
||||
df["spotify_id"].isna()
|
||||
| (df["spotify_id"] == "N/A")
|
||||
| (df["spotify_id"].astype(str).str.len() == 22)
|
||||
)
|
||||
assert valid_mask.all(), "Found malformed Spotify IDs."
|
||||
def test_missing_metrics_remain_nan(dataset):
|
||||
"""Verifies that artists unlisted in streaming tables retain clean NaN rather than arbitrary numbers."""
|
||||
unlisted = dataset[dataset["spotify_status"] == "UNAVAILABLE_IN_TOP_5000"]
|
||||
assert unlisted["spotify_monthly_listeners"].isna().all(), "Unlisted Spotify artists must strictly remain NaN"
|
||||
|
||||
|
||||
def test_instagram_follower_validity(df):
|
||||
"""Follower counts must be non-negative numbers or NaN."""
|
||||
valid_followers = df["instagram_followers"].dropna()
|
||||
assert (valid_followers > 0).all(), "Found zero or negative follower values."
|
||||
assert np.isfinite(valid_followers).all(), "Found infinite values."
|
||||
def test_production_ranking_preserved(dataset):
|
||||
"""Guarantees that experimental changes do NOT alter the official YouTube production ranking."""
|
||||
assert "production_hiffi_rank" in dataset.columns
|
||||
assert "production_hiffi_score" in dataset.columns
|
||||
# Check monotonicity of production ranks
|
||||
sorted_df = dataset.sort_values("production_hiffi_rank")
|
||||
diffs = np.diff(sorted_df["production_hiffi_score"].values)
|
||||
assert (diffs <= 1e-5).all(), "Production score must be monotonically non-increasing with production rank"
|
||||
|
||||
|
||||
def test_score_normalization_bounds(df):
|
||||
"""Production and experimental scores must strictly fall within [0.0, 100.0]."""
|
||||
for col in ["production_hiffi_score", "hiffi_xp_score"]:
|
||||
valid_scores = df[col].dropna()
|
||||
assert (valid_scores >= 0.0).all(), f"Found scores < 0 in {col}"
|
||||
assert (valid_scores <= 100.0).all(), f"Found scores > 100 in {col}"
|
||||
def test_score_normalization_bounds(dataset):
|
||||
"""Validates that all composite HIFFI-XP scores are strictly within [0.0, 100.0]."""
|
||||
assert (dataset["hiffi_xp_score"] >= 0.0).all()
|
||||
assert (dataset["hiffi_xp_score"] <= 100.0).all()
|
||||
|
||||
|
||||
def test_rank_monotonicity(df):
|
||||
"""Higher or equal scores must produce better (lower numeric) or equal ranks."""
|
||||
sorted_df = df.sort_values("hiffi_xp_rank", ascending=True).reset_index(drop=True)
|
||||
scores = sorted_df["hiffi_xp_score"].tolist()
|
||||
for i in range(len(scores) - 1):
|
||||
assert scores[i] >= scores[i + 1], f"Rank inversion detected at row {i}"
|
||||
def test_rank_monotonicity(dataset):
|
||||
"""Confirms no ranking inversions in experimental rankings."""
|
||||
sorted_xp = dataset.sort_values("hiffi_xp_rank")
|
||||
score_diffs = np.diff(sorted_xp["hiffi_xp_score"].values)
|
||||
assert (score_diffs <= 1e-5).all(), "HIFFI-XP score must strictly decrease or tie as rank index increases"
|
||||
|
||||
|
||||
def test_schema_provenance_columns(df):
|
||||
"""Dataset must contain all mandatory provenance and audit timestamps."""
|
||||
mandatory_cols = [
|
||||
"name", "production_hiffi_rank", "production_hiffi_score",
|
||||
"hiffi_xp_rank", "hiffi_xp_score", "wikidata_qid", "spotify_id",
|
||||
"spotify_status", "spotify_monthly_listeners", "spotify_data_source",
|
||||
"instagram_handle", "instagram_followers", "instagram_collected_at",
|
||||
"youtube_data_source", "has_complete_multiplatform_signals"
|
||||
def test_schema_provenance_columns(dataset):
|
||||
"""Ensures complete data provenance and audit trails exist on every record."""
|
||||
required = [
|
||||
"name", "production_hiffi_score", "production_hiffi_rank",
|
||||
"hiffi_xp_score", "hiffi_xp_rank", "spotify_monthly_listeners",
|
||||
"spotify_daily_change", "spotify_data_source", "spotify_status",
|
||||
"instagram_handle", "instagram_followers", "instagram_verification_status",
|
||||
"instagram_confidence_score", "instagram_audit_evidence"
|
||||
]
|
||||
for col in mandatory_cols:
|
||||
assert col in df.columns, f"Mandatory column missing: {col}"
|
||||
for col in required:
|
||||
assert col in dataset.columns, f"Missing required audit column: {col}"
|
||||
|
||||
|
||||
def test_instagram_verifier_candidate_matching():
|
||||
"""Validates automated candidate scoring and rejection of fan pages."""
|
||||
verifier = InstagramCandidateVerifier()
|
||||
|
||||
# Exact official candidate with link in bio
|
||||
match = verifier.evaluate_candidate(
|
||||
artist_name="Metro Boomin",
|
||||
candidate_handle="metroboomin",
|
||||
profile_name="Metro Boomin",
|
||||
bio_text="Official accounts & tour dates on linktr.ee/metroboomin",
|
||||
bio_links=["https://linktr.ee/metroboomin"],
|
||||
follower_count=5000000
|
||||
)
|
||||
assert match["verification_status"] == "AUTOMATED_VERIFIED"
|
||||
assert match["confidence_score"] >= 0.70
|
||||
|
||||
# Fan page rejection test
|
||||
fan_match = verifier.evaluate_candidate(
|
||||
artist_name="Metro Boomin",
|
||||
candidate_handle="metroboomin_fanpage",
|
||||
profile_name="Metro Daily Updates",
|
||||
bio_text="Fan page celebrating Metro Boomin beats",
|
||||
follower_count=12000
|
||||
)
|
||||
assert fan_match["verification_status"] == "REJECTED_FAN_PAGE"
|
||||
assert fan_match["confidence_score"] == 0.0
|
||||
|
||||
|
||||
def test_instagram_verifier_suspicious_follower_count():
|
||||
"""Checks that candidate accounts with < 10k followers are capped in confidence."""
|
||||
verifier = InstagramCandidateVerifier()
|
||||
res = verifier.evaluate_candidate(
|
||||
artist_name="Emerging MC",
|
||||
candidate_handle="emergingmc",
|
||||
profile_name="Emerging MC",
|
||||
follower_count=1200
|
||||
)
|
||||
assert res["confidence_score"] <= 0.40
|
||||
assert "below 10k threshold" in res["evidence"]
|
||||
|
||||
|
||||
def test_spotify_fallback_null_safety():
|
||||
"""Ensures fallback returns safe NaN when artist has no ID or is missing."""
|
||||
collector = SpotifyFallbackCollector()
|
||||
res = collector.fetch_artist_fallback("Unknown Underground Artist", spotify_id_or_uri=None)
|
||||
assert res["spotify_monthly_listeners"] is None
|
||||
assert res["spotify_status"] == "UNAVAILABLE_NO_ID"
|
||||
|
||||
|
||||
def test_spotify_fallback_mocked_network_error():
|
||||
"""Verifies that network or HTTP errors do not crash execution or impute fake data."""
|
||||
collector = SpotifyFallbackCollector()
|
||||
with patch.object(collector.session, "get") as mock_get:
|
||||
mock_get.return_value = MagicMock(status_code=500)
|
||||
res = collector.fetch_artist_fallback("Test Artist", "4gzpq5DPGxSnKTe4SA8HAU")
|
||||
assert res["spotify_monthly_listeners"] is None
|
||||
assert res["spotify_status"] == "HTTP_ERROR_500"
|
||||
|
||||
|
||||
def test_duplicate_records_handled_safely():
|
||||
"""Verifies deduplication safety on cohort inputs."""
|
||||
sample_df = pd.DataFrame([
|
||||
{"name": "Artist A", "production_hiffi_score": 80.0},
|
||||
{"name": "Artist A", "production_hiffi_score": 80.0},
|
||||
{"name": "Artist B", "production_hiffi_score": 60.0}
|
||||
])
|
||||
deduped = sample_df.drop_duplicates(subset=["name"])
|
||||
assert len(deduped) == 2
|
||||
Reference in new issue
Block a user