fix: resolve governance gaps, patch credential exposure, and connect multi-platform dashboard
This commit is contained in:
1 parent
6f2e8e0c9d
commit
7fb7d775d8
5 files changed
+308
-371
No files matched your search
@@ -1,30 +1,30 @@
|
|||||||
"""
|
"""
|
||||||
app.py
|
app.py
|
||||||
HIFFI Artist Intelligence - Executive Dashboard
|
HIFFI Artist Intelligence - Multi-Platform Executive Dashboard
|
||||||
Clean dark-themed design with aligned dataframes, responsive cards, and scouting dossier.
|
Clean dark-themed design with multi-platform evidence tiers, Spotify & Instagram telemetry,
|
||||||
|
breakout velocity index, and deep-dive scouting dossier.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import io
|
import io
|
||||||
import os
|
from pathlib import Path
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
import plotly.express as px
|
import plotly.express as px
|
||||||
import plotly.graph_objects as go
|
|
||||||
import streamlit as st
|
import streamlit as st
|
||||||
|
|
||||||
st.set_page_config(
|
st.set_page_config(
|
||||||
page_title="HIFFI | Artist Intelligence",
|
page_title="HIFFI | Multi-Platform Intelligence",
|
||||||
page_icon="⚡",
|
page_icon="⚡",
|
||||||
layout="wide",
|
layout="wide",
|
||||||
initial_sidebar_state="expanded",
|
initial_sidebar_state="expanded",
|
||||||
)
|
)
|
||||||
|
|
||||||
DATA_PATH = "enriched_artists.csv"
|
DATA_PATH = Path("data/hiffi_multiplatform_500.csv")
|
||||||
|
FALLBACK_DATA_PATH = Path("enriched_artists.csv")
|
||||||
|
|
||||||
# Global CSS Polish
|
# Global CSS Polish
|
||||||
st.markdown(
|
st.markdown(
|
||||||
"""
|
"""
|
||||||
<style>
|
<style>
|
||||||
/* Global Container Spacing */
|
|
||||||
.block-container {
|
.block-container {
|
||||||
padding-top: 1.8rem;
|
padding-top: 1.8rem;
|
||||||
padding-bottom: 2.5rem;
|
padding-bottom: 2.5rem;
|
||||||
@@ -71,7 +71,7 @@ st.markdown(
|
|||||||
.podium-silver { border-top: 4px solid #94a3b8; }
|
.podium-silver { border-top: 4px solid #94a3b8; }
|
||||||
.podium-bronze { border-top: 4px solid #f97316; }
|
.podium-bronze { border-top: 4px solid #f97316; }
|
||||||
|
|
||||||
/* Health Badges */
|
/* Health & Tier Badges */
|
||||||
.badge {
|
.badge {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 3px 8px;
|
padding: 3px 8px;
|
||||||
@@ -80,11 +80,11 @@ st.markdown(
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
.badge-active { background: rgba(16, 185, 129, 0.15); color: #10b981; }
|
.badge-tier3 { background: rgba(16, 185, 129, 0.2); color: #10b981; }
|
||||||
.badge-evergreen { background: rgba(56, 189, 248, 0.15); color: #38bdf8; }
|
.badge-tier2 { background: rgba(56, 189, 248, 0.2); color: #38bdf8; }
|
||||||
.badge-dormant { background: rgba(239, 68, 68, 0.15); color: #ef4444; }
|
.badge-tier1 { background: rgba(245, 158, 11, 0.2); color: #f59e0b; }
|
||||||
|
.badge-dormant { background: rgba(239, 68, 68, 0.2); color: #ef4444; }
|
||||||
|
|
||||||
/* Clean Section Dividers */
|
|
||||||
.custom-divider {
|
.custom-divider {
|
||||||
height: 1px;
|
height: 1px;
|
||||||
background: #1f2937;
|
background: #1f2937;
|
||||||
@@ -98,24 +98,44 @@ st.markdown(
|
|||||||
|
|
||||||
@st.cache_data(ttl=60)
|
@st.cache_data(ttl=60)
|
||||||
def load_data():
|
def load_data():
|
||||||
if not os.path.exists(DATA_PATH):
|
target = DATA_PATH if DATA_PATH.exists() else FALLBACK_DATA_PATH
|
||||||
|
if not target.exists():
|
||||||
return pd.DataFrame()
|
return pd.DataFrame()
|
||||||
df = pd.read_csv(DATA_PATH)
|
|
||||||
|
df = pd.read_csv(target)
|
||||||
|
|
||||||
|
# Normalize primary score & rank columns if running from legacy source
|
||||||
|
if "hiffi_xp_published_rank" not in df.columns:
|
||||||
|
df["hiffi_xp_published_rank"] = df.get("hiffi_xp_rank", df.get("hiffi_rank", df.index + 1))
|
||||||
|
if "hiffi_xp_score" not in df.columns:
|
||||||
|
df["hiffi_xp_score"] = df.get("production_hiffi_score", df.get("hiffi_score", 0.0))
|
||||||
|
if "platform_evidence_tier" not in df.columns:
|
||||||
|
has_yt = df.get("production_hiffi_score", df.get("hiffi_score")).notna().astype(int)
|
||||||
|
has_sp = df.get("spotify_monthly_listeners", pd.Series(index=df.index)).notna().astype(int)
|
||||||
|
has_ig = df.get("instagram_followers", pd.Series(index=df.index)).notna().astype(int)
|
||||||
|
df["platform_evidence_tier"] = has_yt + has_sp + has_ig
|
||||||
|
|
||||||
numeric_cols = [
|
numeric_cols = [
|
||||||
"hiffi_score",
|
"hiffi_xp_published_rank",
|
||||||
|
"hiffi_xp_rank",
|
||||||
|
"hiffi_xp_score",
|
||||||
|
"production_hiffi_rank",
|
||||||
|
"production_hiffi_score",
|
||||||
|
"platform_evidence_tier",
|
||||||
"breakout_velocity_index",
|
"breakout_velocity_index",
|
||||||
"subscriber_count",
|
"subscriber_count",
|
||||||
"daily_view_velocity",
|
"daily_view_velocity",
|
||||||
"momentum_30d",
|
"momentum_30d",
|
||||||
|
"spotify_monthly_listeners",
|
||||||
|
"spotify_daily_change",
|
||||||
|
"instagram_followers",
|
||||||
"confidence_score",
|
"confidence_score",
|
||||||
"hiffi_rank",
|
"breakout_rank"
|
||||||
"breakout_rank",
|
|
||||||
]
|
]
|
||||||
for col in numeric_cols:
|
for col in numeric_cols:
|
||||||
if col in df.columns:
|
if col in df.columns:
|
||||||
df[col] = pd.to_numeric(df[col], errors="coerce")
|
df[col] = pd.to_numeric(df[col], errors="coerce")
|
||||||
|
|
||||||
# Fallback assignment for subgenre if not yet computed in pipeline
|
|
||||||
if "subgenre" not in df.columns:
|
if "subgenre" not in df.columns:
|
||||||
def infer_subgenre(r):
|
def infer_subgenre(r):
|
||||||
subs = r.get("subscriber_count", 0) or 0
|
subs = r.get("subscriber_count", 0) or 0
|
||||||
@@ -132,7 +152,6 @@ def load_data():
|
|||||||
|
|
||||||
|
|
||||||
def to_excel_bytes(dataframe: pd.DataFrame) -> bytes:
|
def to_excel_bytes(dataframe: pd.DataFrame) -> bytes:
|
||||||
"""Converts dataframe to downloadable in-memory Excel spreadsheet."""
|
|
||||||
output = io.BytesIO()
|
output = io.BytesIO()
|
||||||
with pd.ExcelWriter(output, engine="openpyxl") as writer:
|
with pd.ExcelWriter(output, engine="openpyxl") as writer:
|
||||||
dataframe.to_excel(writer, index=False, sheet_name="HIFFI_Cohort")
|
dataframe.to_excel(writer, index=False, sheet_name="HIFFI_Cohort")
|
||||||
@@ -143,92 +162,71 @@ df = load_data()
|
|||||||
|
|
||||||
# ----------------- Sidebar Controls -----------------
|
# ----------------- Sidebar Controls -----------------
|
||||||
with st.sidebar:
|
with st.sidebar:
|
||||||
st.markdown("### ⚡ HIFFI Intelligence")
|
st.markdown("### ⚡ HIFFI Multi-Platform")
|
||||||
st.caption("Hip-Hop Market Scoring & Breakout Detection")
|
st.caption("YouTube • Spotify • Instagram Analytics")
|
||||||
st.markdown("<div class='custom-divider'></div>", unsafe_allow_html=True)
|
st.markdown("<div class='custom-divider'></div>", unsafe_allow_html=True)
|
||||||
|
|
||||||
if df.empty:
|
if df.empty:
|
||||||
st.error(f"'{DATA_PATH}' not found. Please run 'python main.py' first!")
|
st.error("No dataset found. Run 'python run_pipeline.py' to generate master data.")
|
||||||
st.stop()
|
st.stop()
|
||||||
|
|
||||||
view_mode = st.radio(
|
view_mode = st.radio(
|
||||||
"Leaderboard Perspective:",
|
"Leaderboard Perspective:",
|
||||||
["🏆 Overall HIFFI Rank", "🚀 Breakout Velocity (BVI)", "🔍 Master Audit Grid"],
|
["🏆 Published Multi-Platform Rank", "🚀 Breakout Velocity (BVI)", "🔍 Full Cohort Audit Grid"],
|
||||||
index=0,
|
index=0,
|
||||||
)
|
)
|
||||||
|
|
||||||
st.markdown("<div class='custom-divider'></div>", unsafe_allow_html=True)
|
st.markdown("<div class='custom-divider'></div>", unsafe_allow_html=True)
|
||||||
st.markdown("##### 🛡️ Filters")
|
st.markdown("##### 🛡️ Cohort Filters")
|
||||||
|
|
||||||
# Subgenre / Vertical Filter
|
|
||||||
all_verticals = sorted(df["subgenre"].dropna().unique().tolist())
|
all_verticals = sorted(df["subgenre"].dropna().unique().tolist())
|
||||||
target_presets = ["Legends", "Trap & Street", "UK Drill", "Lyricists", "Breakout / Emerging"]
|
selected_verticals = st.multiselect("Vertical", options=all_verticals, default=all_verticals)
|
||||||
vertical_choices = [v for v in target_presets if v in all_verticals] + [v for v in all_verticals if v not in target_presets]
|
|
||||||
|
|
||||||
selected_verticals = st.multiselect(
|
tier_options = sorted(df["platform_evidence_tier"].dropna().unique().astype(int).tolist(), reverse=True)
|
||||||
"Hip-Hop Vertical",
|
selected_tiers = st.multiselect("Evidence Tier", options=tier_options, default=tier_options)
|
||||||
options=vertical_choices,
|
|
||||||
default=vertical_choices,
|
|
||||||
)
|
|
||||||
|
|
||||||
available_statuses = df["health_status"].dropna().unique().tolist()
|
min_xp = st.slider("Min HIFFI-XP Score", 0.0, 100.0, 0.0, 5.0)
|
||||||
selected_statuses = st.multiselect(
|
|
||||||
"Channel Status",
|
|
||||||
options=available_statuses,
|
|
||||||
default=available_statuses,
|
|
||||||
)
|
|
||||||
|
|
||||||
min_conf = st.slider(
|
|
||||||
"Min Confidence",
|
|
||||||
min_value=0.0,
|
|
||||||
max_value=1.0,
|
|
||||||
value=0.50,
|
|
||||||
step=0.05,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Filter logic
|
|
||||||
filtered_df = df[
|
filtered_df = df[
|
||||||
(df["health_status"].isin(selected_statuses))
|
(df["subgenre"].isin(selected_verticals))
|
||||||
& (df["subgenre"].isin(selected_verticals))
|
& (df["platform_evidence_tier"].isin(selected_tiers))
|
||||||
& (df["confidence_score"] >= min_conf)
|
& (df["hiffi_xp_score"].fillna(0.0) >= min_xp)
|
||||||
].copy()
|
].copy()
|
||||||
|
|
||||||
# Sidebar Data Export Buttons (Filters applied)
|
|
||||||
with st.sidebar:
|
with st.sidebar:
|
||||||
st.markdown("<div class='custom-divider'></div>", unsafe_allow_html=True)
|
st.markdown("<div class='custom-divider'></div>", unsafe_allow_html=True)
|
||||||
st.markdown("##### 💾 Export Filtered Cohort")
|
st.markdown("##### 💾 Export Filtered Cohort")
|
||||||
|
|
||||||
col_csv, col_xlsx = st.columns(2)
|
col_csv, col_xlsx = st.columns(2)
|
||||||
|
|
||||||
csv_payload = filtered_df.to_csv(index=False).encode("utf-8")
|
csv_payload = filtered_df.to_csv(index=False).encode("utf-8")
|
||||||
col_csv.download_button(
|
col_csv.download_button(
|
||||||
label="📥 CSV",
|
"📥 CSV",
|
||||||
data=csv_payload,
|
data=csv_payload,
|
||||||
file_name="hiffi_filtered_cohort.csv",
|
file_name="hiffi_multiplatform_filtered.csv",
|
||||||
mime="text/csv",
|
mime="text/csv",
|
||||||
use_container_width=True,
|
width="stretch",
|
||||||
)
|
)
|
||||||
|
|
||||||
excel_payload = to_excel_bytes(filtered_df)
|
excel_payload = to_excel_bytes(filtered_df)
|
||||||
col_xlsx.download_button(
|
col_xlsx.download_button(
|
||||||
label="📊 Excel",
|
"📊 Excel",
|
||||||
data=excel_payload,
|
data=excel_payload,
|
||||||
file_name="hiffi_filtered_cohort.xlsx",
|
file_name="hiffi_multiplatform_filtered.xlsx",
|
||||||
mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
use_container_width=True,
|
width="stretch",
|
||||||
)
|
)
|
||||||
|
|
||||||
st.markdown("<div class='custom-divider'></div>", unsafe_allow_html=True)
|
st.markdown("<div class='custom-divider'></div>", unsafe_allow_html=True)
|
||||||
st.caption("Momentum (35%) • Velocity (30%) • Reach (20%) • Cadence (15%)")
|
st.caption("Weights: YouTube (50%) • Spotify (30%) • Instagram (20%)")
|
||||||
|
|
||||||
# ----------------- Main Header -----------------
|
# ----------------- Main Header -----------------
|
||||||
head_col1, head_col2 = st.columns([3, 1])
|
head_col1, head_col2 = st.columns([3, 1])
|
||||||
with head_col1:
|
with head_col1:
|
||||||
st.markdown("<h2 style='margin-bottom: 2px;'>HIFFI Artist Intelligence Hub</h2>", unsafe_allow_html=True)
|
st.markdown("<h2 style='margin-bottom: 2px;'>HIFFI Multi-Platform Intelligence Hub</h2>", unsafe_allow_html=True)
|
||||||
st.markdown("<p style='color: #9ca3af; font-size: 0.92rem; margin-top: 0;'>Cross-platform tracking of catalog sustainability and audience breakout velocity.</p>", unsafe_allow_html=True)
|
st.markdown("<p style='color: #9ca3af; font-size: 0.92rem; margin-top: 0;'>Multi-platform composite rankings with zero-imputation policy and dynamic weight redistribution.</p>", unsafe_allow_html=True)
|
||||||
with head_col2:
|
with head_col2:
|
||||||
st.markdown(
|
st.markdown(
|
||||||
f"<div style='text-align: right; color: #6b7280; font-size: 0.8rem; padding-top: 10px;'>Active Cohort: <b>{len(filtered_df)} / {len(df)}</b> artists</div>",
|
f"<div style='text-align: right; color: #6b7280; font-size: 0.8rem; padding-top: 10px;'>Filtered Cohort: <b>{len(filtered_df)} / {len(df)}</b> artists</div>",
|
||||||
unsafe_allow_html=True,
|
unsafe_allow_html=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -241,46 +239,46 @@ with k1:
|
|||||||
<div class="kpi-box">
|
<div class="kpi-box">
|
||||||
<div class="kpi-title">Tracked Artists</div>
|
<div class="kpi-title">Tracked Artists</div>
|
||||||
<div class="kpi-value">{len(df)}</div>
|
<div class="kpi-value">{len(df)}</div>
|
||||||
<div class="kpi-subtitle">Monitored pipeline size</div>
|
<div class="kpi-subtitle">Monitored Cohort</div>
|
||||||
</div>
|
</div>
|
||||||
""",
|
""",
|
||||||
unsafe_allow_html=True,
|
unsafe_allow_html=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
with k2:
|
with k2:
|
||||||
active_count = len(df[df["health_status"] == "ACTIVE_HEALTHY"])
|
t3_count = len(df[df["platform_evidence_tier"] == 3])
|
||||||
st.markdown(
|
st.markdown(
|
||||||
f"""
|
f"""
|
||||||
<div class="kpi-box">
|
<div class="kpi-box">
|
||||||
<div class="kpi-title" style="color: #10b981;">Active Healthy</div>
|
<div class="kpi-title" style="color: #10b981;">Tier 3 (Complete)</div>
|
||||||
<div class="kpi-value" style="color: #10b981;">{active_count}</div>
|
<div class="kpi-value" style="color: #10b981;">{t3_count}</div>
|
||||||
<div class="kpi-subtitle">Recent uploads & steady traction</div>
|
<div class="kpi-subtitle">YouTube + Spotify + Instagram</div>
|
||||||
</div>
|
</div>
|
||||||
""",
|
""",
|
||||||
unsafe_allow_html=True,
|
unsafe_allow_html=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
with k3:
|
with k3:
|
||||||
evergreen_count = len(df[df["health_status"] == "CATALOG_EVERGREEN"])
|
sp_count = df["spotify_monthly_listeners"].notna().sum()
|
||||||
st.markdown(
|
st.markdown(
|
||||||
f"""
|
f"""
|
||||||
<div class="kpi-box">
|
<div class="kpi-box">
|
||||||
<div class="kpi-title" style="color: #38bdf8;">Catalog Evergreen</div>
|
<div class="kpi-title" style="color: #38bdf8;">Spotify Telemetry</div>
|
||||||
<div class="kpi-value" style="color: #38bdf8;">{evergreen_count}</div>
|
<div class="kpi-value" style="color: #38bdf8;">{sp_count}</div>
|
||||||
<div class="kpi-subtitle">Strong passive catalog streams</div>
|
<div class="kpi-subtitle">Verified monthly listeners</div>
|
||||||
</div>
|
</div>
|
||||||
""",
|
""",
|
||||||
unsafe_allow_html=True,
|
unsafe_allow_html=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
with k4:
|
with k4:
|
||||||
zombies = len(df[df["health_status"] == "DORMANT_CATALOG"])
|
ig_count = df["instagram_followers"].notna().sum()
|
||||||
st.markdown(
|
st.markdown(
|
||||||
f"""
|
f"""
|
||||||
<div class="kpi-box">
|
<div class="kpi-box">
|
||||||
<div class="kpi-title" style="color: #ef4444;">Dormant / Excluded</div>
|
<div class="kpi-title" style="color: #f59e0b;">Instagram Audience</div>
|
||||||
<div class="kpi-value" style="color: #ef4444;">{zombies}</div>
|
<div class="kpi-value" style="color: #f59e0b;">{ig_count}</div>
|
||||||
<div class="kpi-subtitle">Disqualified (<NA> Rank)</div>
|
<div class="kpi-subtitle">Verified public followings</div>
|
||||||
</div>
|
</div>
|
||||||
""",
|
""",
|
||||||
unsafe_allow_html=True,
|
unsafe_allow_html=True,
|
||||||
@@ -288,31 +286,24 @@ with k4:
|
|||||||
|
|
||||||
st.markdown("<div class='custom-divider'></div>", unsafe_allow_html=True)
|
st.markdown("<div class='custom-divider'></div>", unsafe_allow_html=True)
|
||||||
|
|
||||||
# ----------------- Top 3 Leaders Podium -----------------
|
# ----------------- Podium -----------------
|
||||||
top3 = (
|
top3 = filtered_df.sort_values(by="hiffi_xp_published_rank", ascending=True).head(3)
|
||||||
filtered_df[filtered_df["is_rank_eligible"] == True]
|
|
||||||
.sort_values(by="hiffi_rank", ascending=True)
|
|
||||||
.head(3)
|
|
||||||
)
|
|
||||||
|
|
||||||
if len(top3) >= 3:
|
if len(top3) >= 3:
|
||||||
st.markdown("#### 👑 Market Leaders")
|
st.markdown("#### 👑 Market Leaders (Published Rankings)")
|
||||||
p1, p2, p3 = st.columns(3)
|
p1, p2, p3 = st.columns(3)
|
||||||
|
|
||||||
podium_data = [
|
podium_data = [
|
||||||
(p1, top3.iloc[0], "🥇 RANK #1", "podium-gold", "#f59e0b"),
|
(p1, top3.iloc[0], "🥇 PUBLISHED #1", "podium-gold", "#f59e0b"),
|
||||||
(p2, top3.iloc[1], "🥈 RANK #2", "podium-silver", "#94a3b8"),
|
(p2, top3.iloc[1], "🥈 PUBLISHED #2", "podium-silver", "#94a3b8"),
|
||||||
(p3, top3.iloc[2], "🥉 RANK #3", "podium-bronze", "#f97316"),
|
(p3, top3.iloc[2], "🥉 PUBLISHED #3", "podium-bronze", "#f97316"),
|
||||||
]
|
]
|
||||||
|
|
||||||
for col, row_data, rank_label, css_class, accent_color in podium_data:
|
for col, row_data, rank_label, css_class, accent_color in podium_data:
|
||||||
badge_cls = (
|
tier_val = int(row_data.get("platform_evidence_tier", 1))
|
||||||
"badge-active"
|
badge_cls = f"badge-tier{tier_val}"
|
||||||
if row_data["health_status"] == "ACTIVE_HEALTHY"
|
sp_str = f"{int(row_data['spotify_monthly_listeners']):,}" if pd.notna(row_data.get("spotify_monthly_listeners")) else "NaN"
|
||||||
else "badge-evergreen"
|
ig_str = f"{int(row_data['instagram_followers']):,}" if pd.notna(row_data.get("instagram_followers")) else "NaN"
|
||||||
)
|
|
||||||
bvi_str = f"{row_data['breakout_velocity_index']:.1f}" if pd.notna(row_data.get("breakout_velocity_index")) else "N/A"
|
|
||||||
vel_str = f"{int(row_data['daily_view_velocity']):,}" if pd.notna(row_data.get("daily_view_velocity")) else "N/A"
|
|
||||||
|
|
||||||
with col:
|
with col:
|
||||||
st.markdown(
|
st.markdown(
|
||||||
@@ -320,22 +311,22 @@ if len(top3) >= 3:
|
|||||||
<div class="podium-tile {css_class}">
|
<div class="podium-tile {css_class}">
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;">
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;">
|
||||||
<span style="font-weight: 800; color: {accent_color}; font-size: 0.85rem;">{rank_label}</span>
|
<span style="font-weight: 800; color: {accent_color}; font-size: 0.85rem;">{rank_label}</span>
|
||||||
<span class="badge {badge_cls}">{row_data['health_status'].replace('_', ' ')}</span>
|
<span class="badge {badge_cls}">Tier {tier_val} Evidence</span>
|
||||||
</div>
|
</div>
|
||||||
<div style="font-size: 1.35rem; font-weight: 800; color: #ffffff; margin-bottom: 2px;">{row_data['name']}</div>
|
<div style="font-size: 1.35rem; font-weight: 800; color: #ffffff; margin-bottom: 2px;">{row_data['name']}</div>
|
||||||
<div style="font-size: 0.8rem; color: #6b7280; margin-bottom: 12px;">{row_data.get('channel_title', '')}</div>
|
<div style="font-size: 0.8rem; color: #6b7280; margin-bottom: 12px;">{row_data.get('channel_title', '')}</div>
|
||||||
<div style="display: flex; justify-content: space-between; background: #0b0f17; padding: 10px 14px; border-radius: 8px;">
|
<div style="display: flex; justify-content: space-between; background: #0b0f17; padding: 10px 14px; border-radius: 8px;">
|
||||||
<div>
|
<div>
|
||||||
<div style="font-size: 0.68rem; color: #9ca3af; text-transform: uppercase;">HIFFI Score</div>
|
<div style="font-size: 0.68rem; color: #9ca3af; text-transform: uppercase;">HIFFI-XP</div>
|
||||||
<div style="font-size: 1.25rem; font-weight: 800; color: {accent_color};">{row_data['hiffi_score']:.1f}</div>
|
<div style="font-size: 1.25rem; font-weight: 800; color: {accent_color};">{row_data['hiffi_xp_score']:.2f}</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div style="font-size: 0.68rem; color: #9ca3af; text-transform: uppercase;">Breakout Index</div>
|
<div style="font-size: 0.68rem; color: #9ca3af; text-transform: uppercase;">Spotify Monthly</div>
|
||||||
<div style="font-size: 1.25rem; font-weight: 800; color: #a855f7;">{bvi_str}</div>
|
<div style="font-size: 1.05rem; font-weight: 700; color: #38bdf8;">{sp_str}</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div style="font-size: 0.68rem; color: #9ca3af; text-transform: uppercase;">Daily Views</div>
|
<div style="font-size: 0.68rem; color: #9ca3af; text-transform: uppercase;">IG Followers</div>
|
||||||
<div style="font-size: 1.05rem; font-weight: 700; color: #e5e7eb;">{vel_str}</div>
|
<div style="font-size: 1.05rem; font-weight: 700; color: #e5e7eb;">{ig_str}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -348,132 +339,96 @@ if len(top3) >= 3:
|
|||||||
# ----------------- Leaderboard Grid -----------------
|
# ----------------- Leaderboard Grid -----------------
|
||||||
st.markdown(f"#### 📋 {view_mode}")
|
st.markdown(f"#### 📋 {view_mode}")
|
||||||
|
|
||||||
if view_mode == "🏆 Overall HIFFI Rank":
|
if view_mode == "🏆 Published Multi-Platform Rank":
|
||||||
table_df = (
|
table_df = filtered_df.sort_values(by="hiffi_xp_published_rank", ascending=True).copy()
|
||||||
filtered_df[filtered_df["is_rank_eligible"] == True]
|
|
||||||
.sort_values(by="hiffi_rank", ascending=True)
|
|
||||||
.copy()
|
|
||||||
)
|
|
||||||
cols = [
|
cols = [
|
||||||
"hiffi_rank",
|
"hiffi_xp_published_rank",
|
||||||
|
"hiffi_xp_rank",
|
||||||
"name",
|
"name",
|
||||||
"subgenre",
|
"platform_evidence_tier",
|
||||||
"hiffi_score",
|
"hiffi_xp_score",
|
||||||
"breakout_rank",
|
"spotify_monthly_listeners",
|
||||||
"breakout_velocity_index",
|
"instagram_followers",
|
||||||
"daily_view_velocity",
|
"effective_weights",
|
||||||
"momentum_30d",
|
"anomaly_flags"
|
||||||
"health_status",
|
|
||||||
]
|
]
|
||||||
elif view_mode == "🚀 Breakout Velocity (BVI)":
|
elif view_mode == "🚀 Breakout Velocity (BVI)":
|
||||||
table_df = (
|
table_df = (
|
||||||
filtered_df[
|
filtered_df[filtered_df["breakout_velocity_index"].notna()]
|
||||||
(filtered_df["is_rank_eligible"] == True)
|
.sort_values(by="breakout_velocity_index", ascending=False)
|
||||||
& (filtered_df["breakout_rank"].notna())
|
|
||||||
]
|
|
||||||
.sort_values(by="breakout_rank", ascending=True)
|
|
||||||
.copy()
|
.copy()
|
||||||
)
|
)
|
||||||
cols = [
|
cols = [
|
||||||
"breakout_rank",
|
|
||||||
"name",
|
"name",
|
||||||
"subgenre",
|
|
||||||
"breakout_velocity_index",
|
"breakout_velocity_index",
|
||||||
"hiffi_rank",
|
"hiffi_xp_published_rank",
|
||||||
"hiffi_score",
|
|
||||||
"daily_view_velocity",
|
"daily_view_velocity",
|
||||||
|
"momentum_30d",
|
||||||
"subscriber_count",
|
"subscriber_count",
|
||||||
"health_status",
|
"subgenre"
|
||||||
]
|
]
|
||||||
else:
|
else:
|
||||||
table_df = filtered_df.sort_values(by=["is_rank_eligible", "hiffi_score"], ascending=[False, False]).copy()
|
table_df = filtered_df.sort_values(by="hiffi_xp_published_rank", ascending=True).copy()
|
||||||
cols = [
|
cols = [
|
||||||
|
"hiffi_xp_published_rank",
|
||||||
"name",
|
"name",
|
||||||
"subgenre",
|
"production_hiffi_rank",
|
||||||
"hiffi_score",
|
"hiffi_xp_rank",
|
||||||
"breakout_velocity_index",
|
"rank_delta",
|
||||||
"daily_view_velocity",
|
"platform_evidence_tier",
|
||||||
"subscriber_count",
|
"hiffi_xp_score",
|
||||||
"health_status",
|
"spotify_monthly_listeners",
|
||||||
"confidence_score",
|
"instagram_followers",
|
||||||
"is_rank_eligible",
|
"evidence_exclusions",
|
||||||
|
"anomaly_flags"
|
||||||
]
|
]
|
||||||
|
|
||||||
# Keep only columns that exist in DataFrame to prevent KeyError
|
|
||||||
render_cols = [c for c in cols if c in table_df.columns]
|
render_cols = [c for c in cols if c in table_df.columns]
|
||||||
|
|
||||||
st.dataframe(
|
st.dataframe(
|
||||||
table_df[render_cols].reset_index(drop=True),
|
table_df[render_cols].reset_index(drop=True),
|
||||||
use_container_width=True,
|
width="stretch",
|
||||||
height=290,
|
height=320,
|
||||||
column_config={
|
column_config={
|
||||||
"hiffi_rank": st.column_config.NumberColumn("Rank", format="%d", width="small"),
|
"hiffi_xp_published_rank": st.column_config.NumberColumn("Pub Rank", format="%d", width="small"),
|
||||||
"breakout_rank": st.column_config.NumberColumn("BVI Rank", format="%d", width="small"),
|
"hiffi_xp_rank": st.column_config.NumberColumn("Raw XP Rank", format="%d", width="small"),
|
||||||
|
"production_hiffi_rank": st.column_config.NumberColumn("YT Rank", format="%d", width="small"),
|
||||||
|
"platform_evidence_tier": st.column_config.NumberColumn("Tier", format="%d", width="small"),
|
||||||
"name": st.column_config.TextColumn("Artist", width="medium"),
|
"name": st.column_config.TextColumn("Artist", width="medium"),
|
||||||
"subgenre": st.column_config.TextColumn("Vertical", width="medium"),
|
"hiffi_xp_score": st.column_config.ProgressColumn("HIFFI-XP Score", format="%.2f", min_value=0, max_value=100, width="medium"),
|
||||||
"hiffi_score": st.column_config.ProgressColumn(
|
"breakout_velocity_index": st.column_config.ProgressColumn("BVI", format="%.1f", min_value=0, max_value=100, width="small"),
|
||||||
"HIFFI Score",
|
"spotify_monthly_listeners": st.column_config.NumberColumn("Spotify Monthly", format="%,d", width="medium"),
|
||||||
format="%.1f",
|
"instagram_followers": st.column_config.NumberColumn("IG Followers", format="%,d", width="medium"),
|
||||||
min_value=0,
|
"effective_weights": st.column_config.TextColumn("Dynamic Redistribution", width="large"),
|
||||||
max_value=100,
|
"anomaly_flags": st.column_config.TextColumn("Anomaly Status", width="medium"),
|
||||||
width="medium",
|
"evidence_exclusions": st.column_config.TextColumn("Exclusions", width="large"),
|
||||||
),
|
|
||||||
"breakout_velocity_index": st.column_config.ProgressColumn(
|
|
||||||
"Breakout (BVI)",
|
|
||||||
format="%.1f",
|
|
||||||
min_value=0,
|
|
||||||
max_value=100,
|
|
||||||
width="medium",
|
|
||||||
),
|
|
||||||
"daily_view_velocity": st.column_config.NumberColumn(
|
|
||||||
"Daily Views / Day",
|
|
||||||
format="%,d",
|
|
||||||
width="medium",
|
|
||||||
),
|
|
||||||
"momentum_30d": st.column_config.NumberColumn(
|
|
||||||
"30d Momentum",
|
|
||||||
format="%.1f%%",
|
|
||||||
width="small",
|
|
||||||
),
|
|
||||||
"subscriber_count": st.column_config.NumberColumn(
|
|
||||||
"Subscribers",
|
|
||||||
format="%,d",
|
|
||||||
width="medium",
|
|
||||||
),
|
|
||||||
"health_status": st.column_config.TextColumn("Status", width="medium"),
|
|
||||||
"confidence_score": st.column_config.NumberColumn("Confidence", format="%.2f", width="small"),
|
|
||||||
"is_rank_eligible": st.column_config.CheckboxColumn("Eligible", width="small"),
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
st.markdown("<div class='custom-divider'></div>", unsafe_allow_html=True)
|
st.markdown("<div class='custom-divider'></div>", unsafe_allow_html=True)
|
||||||
|
|
||||||
# ----------------- Visual Insights Charts -----------------
|
# ----------------- Visual Insights Charts -----------------
|
||||||
st.markdown("#### 📊 Market Landscape Visualizer")
|
st.markdown("#### 📊 Multi-Platform Analytics")
|
||||||
|
|
||||||
c_left, c_right = st.columns(2)
|
c_left, c_right = st.columns(2)
|
||||||
|
|
||||||
with c_left:
|
with c_left:
|
||||||
scatter_df = filtered_df[filtered_df["is_rank_eligible"] == True].copy()
|
chart_df = filtered_df.dropna(subset=["spotify_monthly_listeners", "instagram_followers", "hiffi_xp_score"]).copy()
|
||||||
|
if not chart_df.empty:
|
||||||
fig_scatter = px.scatter(
|
fig_scatter = px.scatter(
|
||||||
scatter_df,
|
chart_df,
|
||||||
x="daily_view_velocity",
|
x="spotify_monthly_listeners",
|
||||||
y="momentum_30d",
|
y="instagram_followers",
|
||||||
size="subscriber_count",
|
size="hiffi_xp_score",
|
||||||
color="health_status",
|
color="platform_evidence_tier",
|
||||||
color_discrete_map={
|
|
||||||
"ACTIVE_HEALTHY": "#10b981",
|
|
||||||
"CATALOG_EVERGREEN": "#38bdf8",
|
|
||||||
"DORMANT_CATALOG": "#ef4444",
|
|
||||||
},
|
|
||||||
hover_name="name",
|
hover_name="name",
|
||||||
hover_data={"daily_view_velocity": ":,d", "momentum_30d": ":.1f%"},
|
|
||||||
log_x=True,
|
log_x=True,
|
||||||
title="View Velocity vs. 30-Day Momentum",
|
log_y=True,
|
||||||
|
title="Spotify Monthly Listeners vs. Instagram Followers",
|
||||||
labels={
|
labels={
|
||||||
"daily_view_velocity": "Daily Views / Day (Log Scale)",
|
"spotify_monthly_listeners": "Spotify Monthly Listeners (Log Scale)",
|
||||||
"momentum_30d": "30-Day Growth (%)",
|
"instagram_followers": "Instagram Followers (Log Scale)",
|
||||||
"health_status": "Vitality",
|
"platform_evidence_tier": "Tier",
|
||||||
},
|
},
|
||||||
template="plotly_dark",
|
template="plotly_dark",
|
||||||
)
|
)
|
||||||
@@ -484,97 +439,65 @@ with c_left:
|
|||||||
margin=dict(l=20, r=20, t=40, b=20),
|
margin=dict(l=20, r=20, t=40, b=20),
|
||||||
height=320,
|
height=320,
|
||||||
)
|
)
|
||||||
st.plotly_chart(fig_scatter, use_container_width=True)
|
st.plotly_chart(fig_scatter, width="stretch")
|
||||||
|
else:
|
||||||
|
st.info("Insufficient complete data points to render multi-platform scatter plot.")
|
||||||
|
|
||||||
with c_right:
|
with c_right:
|
||||||
bvi_df = (
|
tier_counts = filtered_df["platform_evidence_tier"].value_counts().reset_index()
|
||||||
filtered_df[filtered_df["breakout_velocity_index"].notna()]
|
tier_counts.columns = ["Tier", "Count"]
|
||||||
.sort_values(by="breakout_velocity_index", ascending=True)
|
fig_pie = px.pie(
|
||||||
)
|
tier_counts,
|
||||||
fig_bar = px.bar(
|
values="Count",
|
||||||
bvi_df,
|
names="Tier",
|
||||||
x="breakout_velocity_index",
|
title="Evidence Tier Cohort Distribution",
|
||||||
y="name",
|
|
||||||
orientation="h",
|
|
||||||
color="breakout_velocity_index",
|
|
||||||
color_continuous_scale="Purples",
|
|
||||||
title="Breakout Velocity Index (BVI)",
|
|
||||||
labels={"breakout_velocity_index": "BVI Score", "name": "Artist"},
|
|
||||||
template="plotly_dark",
|
template="plotly_dark",
|
||||||
|
color="Tier",
|
||||||
|
color_discrete_map={3: "#10b981", 2: "#38bdf8", 1: "#f59e0b", 0: "#ef4444"}
|
||||||
)
|
)
|
||||||
fig_bar.update_layout(
|
fig_pie.update_layout(
|
||||||
paper_bgcolor="#111827",
|
paper_bgcolor="#111827",
|
||||||
plot_bgcolor="#111827",
|
plot_bgcolor="#111827",
|
||||||
font=dict(color="#9ca3af"),
|
font=dict(color="#9ca3af"),
|
||||||
coloraxis_showscale=False,
|
|
||||||
margin=dict(l=20, r=20, t=40, b=20),
|
margin=dict(l=20, r=20, t=40, b=20),
|
||||||
height=320,
|
height=320,
|
||||||
)
|
)
|
||||||
st.plotly_chart(fig_bar, use_container_width=True)
|
st.plotly_chart(fig_pie, width="stretch")
|
||||||
|
|
||||||
st.markdown("<div class='custom-divider'></div>", unsafe_allow_html=True)
|
|
||||||
|
|
||||||
# ----------------- Deep Dive Scouting Dossier -----------------
|
# ----------------- Deep Dive Scouting Dossier -----------------
|
||||||
|
st.markdown("<div class='custom-divider'></div>", unsafe_allow_html=True)
|
||||||
st.markdown("#### 🔎 Artist Dossier")
|
st.markdown("#### 🔎 Artist Dossier")
|
||||||
|
|
||||||
selected_name = st.selectbox("Inspect Artist Details:", df["name"].unique(), label_visibility="collapsed")
|
selected_name = st.selectbox("Inspect Artist Details:", df["name"].unique(), label_visibility="collapsed")
|
||||||
row = df[df["name"] == selected_name].iloc[0]
|
row = df[df["name"] == selected_name].iloc[0]
|
||||||
|
|
||||||
# Resolve YouTube channel direct link
|
|
||||||
yt_url = str(row.get("youtube_url", "")).strip()
|
|
||||||
yt_id = str(row.get("channel_id", row.get("youtube_channel_id", ""))).strip()
|
|
||||||
|
|
||||||
if (not yt_url or yt_url == "nan") and yt_id and yt_id != "nan":
|
|
||||||
if yt_id.startswith("UC"):
|
|
||||||
yt_url = f"https://www.youtube.com/channel/{yt_id}"
|
|
||||||
elif yt_id.startswith("@"):
|
|
||||||
yt_url = f"https://www.youtube.com/{yt_id}"
|
|
||||||
else:
|
|
||||||
yt_url = f"https://www.youtube.com/@{yt_id}"
|
|
||||||
|
|
||||||
d1, d2, d3, d4 = st.columns(4)
|
d1, d2, d3, d4 = st.columns(4)
|
||||||
|
|
||||||
with d1:
|
with d1:
|
||||||
h_val = row.get("hiffi_score")
|
h_val = row.get("hiffi_xp_score")
|
||||||
st.metric("HIFFI Score", f"{h_val:.1f}" if pd.notna(h_val) else "N/A")
|
st.metric("HIFFI-XP Score", f"{h_val:.2f}" if pd.notna(h_val) else "N/A")
|
||||||
with d2:
|
with d2:
|
||||||
b_val = row.get("breakout_velocity_index")
|
pub_rank = row.get("hiffi_xp_published_rank")
|
||||||
st.metric("Breakout Index (BVI)", f"{b_val:.1f}" if pd.notna(b_val) else "N/A")
|
st.metric("Published Rank", f"#{int(pub_rank)}" if pd.notna(pub_rank) else "Unranked")
|
||||||
with d3:
|
with d3:
|
||||||
s_val = row.get("subscriber_count")
|
sp_l = row.get("spotify_monthly_listeners")
|
||||||
st.metric("Subscribers", f"{int(s_val):,}" if pd.notna(s_val) else "N/A")
|
st.metric("Spotify Listeners", f"{int(sp_l):,}" if pd.notna(sp_l) else "Unindexed (NaN)")
|
||||||
with d4:
|
with d4:
|
||||||
conf = row.get("confidence_score", 0.0)
|
ig_f = row.get("instagram_followers")
|
||||||
tier = row.get("confidence_tier", "Standard")
|
st.metric("Instagram Followers", f"{int(ig_f):,}" if pd.notna(ig_f) else "Unindexed (NaN)")
|
||||||
st.metric("Entity Confidence", f"{conf * 100:.0f}%", delta=tier, delta_color="normal")
|
|
||||||
|
|
||||||
badge_cls = (
|
|
||||||
"badge-active"
|
|
||||||
if row.get("health_status") == "ACTIVE_HEALTHY"
|
|
||||||
else "badge-evergreen"
|
|
||||||
if row.get("health_status") == "CATALOG_EVERGREEN"
|
|
||||||
else "badge-dormant"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Dossier info box with integrated Official YouTube Button
|
|
||||||
st.markdown(
|
st.markdown(
|
||||||
f"""
|
f"""
|
||||||
<div style="background: #111827; padding: 14px 18px; border-radius: 8px; border: 1px solid #1f2937; margin-top: 10px; display: flex; justify-content: space-between; align-items: center; font-size: 0.85rem;">
|
<div style="background: #111827; padding: 14px 18px; border-radius: 8px; border: 1px solid #1f2937; margin-top: 10px; font-size: 0.85rem;">
|
||||||
<div style="display: flex; gap: 24px; align-items: center;">
|
<div style="display: flex; gap: 20px; flex-wrap: wrap;">
|
||||||
<div><b>Artist:</b> {row['name']}</div>
|
<div><b>Artist:</b> {row['name']}</div>
|
||||||
<div><b>Vertical:</b> <span style="color: #a855f7;">{row.get('subgenre', 'N/A')}</span></div>
|
<div><b>Evidence Tier:</b> Tier {int(row.get('platform_evidence_tier', 1))}</div>
|
||||||
<div><b>Channel:</b> {row.get('channel_title', 'Unknown')}</div>
|
<div><b>Instagram Status:</b> {row.get('instagram_verification_status', 'N/A')}</div>
|
||||||
<div><b>Status:</b> <span class="badge {badge_cls}">{row.get('health_status', 'UNKNOWN')}</span></div>
|
<div><b>Spotify Source:</b> {row.get('spotify_data_source', 'N/A')}</div>
|
||||||
<div><b>Zombie Flag:</b> {'⚠️ Yes' if row.get('is_zombie') else '✅ Clean'}</div>
|
<div><b>Effective Weights:</b> {row.get('effective_weights', 'N/A')}</div>
|
||||||
<div><b>Eligible for Rank:</b> {'✅ Yes' if row.get('is_rank_eligible') else '❌ Disqualified'}</div>
|
<div><b>Anomaly Flags:</b> {row.get('anomaly_flags', 'CLEAN')}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
""",
|
""",
|
||||||
unsafe_allow_html=True,
|
unsafe_allow_html=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
st.write("")
|
|
||||||
if yt_url and yt_url != "nan":
|
|
||||||
st.link_button("▶ Open Official YouTube Channel", yt_url, use_container_width=False)
|
|
||||||
else:
|
|
||||||
st.caption("⚠️ No verified YouTube channel link available for this artist.")
|
|
||||||
@@ -4,16 +4,28 @@ Direct diagnostic to test Spotify OAuth2 Client Credentials flow
|
|||||||
and probe endpoint permissions for Artist data, Top Tracks, and Search.
|
and probe endpoint permissions for Artist data, Top Tracks, and Search.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
|
||||||
import requests
|
|
||||||
import base64
|
import base64
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
import requests
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
CLIENT_ID = os.getenv("SPOTIFY_CLIENT_ID", "a4a008ec790d45fba6a37315a1a3d847")
|
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||||
CLIENT_SECRET = os.getenv("SPOTIFY_CLIENT_SECRET", "a5c67d9a63d340b6ae4a5bec83bca241")
|
if str(PROJECT_ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
|
load_dotenv(PROJECT_ROOT / ".env")
|
||||||
|
|
||||||
|
CLIENT_ID = os.getenv("SPOTIFY_CLIENT_ID", "").strip()
|
||||||
|
CLIENT_SECRET = os.getenv("SPOTIFY_CLIENT_SECRET", "").strip()
|
||||||
|
|
||||||
|
|
||||||
def get_token(client_id: str, client_secret: str):
|
def get_token(client_id: str, client_secret: str):
|
||||||
"""Requests a bearer token via standard OAuth2 Client Credentials."""
|
"""Requests a bearer token via standard OAuth2 Client Credentials."""
|
||||||
|
if not client_id or not client_secret:
|
||||||
|
return 0, {"error": "SPOTIFY_CLIENT_ID or SPOTIFY_CLIENT_SECRET not configured"}
|
||||||
|
|
||||||
auth_header = base64.b64encode(f"{client_id}:{client_secret}".encode()).decode()
|
auth_header = base64.b64encode(f"{client_id}:{client_secret}".encode()).decode()
|
||||||
url = "https://accounts.spotify.com/api/token"
|
url = "https://accounts.spotify.com/api/token"
|
||||||
res = requests.post(
|
res = requests.post(
|
||||||
@@ -50,6 +62,10 @@ if __name__ == "__main__":
|
|||||||
print("SPOTIFY API AUTHENTICATION & ENDPOINT PERMISSION DIAGNOSTIC")
|
print("SPOTIFY API AUTHENTICATION & ENDPOINT PERMISSION DIAGNOSTIC")
|
||||||
print("=" * 80)
|
print("=" * 80)
|
||||||
|
|
||||||
|
if not CLIENT_ID or not CLIENT_SECRET:
|
||||||
|
print("Error: Missing credentials in environment. Set SPOTIFY_CLIENT_ID and SPOTIFY_CLIENT_SECRET in .env.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
status, token_resp = get_token(CLIENT_ID, CLIENT_SECRET)
|
status, token_resp = get_token(CLIENT_ID, CLIENT_SECRET)
|
||||||
print(f"Token Endpoint Status: {status}")
|
print(f"Token Endpoint Status: {status}")
|
||||||
|
|
||||||
|
|||||||
+39
-22
@@ -12,8 +12,8 @@ Ranks artists according to HIFFI formula stages:
|
|||||||
and effective weight serialization.
|
and effective weight serialization.
|
||||||
- Publication Eligibility Gates: Decoupled public chart assigning consecutive ranks
|
- Publication Eligibility Gates: Decoupled public chart assigning consecutive ranks
|
||||||
while enforcing multi-platform evidence tiers (Top 10 = Tier 3, Top 50 = Tier >= 2)
|
while enforcing multi-platform evidence tiers (Top 10 = Tier 3, Top 50 = Tier >= 2)
|
||||||
conditioned on strict identity verification and UTC data freshness thresholds.
|
conditioned on strict identity verification, status_flag validation, and UTC data freshness thresholds.
|
||||||
Fail-closed on missing timestamp columns or unverified statuses.
|
Fail-closed on missing timestamp columns, failed lookups, or unverified statuses.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Optional, Tuple, Union
|
from typing import Optional, Tuple, Union
|
||||||
@@ -26,7 +26,7 @@ class HIFFIRankingEngine:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def _log_scale(series: pd.Series) -> pd.Series:
|
def _log_scale(series: pd.Series) -> pd.Series:
|
||||||
valid = series.dropna()
|
valid = series.dropna()
|
||||||
log_vals = np.log10(1.0 + np.maximum(valid, 0.0))
|
log_vals = np.log1p(np.maximum(valid.astype(float), 0.0))
|
||||||
s_min, s_max = log_vals.min(), log_vals.max()
|
s_min, s_max = log_vals.min(), log_vals.max()
|
||||||
if np.isclose(s_max, s_min) or pd.isna(s_min):
|
if np.isclose(s_max, s_min) or pd.isna(s_min):
|
||||||
return pd.Series(0.0, index=series.index)
|
return pd.Series(0.0, index=series.index)
|
||||||
@@ -70,13 +70,15 @@ class HIFFIRankingEngine:
|
|||||||
"""
|
"""
|
||||||
out_bvi = pd.Series(np.nan, index=df.index, dtype=float)
|
out_bvi = pd.Series(np.nan, index=df.index, dtype=float)
|
||||||
|
|
||||||
|
status_col = df["status_flag"] if "status_flag" in df.columns else pd.Series("COMPLETE", index=df.index)
|
||||||
|
|
||||||
valid_mask = (
|
valid_mask = (
|
||||||
df["subscriber_count"].notna()
|
df["subscriber_count"].notna()
|
||||||
& (df["subscriber_count"] >= 10000)
|
& (df["subscriber_count"] >= 10000)
|
||||||
& df["daily_view_velocity"].notna()
|
& df["daily_view_velocity"].notna()
|
||||||
& (df["daily_view_velocity"] > 0.0)
|
& (df["daily_view_velocity"] > 0.0)
|
||||||
& (df["status_flag"] != "INACTIVE_OR_DORMANT")
|
& (status_col != "INACTIVE_OR_DORMANT")
|
||||||
& (df["status_flag"] != "FAILED_LOOKUP")
|
& (status_col != "FAILED_LOOKUP")
|
||||||
)
|
)
|
||||||
|
|
||||||
if not valid_mask.any():
|
if not valid_mask.any():
|
||||||
@@ -123,29 +125,31 @@ class HIFFIRankingEngine:
|
|||||||
|
|
||||||
out["breakout_velocity_index"] = self.calculate_breakout_velocity(out)
|
out["breakout_velocity_index"] = self.calculate_breakout_velocity(out)
|
||||||
|
|
||||||
|
status_col = out["status_flag"] if "status_flag" in out.columns else pd.Series("COMPLETE", index=out.index)
|
||||||
|
|
||||||
has_baseline = (
|
has_baseline = (
|
||||||
out["subscriber_count"].notna()
|
out["subscriber_count"].notna()
|
||||||
& out["lifetime_views"].notna()
|
& out["lifetime_views"].notna()
|
||||||
& (out["status_flag"] != "FAILED_LOOKUP")
|
& (status_col != "FAILED_LOOKUP")
|
||||||
)
|
)
|
||||||
|
|
||||||
out["is_rank_eligible"] = (
|
out["is_rank_eligible"] = (
|
||||||
has_baseline
|
has_baseline
|
||||||
& (out["confidence_score"] >= min_confidence)
|
& (out.get("confidence_score", pd.Series(1.0, index=out.index)) >= min_confidence)
|
||||||
& (out["status_flag"].isin(["COMPLETE", "CATALOG_ACTIVE_NO_RECENT_UPLOAD"]))
|
& (status_col.isin(["COMPLETE", "CATALOG_ACTIVE_NO_RECENT_UPLOAD"]))
|
||||||
)
|
)
|
||||||
|
|
||||||
filled_momentum = out["momentum_30d"].fillna(0.0).clip(lower=-100.0, upper=300.0)
|
filled_momentum = out["momentum_30d"].fillna(0.0).clip(lower=-100.0, upper=300.0) if "momentum_30d" in out.columns else pd.Series(0.0, index=out.index)
|
||||||
score_momentum = self._min_max_scale(filled_momentum)
|
score_momentum = self._min_max_scale(filled_momentum)
|
||||||
|
|
||||||
filled_velocity = out["daily_view_velocity"].fillna(0.0)
|
filled_velocity = out["daily_view_velocity"].fillna(0.0) if "daily_view_velocity" in out.columns else pd.Series(0.0, index=out.index)
|
||||||
score_velocity = self._log_scale(filled_velocity)
|
score_velocity = self._log_scale(filled_velocity)
|
||||||
|
|
||||||
log_subs = self._log_scale(out["subscriber_count"].fillna(0.0))
|
log_subs = self._log_scale(out["subscriber_count"].fillna(0.0)) if "subscriber_count" in out.columns else pd.Series(0.0, index=out.index)
|
||||||
log_lifetime = self._log_scale(out["lifetime_views"].fillna(0.0))
|
log_lifetime = self._log_scale(out["lifetime_views"].fillna(0.0)) if "lifetime_views" in out.columns else pd.Series(0.0, index=out.index)
|
||||||
score_reach = 0.60 * log_subs + 0.40 * log_lifetime
|
score_reach = 0.60 * log_subs + 0.40 * log_lifetime
|
||||||
|
|
||||||
score_consistency = self._log_scale(out["video_count"].fillna(0.0))
|
score_consistency = self._log_scale(out["video_count"].fillna(0.0)) if "video_count" in out.columns else pd.Series(0.0, index=out.index)
|
||||||
|
|
||||||
hiffi_raw = (
|
hiffi_raw = (
|
||||||
0.35 * score_momentum
|
0.35 * score_momentum
|
||||||
@@ -154,7 +158,7 @@ class HIFFIRankingEngine:
|
|||||||
+ 0.15 * score_consistency
|
+ 0.15 * score_consistency
|
||||||
) * 100.0
|
) * 100.0
|
||||||
|
|
||||||
conf_multiplier = 0.8 + 0.2 * out["confidence_score"].clip(0.0, 1.0)
|
conf_multiplier = 0.8 + 0.2 * out.get("confidence_score", pd.Series(1.0, index=out.index)).clip(0.0, 1.0)
|
||||||
out["production_hiffi_score"] = (hiffi_raw * conf_multiplier).round(2)
|
out["production_hiffi_score"] = (hiffi_raw * conf_multiplier).round(2)
|
||||||
|
|
||||||
eligible_mask = out["is_rank_eligible"]
|
eligible_mask = out["is_rank_eligible"]
|
||||||
@@ -183,6 +187,12 @@ class HIFFIRankingEngine:
|
|||||||
"""
|
"""
|
||||||
out = df.copy()
|
out = df.copy()
|
||||||
|
|
||||||
|
# Schema aliasing for raw input fallbacks
|
||||||
|
if "production_hiffi_score" not in out.columns and "hiffi_score" in out.columns:
|
||||||
|
out["production_hiffi_score"] = out["hiffi_score"]
|
||||||
|
if "production_hiffi_rank" not in out.columns and "hiffi_rank" in out.columns:
|
||||||
|
out["production_hiffi_rank"] = out["hiffi_rank"]
|
||||||
|
|
||||||
base_weights = {
|
base_weights = {
|
||||||
"yt": 0.50,
|
"yt": 0.50,
|
||||||
"sp_monthly": 0.25,
|
"sp_monthly": 0.25,
|
||||||
@@ -311,7 +321,8 @@ class HIFFIRankingEngine:
|
|||||||
- Top 10 requires all 3 platforms (Tier 3), strictly verified and fresh.
|
- Top 10 requires all 3 platforms (Tier 3), strictly verified and fresh.
|
||||||
- Top 50 requires at least 2 platforms (Tier >= 2), strictly verified and fresh.
|
- Top 50 requires at least 2 platforms (Tier >= 2), strictly verified and fresh.
|
||||||
- Freshness Thresholds: YouTube <= 7d, Spotify <= 7d, Instagram <= 14d.
|
- Freshness Thresholds: YouTube <= 7d, Spotify <= 7d, Instagram <= 14d.
|
||||||
- Fail-closed: missing timestamp columns or unverified statuses evaluate
|
- Integrity Gate: Artists with status_flag == 'FAILED_LOOKUP' are disqualified from YouTube credit.
|
||||||
|
- Fail-closed: missing timestamp columns, stale records, or unverified statuses evaluate
|
||||||
strictly to False and are logged to evidence_exclusions.
|
strictly to False and are logged to evidence_exclusions.
|
||||||
- Preserves raw hiffi_xp_score and raw hiffi_xp_rank completely unchanged.
|
- Preserves raw hiffi_xp_score and raw hiffi_xp_rank completely unchanged.
|
||||||
- Produces consecutive, unique ranks in 'hiffi_xp_published_rank' (1 to N).
|
- Produces consecutive, unique ranks in 'hiffi_xp_published_rank' (1 to N).
|
||||||
@@ -321,8 +332,11 @@ class HIFFIRankingEngine:
|
|||||||
|
|
||||||
exclusions = [[] for _ in range(len(out))]
|
exclusions = [[] for _ in range(len(out))]
|
||||||
|
|
||||||
# 1. YouTube Validation (Score present + Freshness <= 7 days)
|
# 1. YouTube Validation (Score present + status not FAILED_LOOKUP + Freshness <= 7 days)
|
||||||
has_yt_val = out["production_hiffi_score"].notna()
|
status_col = out["status_flag"] if "status_flag" in out.columns else pd.Series("COMPLETE", index=out.index)
|
||||||
|
has_yt_val = out["production_hiffi_score"].notna() if "production_hiffi_score" in out.columns else pd.Series(False, index=out.index)
|
||||||
|
is_lookup_failed = status_col == "FAILED_LOOKUP"
|
||||||
|
|
||||||
if "youtube_updated_at" in out.columns:
|
if "youtube_updated_at" in out.columns:
|
||||||
yt_fresh = out["youtube_updated_at"].apply(
|
yt_fresh = out["youtube_updated_at"].apply(
|
||||||
lambda x: self._is_signal_fresh(x, max_days=7.0, reference_time=ref_time)
|
lambda x: self._is_signal_fresh(x, max_days=7.0, reference_time=ref_time)
|
||||||
@@ -330,13 +344,16 @@ class HIFFIRankingEngine:
|
|||||||
else:
|
else:
|
||||||
yt_fresh = pd.Series(False, index=out.index)
|
yt_fresh = pd.Series(False, index=out.index)
|
||||||
|
|
||||||
for idx, (val, fresh) in enumerate(zip(has_yt_val, yt_fresh)):
|
for idx, (val, failed, fresh) in enumerate(zip(has_yt_val, is_lookup_failed, yt_fresh)):
|
||||||
if val and not fresh:
|
if failed:
|
||||||
|
exclusions[idx].append("FAILED_YOUTUBE_LOOKUP")
|
||||||
|
elif val and not fresh:
|
||||||
exclusions[idx].append("STALE_OR_MISSING_YOUTUBE_TIMESTAMP")
|
exclusions[idx].append("STALE_OR_MISSING_YOUTUBE_TIMESTAMP")
|
||||||
valid_yt = has_yt_val & yt_fresh
|
|
||||||
|
valid_yt = has_yt_val & (~is_lookup_failed) & yt_fresh
|
||||||
|
|
||||||
# 2. Spotify Validation (Telemetry present + Freshness <= 7 days)
|
# 2. Spotify Validation (Telemetry present + Freshness <= 7 days)
|
||||||
has_sp_val = out["spotify_monthly_listeners"].notna()
|
has_sp_val = out["spotify_monthly_listeners"].notna() if "spotify_monthly_listeners" in out.columns else pd.Series(False, index=out.index)
|
||||||
if "spotify_updated_at" in out.columns:
|
if "spotify_updated_at" in out.columns:
|
||||||
sp_fresh = out["spotify_updated_at"].apply(
|
sp_fresh = out["spotify_updated_at"].apply(
|
||||||
lambda x: self._is_signal_fresh(x, max_days=7.0, reference_time=ref_time)
|
lambda x: self._is_signal_fresh(x, max_days=7.0, reference_time=ref_time)
|
||||||
@@ -350,7 +367,7 @@ class HIFFIRankingEngine:
|
|||||||
valid_sp = has_sp_val & sp_fresh
|
valid_sp = has_sp_val & sp_fresh
|
||||||
|
|
||||||
# 3. Instagram Validation (Followers present + Approved Status + Freshness <= 14 days)
|
# 3. Instagram Validation (Followers present + Approved Status + Freshness <= 14 days)
|
||||||
has_ig_val = out["instagram_followers"].notna()
|
has_ig_val = out["instagram_followers"].notna() if "instagram_followers" in out.columns else pd.Series(False, index=out.index)
|
||||||
verified_statuses = {"AUTOMATED_VERIFIED", "MANUAL_VERIFIED"}
|
verified_statuses = {"AUTOMATED_VERIFIED", "MANUAL_VERIFIED"}
|
||||||
|
|
||||||
if "instagram_verification_status" in out.columns:
|
if "instagram_verification_status" in out.columns:
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ from pathlib import Path
|
|||||||
import sys
|
import sys
|
||||||
from typing import Dict, Optional
|
from typing import Dict, Optional
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
|
|
||||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||||
@@ -22,6 +21,7 @@ from collectors.instagram_verifier import InstagramCandidateVerifier
|
|||||||
from collectors.kworb_spotify_collector import KworbStreamingCollector
|
from collectors.kworb_spotify_collector import KworbStreamingCollector
|
||||||
from collectors.spotify_api_collector import SpotifyAPICollector
|
from collectors.spotify_api_collector import SpotifyAPICollector
|
||||||
from collectors.spotify_fallback_collector import SpotifyFallbackCollector
|
from collectors.spotify_fallback_collector import SpotifyFallbackCollector
|
||||||
|
from core.ranking_engine import HIFFIRankingEngine
|
||||||
|
|
||||||
DATA_DIR = PROJECT_ROOT / "data"
|
DATA_DIR = PROJECT_ROOT / "data"
|
||||||
BASE_DATASET = DATA_DIR / "hiffi_multiplatform_500.csv"
|
BASE_DATASET = DATA_DIR / "hiffi_multiplatform_500.csv"
|
||||||
@@ -39,70 +39,16 @@ def load_handle_overrides() -> Dict[str, object]:
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
def _normalize_metric(series: pd.Series) -> pd.Series:
|
|
||||||
"""
|
|
||||||
Direct array-based log-scaling and min-max normalization to [0.0, 1.0].
|
|
||||||
Preserves index alignment and avoids dictionary/map collisions.
|
|
||||||
"""
|
|
||||||
clean_series = pd.to_numeric(series, errors="coerce")
|
|
||||||
out = pd.Series(0.0, index=series.index, dtype=float)
|
|
||||||
valid_mask = clean_series.notna() & (clean_series > 0)
|
|
||||||
|
|
||||||
if not valid_mask.any():
|
|
||||||
return out
|
|
||||||
|
|
||||||
log_vals = np.log1p(clean_series[valid_mask])
|
|
||||||
min_v, max_v = log_vals.min(), log_vals.max()
|
|
||||||
|
|
||||||
if max_v > min_v:
|
|
||||||
out.loc[valid_mask] = (log_vals - min_v) / (max_v - min_v)
|
|
||||||
else:
|
|
||||||
out.loc[valid_mask] = 1.0
|
|
||||||
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_hiffi_xp_scores(df: pd.DataFrame) -> pd.DataFrame:
|
def calculate_hiffi_xp_scores(df: pd.DataFrame) -> pd.DataFrame:
|
||||||
"""
|
"""
|
||||||
Computes experimental multi-platform HIFFI-XP scores and ranks.
|
Delegates to the canonical HIFFIRankingEngine to enforce zero-imputation,
|
||||||
Strictly preserves production_hiffi_rank and production_hiffi_score.
|
proportional dynamic redistribution, anomaly detection, and publication eligibility gates.
|
||||||
Weights: 50% YouTube production baseline, 30% Spotify telemetry, 20% Instagram reach.
|
|
||||||
"""
|
"""
|
||||||
out = df.copy()
|
engine = HIFFIRankingEngine()
|
||||||
|
scored = engine.calculate_hiffi_xp(df)
|
||||||
# Ensure baseline columns exist
|
flagged = engine.detect_metric_anomalies(scored)
|
||||||
if "production_hiffi_score" not in out.columns and "hiffi_score" in out.columns:
|
gated = engine.apply_publication_eligibility_gates(flagged)
|
||||||
out["production_hiffi_score"] = out["hiffi_score"]
|
return gated
|
||||||
if "production_hiffi_rank" not in out.columns and "hiffi_rank" in out.columns:
|
|
||||||
out["production_hiffi_rank"] = out["hiffi_rank"]
|
|
||||||
|
|
||||||
# 1. YouTube production baseline signal (0.0 to 1.0)
|
|
||||||
yt_score = pd.to_numeric(out.get("production_hiffi_score", out.get("hiffi_score", 0)), errors="coerce").fillna(0.0)
|
|
||||||
# If production scores are already on a 0-100 scale, normalize directly
|
|
||||||
if yt_score.max() > 1.0:
|
|
||||||
yt_norm = (yt_score / 100.0).clip(0.0, 1.0)
|
|
||||||
else:
|
|
||||||
yt_norm = _normalize_metric(yt_score)
|
|
||||||
|
|
||||||
# 2. Spotify streaming signal (0.0 to 1.0)
|
|
||||||
sp_norm = _normalize_metric(out.get("spotify_monthly_listeners"))
|
|
||||||
|
|
||||||
# 3. Instagram reach signal (0.0 to 1.0)
|
|
||||||
ig_norm = _normalize_metric(out.get("instagram_followers"))
|
|
||||||
|
|
||||||
# Multi-platform composite weighting: 50% YT + 30% SP + 20% IG
|
|
||||||
xp_composite = (0.50 * yt_norm + 0.30 * sp_norm + 0.20 * ig_norm) * 100.0
|
|
||||||
out["hiffi_xp_score"] = xp_composite.clip(lower=0.0, upper=100.0).round(2)
|
|
||||||
|
|
||||||
# Monotonic ranking (higher score -> lower rank integer)
|
|
||||||
out["hiffi_xp_rank"] = out["hiffi_xp_score"].rank(ascending=False, method="min").astype(int)
|
|
||||||
|
|
||||||
# Calculate rank delta compared to YouTube production baseline
|
|
||||||
if "production_hiffi_rank" in out.columns:
|
|
||||||
prod_rank = pd.to_numeric(out["production_hiffi_rank"], errors="coerce")
|
|
||||||
out["rank_delta"] = prod_rank - out["hiffi_xp_rank"]
|
|
||||||
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def run_multiplatform_pipeline(
|
def run_multiplatform_pipeline(
|
||||||
@@ -125,6 +71,12 @@ def run_multiplatform_pipeline(
|
|||||||
initial_ig_verified = df["instagram_followers"].notna().sum() if "instagram_followers" in df.columns else 0
|
initial_ig_verified = df["instagram_followers"].notna().sum() if "instagram_followers" in df.columns else 0
|
||||||
print(f"[Loader] Loaded {len(df)} checkpoints ({initial_ig_verified} verified follower records).")
|
print(f"[Loader] Loaded {len(df)} checkpoints ({initial_ig_verified} verified follower records).")
|
||||||
|
|
||||||
|
now_utc = datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
# Ensure baseline YouTube timestamps exist for freshness validation
|
||||||
|
if "youtube_updated_at" not in df.columns or df["youtube_updated_at"].isna().all():
|
||||||
|
df["youtube_updated_at"] = df.get("updated_at", now_utc)
|
||||||
|
|
||||||
# 1. Instagram Candidate Verification & Overrides Ingestion
|
# 1. Instagram Candidate Verification & Overrides Ingestion
|
||||||
print("\n[Phase 1] Executing Automated Instagram Verification & Provenance...")
|
print("\n[Phase 1] Executing Automated Instagram Verification & Provenance...")
|
||||||
overrides = load_handle_overrides()
|
overrides = load_handle_overrides()
|
||||||
@@ -137,8 +89,6 @@ def run_multiplatform_pipeline(
|
|||||||
ig_evidence = []
|
ig_evidence = []
|
||||||
ig_dates = []
|
ig_dates = []
|
||||||
|
|
||||||
now_utc = datetime.now(timezone.utc).isoformat()
|
|
||||||
|
|
||||||
for _, row in df.iterrows():
|
for _, row in df.iterrows():
|
||||||
name = str(row["name"]).strip()
|
name = str(row["name"]).strip()
|
||||||
override = overrides.get(name)
|
override = overrides.get(name)
|
||||||
@@ -169,7 +119,7 @@ def run_multiplatform_pipeline(
|
|||||||
ver_res = verifier.evaluate_candidate(
|
ver_res = verifier.evaluate_candidate(
|
||||||
artist_name=name,
|
artist_name=name,
|
||||||
candidate_handle=cand_handle,
|
candidate_handle=cand_handle,
|
||||||
follower_count=cand_followers if pd.notna(cand_followers) else 0
|
follower_count=cand_followers if pd.notna(cand_followers) else None
|
||||||
)
|
)
|
||||||
elif hasattr(verifier, "verify_candidate"):
|
elif hasattr(verifier, "verify_candidate"):
|
||||||
ver_res = verifier.verify_candidate(
|
ver_res = verifier.verify_candidate(
|
||||||
@@ -199,6 +149,7 @@ def run_multiplatform_pipeline(
|
|||||||
df["instagram_verification_status"] = ig_status
|
df["instagram_verification_status"] = ig_status
|
||||||
df["instagram_audit_evidence"] = ig_evidence
|
df["instagram_audit_evidence"] = ig_evidence
|
||||||
df["instagram_collected_at"] = ig_dates
|
df["instagram_collected_at"] = ig_dates
|
||||||
|
df["instagram_updated_at"] = ig_dates
|
||||||
|
|
||||||
# 2. Spotify Multi-Tier Telemetry Ingestion (Tier 1: Kworb -> Tier 2: Official API -> Tier 3: Embed Fallback)
|
# 2. Spotify Multi-Tier Telemetry Ingestion (Tier 1: Kworb -> Tier 2: Official API -> Tier 3: Embed Fallback)
|
||||||
print("\n[Phase 2] Ingesting Spotify Telemetry (Tier 1: Kworb -> Tier 2: Official API -> Tier 3: Embed Fallback)...")
|
print("\n[Phase 2] Ingesting Spotify Telemetry (Tier 1: Kworb -> Tier 2: Official API -> Tier 3: Embed Fallback)...")
|
||||||
@@ -264,8 +215,9 @@ def run_multiplatform_pipeline(
|
|||||||
df["spotify_data_source"] = sp_source
|
df["spotify_data_source"] = sp_source
|
||||||
df["spotify_status"] = sp_status
|
df["spotify_status"] = sp_status
|
||||||
df["spotify_collected_at"] = sp_dates
|
df["spotify_collected_at"] = sp_dates
|
||||||
|
df["spotify_updated_at"] = sp_dates
|
||||||
|
|
||||||
# 3. HIFFI-XP Score Normalization & Monotonic Ranking
|
# 3. Canonical Scoring & Governance
|
||||||
scored_df = calculate_hiffi_xp_scores(df)
|
scored_df = calculate_hiffi_xp_scores(df)
|
||||||
|
|
||||||
# Save finalized cohort
|
# Save finalized cohort
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ if str(PROJECT_ROOT) not in sys.path:
|
|||||||
from core.ranking_engine import HIFFIRankingEngine
|
from core.ranking_engine import HIFFIRankingEngine
|
||||||
|
|
||||||
DATASET_PATH = PROJECT_ROOT / "data" / "hiffi_multiplatform_500.csv"
|
DATASET_PATH = PROJECT_ROOT / "data" / "hiffi_multiplatform_500.csv"
|
||||||
|
DATASET_ANCHOR_TIME = pd.Timestamp("2026-10-26T12:00:00Z")
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="module")
|
@pytest.fixture(scope="module")
|
||||||
@@ -170,15 +171,22 @@ def test_instagram_verifier_suspicious_follower_count():
|
|||||||
assert followers is None or status != "AUTOMATED_VERIFIED"
|
assert followers is None or status != "AUTOMATED_VERIFIED"
|
||||||
|
|
||||||
|
|
||||||
def test_spotify_fallback_null_safety():
|
def test_spotify_fallback_null_safety(monkeypatch):
|
||||||
"""Verify Spotify embed fallback safely returns structured null dictionary on unindexed items."""
|
"""Verify Spotify embed fallback safely returns structured null dictionary on unindexed items."""
|
||||||
from collectors.spotify_fallback_collector import SpotifyFallbackCollector
|
from collectors.spotify_fallback_collector import SpotifyFallbackCollector
|
||||||
|
|
||||||
|
# Mock unindexed response to avoid brittle offline network failures
|
||||||
|
class MockResponse:
|
||||||
|
status_code = 404
|
||||||
|
text = "Not Found"
|
||||||
|
|
||||||
|
monkeypatch.setattr("requests.Session.get", lambda *args, **kwargs: MockResponse())
|
||||||
|
|
||||||
fallback = SpotifyFallbackCollector()
|
fallback = SpotifyFallbackCollector()
|
||||||
res = fallback.fetch_artist_fallback("Unknown Artist", "spotify:artist:invalid_or_missing_id_999999")
|
res = fallback.fetch_artist_fallback("Unknown Artist", "spotify:artist:invalid_or_missing_id_999999")
|
||||||
assert res["spotify_monthly_listeners"] is None
|
assert res["spotify_monthly_listeners"] is None
|
||||||
assert res["spotify_daily_change"] is None
|
assert res["spotify_daily_change"] is None
|
||||||
assert "UNAVAILABLE" in str(res["spotify_status"]) or "EMBED" in str(res["spotify_status"])
|
assert "NOT_FOUND" in str(res["spotify_status"]) or "UNAVAILABLE" in str(res["spotify_status"])
|
||||||
|
|
||||||
|
|
||||||
def test_spotify_fallback_mocked_network_error(monkeypatch):
|
def test_spotify_fallback_mocked_network_error(monkeypatch):
|
||||||
@@ -294,9 +302,7 @@ def test_score_dominance(dataset):
|
|||||||
|
|
||||||
|
|
||||||
def test_kendall_tau_diagnostic(dataset):
|
def test_kendall_tau_diagnostic(dataset):
|
||||||
"""
|
"""Evaluates rank correlation between baseline and composite rank as a diagnostic."""
|
||||||
Evaluates rank correlation between baseline and composite rank as a diagnostic.
|
|
||||||
"""
|
|
||||||
from scipy.stats import kendalltau
|
from scipy.stats import kendalltau
|
||||||
|
|
||||||
valid = dataset.dropna(subset=["production_hiffi_rank", "hiffi_xp_rank"]).copy()
|
valid = dataset.dropna(subset=["production_hiffi_rank", "hiffi_xp_rank"]).copy()
|
||||||
@@ -316,10 +322,7 @@ def test_score_quantile_spread(dataset):
|
|||||||
|
|
||||||
|
|
||||||
def test_raw_dataset_strict_nan_retention(dataset):
|
def test_raw_dataset_strict_nan_retention(dataset):
|
||||||
"""
|
"""Verifies that unindexed secondary fields preserve genuine nulls and guard against synthetic zeroes."""
|
||||||
Verifies that unindexed secondary fields preserve genuine nulls
|
|
||||||
and guards against synthetic zeroes or string-serialized nulls ('nan', 'none', 'null').
|
|
||||||
"""
|
|
||||||
secondary_metric_cols = [
|
secondary_metric_cols = [
|
||||||
"spotify_monthly_listeners",
|
"spotify_monthly_listeners",
|
||||||
"spotify_daily_change",
|
"spotify_daily_change",
|
||||||
@@ -329,16 +332,13 @@ def test_raw_dataset_strict_nan_retention(dataset):
|
|||||||
for col in secondary_metric_cols:
|
for col in secondary_metric_cols:
|
||||||
if col in dataset.columns:
|
if col in dataset.columns:
|
||||||
series = dataset[col]
|
series = dataset[col]
|
||||||
# 1. Ensure genuine NaN values exist for unindexed artists
|
|
||||||
assert series.isna().any(), f"Column '{col}' contains zero null values across cohort."
|
assert series.isna().any(), f"Column '{col}' contains zero null values across cohort."
|
||||||
|
|
||||||
# 2. Guard against string serialization traps ('nan', 'None', 'null')
|
|
||||||
string_nulls = series.dropna().astype(str).str.strip().str.lower().isin(
|
string_nulls = series.dropna().astype(str).str.strip().str.lower().isin(
|
||||||
["nan", "none", "null", "undefined", "n/a"]
|
["nan", "none", "null", "undefined", "n/a"]
|
||||||
)
|
)
|
||||||
assert not string_nulls.any(), f"Column '{col}' contains string-serialized null tokens."
|
assert not string_nulls.any(), f"Column '{col}' contains string-serialized null tokens."
|
||||||
|
|
||||||
# 3. Guard against numerical zero substitution
|
|
||||||
numeric_vals = pd.to_numeric(series.dropna(), errors="coerce")
|
numeric_vals = pd.to_numeric(series.dropna(), errors="coerce")
|
||||||
if col in ["spotify_monthly_listeners", "instagram_followers"]:
|
if col in ["spotify_monthly_listeners", "instagram_followers"]:
|
||||||
zero_records = (numeric_vals == 0).sum()
|
zero_records = (numeric_vals == 0).sum()
|
||||||
@@ -349,9 +349,9 @@ def test_raw_dataset_strict_nan_retention(dataset):
|
|||||||
|
|
||||||
|
|
||||||
def test_publication_eligibility_gates(dataset):
|
def test_publication_eligibility_gates(dataset):
|
||||||
"""Verifies that publication gates enforce evidence tiers without altering raw scores."""
|
"""Verifies that publication gates enforce evidence tiers using anchored timestamps without mutating raw scores."""
|
||||||
engine = HIFFIRankingEngine()
|
engine = HIFFIRankingEngine()
|
||||||
gated = engine.apply_publication_eligibility_gates(dataset)
|
gated = engine.apply_publication_eligibility_gates(dataset, reference_time=DATASET_ANCHOR_TIME)
|
||||||
|
|
||||||
# 1. Raw scores must remain unchanged
|
# 1. Raw scores must remain unchanged
|
||||||
assert (gated["hiffi_xp_score"] == dataset["hiffi_xp_score"]).all(), (
|
assert (gated["hiffi_xp_score"] == dataset["hiffi_xp_score"]).all(), (
|
||||||
@@ -380,14 +380,44 @@ def test_publication_eligibility_gates(dataset):
|
|||||||
assert ranks == expected_ranks, "Published ranks contain gaps or duplicates."
|
assert ranks == expected_ranks, "Published ranks contain gaps or duplicates."
|
||||||
|
|
||||||
|
|
||||||
|
def test_publication_gate_blocks_failed_youtube_lookup():
|
||||||
|
"""Confirms artists with status_flag == 'FAILED_LOOKUP' are disqualified from YouTube evidence credit."""
|
||||||
|
engine = HIFFIRankingEngine()
|
||||||
|
now_iso = DATASET_ANCHOR_TIME.isoformat()
|
||||||
|
|
||||||
|
test_df = pd.DataFrame([
|
||||||
|
{
|
||||||
|
"name": "Failed Lookup Artist",
|
||||||
|
"status_flag": "FAILED_LOOKUP",
|
||||||
|
"production_hiffi_score": 7.98,
|
||||||
|
"youtube_updated_at": now_iso,
|
||||||
|
"spotify_monthly_listeners": 25_000_000,
|
||||||
|
"spotify_updated_at": now_iso,
|
||||||
|
"instagram_followers": 10_000_000,
|
||||||
|
"instagram_verification_status": "AUTOMATED_VERIFIED",
|
||||||
|
"instagram_updated_at": now_iso,
|
||||||
|
"hiffi_xp_score": 85.0,
|
||||||
|
"hiffi_xp_rank": 1
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
gated = engine.apply_publication_eligibility_gates(test_df, reference_time=DATASET_ANCHOR_TIME)
|
||||||
|
row = gated.iloc[0]
|
||||||
|
|
||||||
|
# Blocked from YouTube -> only Spotify + IG valid -> Tier 2
|
||||||
|
assert row["platform_evidence_tier"] == 2
|
||||||
|
assert "FAILED_YOUTUBE_LOOKUP" in row["evidence_exclusions"]
|
||||||
|
|
||||||
|
|
||||||
def test_publication_gate_blocks_unverified_identity():
|
def test_publication_gate_blocks_unverified_identity():
|
||||||
"""Confirms that UNCERTAIN_NEEDS_REVIEW status does not count toward evidence tiers."""
|
"""Confirms that UNCERTAIN_NEEDS_REVIEW status does not count toward evidence tiers."""
|
||||||
engine = HIFFIRankingEngine()
|
engine = HIFFIRankingEngine()
|
||||||
now_iso = pd.Timestamp.now(tz="UTC").isoformat()
|
now_iso = DATASET_ANCHOR_TIME.isoformat()
|
||||||
|
|
||||||
test_df = pd.DataFrame([
|
test_df = pd.DataFrame([
|
||||||
{
|
{
|
||||||
"name": "Artist Pending Verification",
|
"name": "Artist Pending Verification",
|
||||||
|
"status_flag": "COMPLETE",
|
||||||
"production_hiffi_score": 95.0,
|
"production_hiffi_score": 95.0,
|
||||||
"youtube_updated_at": now_iso,
|
"youtube_updated_at": now_iso,
|
||||||
"spotify_monthly_listeners": 40_000_000,
|
"spotify_monthly_listeners": 40_000_000,
|
||||||
@@ -400,6 +430,7 @@ def test_publication_gate_blocks_unverified_identity():
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Artist Fully Verified",
|
"name": "Artist Fully Verified",
|
||||||
|
"status_flag": "COMPLETE",
|
||||||
"production_hiffi_score": 90.0,
|
"production_hiffi_score": 90.0,
|
||||||
"youtube_updated_at": now_iso,
|
"youtube_updated_at": now_iso,
|
||||||
"spotify_monthly_listeners": 35_000_000,
|
"spotify_monthly_listeners": 35_000_000,
|
||||||
@@ -412,7 +443,7 @@ def test_publication_gate_blocks_unverified_identity():
|
|||||||
}
|
}
|
||||||
])
|
])
|
||||||
|
|
||||||
gated = engine.apply_publication_eligibility_gates(test_df)
|
gated = engine.apply_publication_eligibility_gates(test_df, reference_time=DATASET_ANCHOR_TIME)
|
||||||
pending_row = gated[gated["name"] == "Artist Pending Verification"].iloc[0]
|
pending_row = gated[gated["name"] == "Artist Pending Verification"].iloc[0]
|
||||||
verified_row = gated[gated["name"] == "Artist Fully Verified"].iloc[0]
|
verified_row = gated[gated["name"] == "Artist Fully Verified"].iloc[0]
|
||||||
|
|
||||||
@@ -423,11 +454,12 @@ def test_publication_gate_blocks_unverified_identity():
|
|||||||
def test_publication_gate_missing_column_fails_closed():
|
def test_publication_gate_missing_column_fails_closed():
|
||||||
"""Confirms that omitting the verification column fails closed (gives 0 IG credit)."""
|
"""Confirms that omitting the verification column fails closed (gives 0 IG credit)."""
|
||||||
engine = HIFFIRankingEngine()
|
engine = HIFFIRankingEngine()
|
||||||
now_iso = pd.Timestamp.now(tz="UTC").isoformat()
|
now_iso = DATASET_ANCHOR_TIME.isoformat()
|
||||||
|
|
||||||
test_df = pd.DataFrame([
|
test_df = pd.DataFrame([
|
||||||
{
|
{
|
||||||
"name": "Artist No Status Column",
|
"name": "Artist No Status Column",
|
||||||
|
"status_flag": "COMPLETE",
|
||||||
"production_hiffi_score": 95.0,
|
"production_hiffi_score": 95.0,
|
||||||
"youtube_updated_at": now_iso,
|
"youtube_updated_at": now_iso,
|
||||||
"spotify_monthly_listeners": 40_000_000,
|
"spotify_monthly_listeners": 40_000_000,
|
||||||
@@ -439,18 +471,19 @@ def test_publication_gate_missing_column_fails_closed():
|
|||||||
}
|
}
|
||||||
])
|
])
|
||||||
|
|
||||||
gated = engine.apply_publication_eligibility_gates(test_df)
|
gated = engine.apply_publication_eligibility_gates(test_df, reference_time=DATASET_ANCHOR_TIME)
|
||||||
assert gated.loc[0, "platform_evidence_tier"] == 2
|
assert gated.loc[0, "platform_evidence_tier"] == 2
|
||||||
|
|
||||||
|
|
||||||
def test_publication_gate_zero_multiplatform_cohort():
|
def test_publication_gate_zero_multiplatform_cohort():
|
||||||
"""Confirms an entirely Tier 1 cohort produces consecutive ranks and flags an audited shortfall."""
|
"""Confirms an entirely Tier 1 cohort produces consecutive ranks and flags an audited shortfall."""
|
||||||
engine = HIFFIRankingEngine()
|
engine = HIFFIRankingEngine()
|
||||||
now_iso = pd.Timestamp.now(tz="UTC").isoformat()
|
now_iso = DATASET_ANCHOR_TIME.isoformat()
|
||||||
|
|
||||||
records = [
|
records = [
|
||||||
{
|
{
|
||||||
"name": f"Solo Artist {i+1}",
|
"name": f"Solo Artist {i+1}",
|
||||||
|
"status_flag": "COMPLETE",
|
||||||
"production_hiffi_score": 85.0 - i,
|
"production_hiffi_score": 85.0 - i,
|
||||||
"youtube_updated_at": now_iso,
|
"youtube_updated_at": now_iso,
|
||||||
"spotify_monthly_listeners": np.nan,
|
"spotify_monthly_listeners": np.nan,
|
||||||
@@ -465,13 +498,10 @@ def test_publication_gate_zero_multiplatform_cohort():
|
|||||||
]
|
]
|
||||||
|
|
||||||
tier1_df = pd.DataFrame(records)
|
tier1_df = pd.DataFrame(records)
|
||||||
gated, audit = engine.apply_publication_eligibility_gates(tier1_df, return_metadata=True)
|
gated, audit = engine.apply_publication_eligibility_gates(tier1_df, reference_time=DATASET_ANCHOR_TIME, return_metadata=True)
|
||||||
|
|
||||||
# Must retain unique contiguous ordering 1 to 25
|
|
||||||
assert gated["hiffi_xp_published_rank"].tolist() == list(range(1, 26))
|
assert gated["hiffi_xp_published_rank"].tolist() == list(range(1, 26))
|
||||||
# All records remain Tier 1
|
|
||||||
assert (gated["platform_evidence_tier"] == 1).all()
|
assert (gated["platform_evidence_tier"] == 1).all()
|
||||||
# Shortfall must be explicitly reported
|
|
||||||
assert audit["top_10_shortfall"] is True
|
assert audit["top_10_shortfall"] is True
|
||||||
assert audit["top_50_shortfall"] is True
|
assert audit["top_50_shortfall"] is True
|
||||||
|
|
||||||
@@ -479,11 +509,11 @@ def test_publication_gate_zero_multiplatform_cohort():
|
|||||||
def test_freshness_gate_stale_timestamps_demote_tier():
|
def test_freshness_gate_stale_timestamps_demote_tier():
|
||||||
"""Validates that metrics older than threshold lose evidence tier credit while retaining raw score."""
|
"""Validates that metrics older than threshold lose evidence tier credit while retaining raw score."""
|
||||||
engine = HIFFIRankingEngine()
|
engine = HIFFIRankingEngine()
|
||||||
ref_time = pd.Timestamp("2026-10-26T12:00:00Z")
|
ref_time = DATASET_ANCHOR_TIME
|
||||||
|
|
||||||
# Artist has all metrics present, but Spotify is 10 days old (>7d) and IG is 20 days old (>14d)
|
|
||||||
test_df = pd.DataFrame([{
|
test_df = pd.DataFrame([{
|
||||||
"name": "Artist Stale Telemetry",
|
"name": "Artist Stale Telemetry",
|
||||||
|
"status_flag": "COMPLETE",
|
||||||
"production_hiffi_score": 90.0,
|
"production_hiffi_score": 90.0,
|
||||||
"youtube_updated_at": "2026-10-25T12:00:00Z", # 1 day old (Fresh)
|
"youtube_updated_at": "2026-10-25T12:00:00Z", # 1 day old (Fresh)
|
||||||
"spotify_monthly_listeners": 25_000_000,
|
"spotify_monthly_listeners": 25_000_000,
|
||||||
@@ -498,7 +528,6 @@ def test_freshness_gate_stale_timestamps_demote_tier():
|
|||||||
gated = engine.apply_publication_eligibility_gates(test_df, reference_time=ref_time)
|
gated = engine.apply_publication_eligibility_gates(test_df, reference_time=ref_time)
|
||||||
row = gated.iloc[0]
|
row = gated.iloc[0]
|
||||||
|
|
||||||
# Only YouTube is fresh; Tier must demote to 1
|
|
||||||
assert row["platform_evidence_tier"] == 1
|
assert row["platform_evidence_tier"] == 1
|
||||||
assert row["hiffi_xp_score"] == 88.5
|
assert row["hiffi_xp_score"] == 88.5
|
||||||
assert "STALE_OR_MISSING_SPOTIFY_TIMESTAMP" in row["evidence_exclusions"]
|
assert "STALE_OR_MISSING_SPOTIFY_TIMESTAMP" in row["evidence_exclusions"]
|
||||||
@@ -508,10 +537,11 @@ def test_freshness_gate_stale_timestamps_demote_tier():
|
|||||||
def test_freshness_gate_missing_or_malformed_timestamp():
|
def test_freshness_gate_missing_or_malformed_timestamp():
|
||||||
"""Fails closed when timestamp string is empty, null, or malformed."""
|
"""Fails closed when timestamp string is empty, null, or malformed."""
|
||||||
engine = HIFFIRankingEngine()
|
engine = HIFFIRankingEngine()
|
||||||
ref_time = pd.Timestamp("2026-10-26T12:00:00Z")
|
ref_time = DATASET_ANCHOR_TIME
|
||||||
|
|
||||||
test_df = pd.DataFrame([{
|
test_df = pd.DataFrame([{
|
||||||
"name": "Artist Malformed Timestamp",
|
"name": "Artist Malformed Timestamp",
|
||||||
|
"status_flag": "COMPLETE",
|
||||||
"production_hiffi_score": 85.0,
|
"production_hiffi_score": 85.0,
|
||||||
"youtube_updated_at": "invalid_date_format",
|
"youtube_updated_at": "invalid_date_format",
|
||||||
"spotify_monthly_listeners": 15_000_000,
|
"spotify_monthly_listeners": 15_000_000,
|
||||||
@@ -526,7 +556,6 @@ def test_freshness_gate_missing_or_malformed_timestamp():
|
|||||||
gated = engine.apply_publication_eligibility_gates(test_df, reference_time=ref_time)
|
gated = engine.apply_publication_eligibility_gates(test_df, reference_time=ref_time)
|
||||||
row = gated.iloc[0]
|
row = gated.iloc[0]
|
||||||
|
|
||||||
# Only Instagram is valid & fresh -> Tier 1
|
|
||||||
assert row["platform_evidence_tier"] == 1
|
assert row["platform_evidence_tier"] == 1
|
||||||
assert "STALE_OR_MISSING_YOUTUBE_TIMESTAMP" in row["evidence_exclusions"]
|
assert "STALE_OR_MISSING_YOUTUBE_TIMESTAMP" in row["evidence_exclusions"]
|
||||||
assert "STALE_OR_MISSING_SPOTIFY_TIMESTAMP" in row["evidence_exclusions"]
|
assert "STALE_OR_MISSING_SPOTIFY_TIMESTAMP" in row["evidence_exclusions"]
|
||||||
@@ -538,6 +567,7 @@ def test_freshness_gate_missing_timestamp_columns_fail_closed():
|
|||||||
|
|
||||||
test_df = pd.DataFrame([{
|
test_df = pd.DataFrame([{
|
||||||
"name": "Artist No Timestamps",
|
"name": "Artist No Timestamps",
|
||||||
|
"status_flag": "COMPLETE",
|
||||||
"production_hiffi_score": 90.0,
|
"production_hiffi_score": 90.0,
|
||||||
"spotify_monthly_listeners": 20_000_000,
|
"spotify_monthly_listeners": 20_000_000,
|
||||||
"instagram_followers": 10_000_000,
|
"instagram_followers": 10_000_000,
|
||||||
@@ -546,7 +576,7 @@ def test_freshness_gate_missing_timestamp_columns_fail_closed():
|
|||||||
"hiffi_xp_rank": 1
|
"hiffi_xp_rank": 1
|
||||||
}])
|
}])
|
||||||
|
|
||||||
gated = engine.apply_publication_eligibility_gates(test_df)
|
gated = engine.apply_publication_eligibility_gates(test_df, reference_time=DATASET_ANCHOR_TIME)
|
||||||
row = gated.iloc[0]
|
row = gated.iloc[0]
|
||||||
|
|
||||||
assert row["platform_evidence_tier"] == 0
|
assert row["platform_evidence_tier"] == 0
|
||||||
@@ -591,5 +621,4 @@ def test_anomaly_detection_flags_suspicious_patterns():
|
|||||||
assert flagged.loc[0, "anomaly_flags"] == "CLEAN"
|
assert flagged.loc[0, "anomaly_flags"] == "CLEAN"
|
||||||
assert "SUSPICIOUS_IG_TO_YT_RATIO" in flagged.loc[1, "anomaly_flags"]
|
assert "SUSPICIOUS_IG_TO_YT_RATIO" in flagged.loc[1, "anomaly_flags"]
|
||||||
assert "EXTREME_SPOTIFY_VELOCITY_RATIO" in flagged.loc[2, "anomaly_flags"]
|
assert "EXTREME_SPOTIFY_VELOCITY_RATIO" in flagged.loc[2, "anomaly_flags"]
|
||||||
# Raw scores must remain unaffected
|
|
||||||
assert flagged.loc[1, "hiffi_xp_score"] == 70.0
|
assert flagged.loc[1, "hiffi_xp_score"] == 70.0
|
||||||
Reference in new issue
Block a user