from __future__ import annotations import os import json import hashlib from pathlib import Path from datetime import datetime, timezone import numpy as np import pandas as pd import plotly.express as px import plotly.graph_objects as go import gradio as gr from huggingface_hub import snapshot_download try: from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey CRYPTO_AVAILABLE = True except Exception: Ed25519PublicKey = None CRYPTO_AVAILABLE = False # ============================================================ # Hugging Face storage configuration # ============================================================ STORAGE_REPO_ID = os.getenv("BLOCKFL_STORAGE_REPO_ID", "ashaddams/BlockFL-VSCC-storage") STORAGE_REPO_TYPE = os.getenv("BLOCKFL_STORAGE_REPO_TYPE", "dataset") HF_TOKEN = os.getenv("HF_TOKEN") or os.getenv("HUGGINGFACEHUB_API_TOKEN") BASE = Path(__file__).resolve().parent LOCAL_ASSETS = BASE / "assets" CACHE_DIR = Path(os.getenv("BLOCKFL_STORAGE_CACHE", "/tmp/blockfl_vscc_storage")) # Do not load raw SEER case-level data by default. ALLOW_CASE_LEVEL_FALLBACK = os.getenv("ALLOW_CASE_LEVEL_FALLBACK", "0") == "1" def utc_now(): return datetime.now(timezone.utc).replace(microsecond=0).isoformat() def download_storage_repo() -> Path: """ Downloads/caches the external Hugging Face storage repo. Expected repo: ashaddams/BlockFL-VSCC-storage Default repo_type is "dataset". If your storage repo is a model or space, set the HF Space variable: BLOCKFL_STORAGE_REPO_TYPE=model or: BLOCKFL_STORAGE_REPO_TYPE=space """ CACHE_DIR.mkdir(parents=True, exist_ok=True) allow_patterns = [ "assets/**", "blockfl_vscc/assets/**", "outputs/**/*.csv", "outputs/**/*.json", "blockfl_vscc/outputs/**/*.csv", "blockfl_vscc/outputs/**/*.json", "data/processed/*.csv", "data/processed/*.parquet", "blockfl_vscc/data/processed/*.csv", "blockfl_vscc/data/processed/*.parquet", ] return Path( snapshot_download( repo_id=STORAGE_REPO_ID, repo_type=STORAGE_REPO_TYPE, token=HF_TOKEN, local_dir=str(CACHE_DIR), allow_patterns=allow_patterns, ) ) def resolve_storage_root() -> Path: # Prefer bundled assets if present. if (LOCAL_ASSETS / "tables").exists() or (LOCAL_ASSETS / "json").exists(): return BASE try: snap = download_storage_repo() except Exception as e: print(f"[WARN] Could not download storage repo {STORAGE_REPO_ID}: {e}") return BASE candidates = [ snap, snap / "blockfl_vscc", snap / "BlockFL-VSCC", snap / "BlockFL_VSCC", ] for c in candidates: if (c / "assets").exists() or (c / "outputs").exists() or (c / "data").exists(): return c return snap ROOT = resolve_storage_root() ASSETS = ROOT / "assets" TABLES_ASSET = ASSETS / "tables" JSON_ASSET = ASSETS / "json" OUT = ROOT / "outputs" PH = OUT / "publication_hardening" FINAL = OUT / "final_package" FLB = OUT / "fl_blockchain" BC = OUT / "fully_validated_permissioned_blockchain" BCBENCH = OUT / "blockchain_benchmarks" TIMING = OUT / "end_to_end_timing_benchmark" EXP = OUT / "federated_client_expansion" REALBC = OUT / "real_permissioned_blockchain" # ============================================================ # File helpers # ============================================================ def read_json_path(path: Path, default=None): path = Path(path) if path.exists(): try: return json.loads(path.read_text()) except Exception as e: print(f"[WARN] Could not read JSON {path}: {e}") return default if default is not None else {} return default if default is not None else {} def read_json_first(paths, default=None): for p in paths: p = Path(p) if p.exists(): return read_json_path(p, default=default) return default if default is not None else {} def load_csv_first(paths, default=None): for p in paths: p = Path(p) if p.exists(): try: df0 = pd.read_csv(p) print("[LOAD]", p, df0.shape) return df0 except Exception as e: print("[WARN] Could not load", p, e) return default if default is not None else pd.DataFrame() def maybe_load_case_level_aggregate(): """ Privacy-safe default: do not load raw SEER case-level data. Only use this if ALLOW_CASE_LEVEL_FALLBACK=1. """ if not ALLOW_CASE_LEVEL_FALLBACK: return pd.DataFrame(), pd.DataFrame(), pd.DataFrame() candidates = [ ROOT / "data" / "processed" / "vscc_modeling_exact_nodes.csv", ROOT / "blockfl_vscc" / "data" / "processed" / "vscc_modeling_exact_nodes.csv", ] df = pd.DataFrame() for p in candidates: if p.exists(): df = pd.read_csv(p, dtype=str) break if len(df) == 0: return pd.DataFrame(), pd.DataFrame(), pd.DataFrame() if "node_positive_exact" in df.columns: df["node_positive_exact"] = pd.to_numeric(df["node_positive_exact"], errors="coerce") if "year" in df.columns: df["year"] = pd.to_numeric(df["year"], errors="coerce") elif "Year_of_diagnosis" in df.columns: df["year"] = pd.to_numeric(df["Year_of_diagnosis"], errors="coerce") df = df[(df["year"] >= 2004) & (df["year"] <= 2023)].copy() n_records = len(df) n_pos = int((df["node_positive_exact"] == 1).sum()) n_neg = int((df["node_positive_exact"] == 0).sum()) cohort_overview = pd.DataFrame([ {"metric": "n_records", "value": n_records}, {"metric": "n_node_negative", "value": n_neg}, {"metric": "n_node_positive", "value": n_pos}, {"metric": "node_positive_rate", "value": n_pos / n_records if n_records else np.nan}, {"metric": "year_min", "value": int(df["year"].min())}, {"metric": "year_max", "value": int(df["year"].max())}, ]) endpoint_summary = pd.DataFrame({ "endpoint": ["node_negative", "node_positive"], "label": ["Node-negative", "Node-positive"], "records": [n_neg, n_pos], "proportion": [n_neg / n_records if n_records else np.nan, n_pos / n_records if n_records else np.nan], }) year_summary = ( df.groupby("year") .agg( records=("node_positive_exact", "size"), node_positive=("node_positive_exact", "sum"), node_positive_rate=("node_positive_exact", "mean"), ) .reset_index() ) return cohort_overview, endpoint_summary, year_summary # ============================================================ # Cryptographic helpers # ============================================================ def canonical_json_bytes(obj) -> bytes: return json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=True, default=str).encode("utf-8") def sha256_json(obj) -> str: return hashlib.sha256(canonical_json_bytes(obj)).hexdigest() def sha256_bytes(x: bytes) -> str: return hashlib.sha256(x).hexdigest() def merkle_parent(left: str, right: str) -> str: return sha256_bytes((left + right).encode("utf-8")) def merkle_root(hashes): hashes = list(hashes) if len(hashes) == 0: return sha256_bytes(b"EMPTY") layer = hashes while len(layer) > 1: if len(layer) % 2 == 1: layer.append(layer[-1]) layer = [merkle_parent(layer[i], layer[i + 1]) for i in range(0, len(layer), 2)] return layer[0] def public_key_from_hex(pub_hex: str): if not CRYPTO_AVAILABLE: return None return Ed25519PublicKey.from_public_bytes(bytes.fromhex(pub_hex)) def verify_obj(pub, obj: dict, sig_hex: str) -> bool: if not CRYPTO_AVAILABLE or pub is None: return True h = sha256_json(obj) try: pub.verify(bytes.fromhex(sig_hex), h.encode("utf-8")) return True except Exception: return False # ============================================================ # General helpers # ============================================================ def short_label(s): s = str(s) mapping = { "centralized": "Centralized", "centralized_primary_Model_B": "Centralized", "normal_fl_signed_audit": "Normal signed-audit FL", "normal_fedavg": "Normal FedAvg", "normal_median": "Normal median", "normal_trimmed_mean": "Normal trimmed mean", "label_flip_fedavg_no_audit": "Label flip FedAvg, no audit", "label_flip_fedavg_with_audit": "Label flip FedAvg, audit", "label_flip_median_robust": "Label flip median", "label_flip_trimmed_mean_robust": "Label flip trimmed mean", "random_noise_fedavg_no_audit": "Noise FedAvg, no audit", "random_noise_fedavg_with_audit": "Noise FedAvg, audit", "random_noise_median_robust": "Noise median", "random_noise_trimmed_mean_robust": "Noise trimmed mean", "temporal_5_old": "5 temporal clients", "temporal_10_primary": "10 temporal clients", "temporal_20_annual_sensitivity": "20 annual clients", "temporal_20_annual": "20 annual clients", } if s.startswith("client_"): return s.replace("client_", "").replace("_", "-") return mapping.get(s, s.replace("_", " ")) def fmt_num(x, digits=3): try: if pd.isna(x): return "NA" if abs(float(x)) >= 1000: return f"{float(x):,.0f}" return f"{float(x):.{digits}f}" except Exception: return str(x) def to_display_df(df0, max_rows=800): if df0 is None or len(df0) == 0: return pd.DataFrame({"message": ["Table unavailable in current package."]}) out = df0.copy() if len(out) > max_rows: out = out.head(max_rows) return out def first_value(df0, scenario_contains=None, col="AUROC", scheme=None): if df0 is None or len(df0) == 0 or col not in df0.columns: return np.nan tmp = df0.copy() if scheme is not None and "client_scheme" in tmp.columns: tmp = tmp[tmp["client_scheme"].astype(str) == str(scheme)] if scenario_contains and "scenario" in tmp.columns: tmp = tmp[tmp["scenario"].astype(str).str.contains(scenario_contains, case=False, na=False, regex=True)] vals = pd.to_numeric(tmp[col], errors="coerce").dropna() if len(vals) == 0: return np.nan return vals.iloc[0] def make_card(title, value, subtitle="", accent="#2563eb"): return f"""
{title}
{value}
{subtitle}
""" def force_plot_text_black(fig): fig.update_layout( font=dict(color="#0f172a"), title_font=dict(color="#0f172a"), paper_bgcolor="white", plot_bgcolor="white", ) fig.update_xaxes(color="#0f172a", title_font=dict(color="#0f172a")) fig.update_yaxes(color="#0f172a", title_font=dict(color="#0f172a")) return fig def empty_plot(title="No data available"): fig = go.Figure() fig.add_annotation(text=title, x=0.5, y=0.5, showarrow=False, font=dict(size=18, color="#0f172a")) fig.update_layout(template="plotly_white", height=420) return force_plot_text_black(fig) def truthy_series(s): return s.astype(str).str.lower().isin(["true", "1", "yes", "passed", "valid"]) def status_label(ok): return "PASS" if ok else "FAIL" # ============================================================ # Load exported assets from bucket # ============================================================ cohort_overview = load_csv_first([ TABLES_ASSET / "cohort_overview.csv", ROOT / "assets" / "tables" / "cohort_overview.csv", ]) endpoint_summary = load_csv_first([ TABLES_ASSET / "cohort_endpoint_summary.csv", ROOT / "assets" / "tables" / "cohort_endpoint_summary.csv", ]) year_summary = load_csv_first([ TABLES_ASSET / "cohort_year_summary.csv", ROOT / "assets" / "tables" / "cohort_year_summary.csv", ]) if len(cohort_overview) == 0 or len(endpoint_summary) == 0 or len(year_summary) == 0: fallback_overview, fallback_endpoint, fallback_year = maybe_load_case_level_aggregate() if len(cohort_overview) == 0: cohort_overview = fallback_overview if len(endpoint_summary) == 0: endpoint_summary = fallback_endpoint if len(year_summary) == 0: year_summary = fallback_year expanded_client_summary = load_csv_first([ TABLES_ASSET / "expanded_temporal_client_summary.csv", EXP / "tables" / "expanded_temporal_client_summary.csv", ]) expanded_split_summary = load_csv_first([ TABLES_ASSET / "expanded_temporal_client_split_summary.csv", EXP / "tables" / "expanded_temporal_client_split_summary.csv", ]) fl_results = load_csv_first([ TABLES_ASSET / "expanded_temporal_client_FL_results.csv", EXP / "tables" / "expanded_temporal_client_FL_results.csv", PH / "robust_aggregation_with_ci" / "FL_robust_results_point_estimates.csv", FINAL / "robust_aggregation" / "robust_aggregation_results.csv", FLB / "fl_vs_centralized_results.csv", ]) fl_rounds = load_csv_first([ TABLES_ASSET / "expanded_temporal_client_FL_round_metrics.csv", EXP / "tables" / "expanded_temporal_client_FL_round_metrics.csv", PH / "robust_aggregation_with_ci" / "FL_robust_round_metrics.csv", FINAL / "robust_aggregation" / "robust_aggregation_round_metrics.csv", FLB / "fl_round_metrics.csv", ]) fl_updates = load_csv_first([ TABLES_ASSET / "expanded_temporal_client_FL_update_events.csv", EXP / "tables" / "expanded_temporal_client_FL_update_events.csv", PH / "robust_aggregation_with_ci" / "FL_robust_client_updates.csv", FINAL / "robust_aggregation" / "robust_aggregation_client_updates.csv", FLB / "fl_client_updates.csv", ]) blocks_df = load_csv_first([ TABLES_ASSET / "fully_validated_blocks.csv", BC / "tables" / "fully_validated_blocks.csv", REALBC / "tables" / "real_permissioned_blockchain_blocks.csv", ]) attack_df = load_csv_first([ TABLES_ASSET / "fully_validated_attack_tests.csv", BC / "tables" / "fully_validated_attack_tests.csv", REALBC / "tables" / "real_permissioned_blockchain_attack_tests.csv", FLB / "fl_blockchain_special_attack_checks.csv", ]) overhead_summary = load_csv_first([ TABLES_ASSET / "computational_overhead_summary.csv", BCBENCH / "tables" / "computational_overhead_summary.csv", ]) storage_summary = load_csv_first([ TABLES_ASSET / "storage_network_efficiency_summary.csv", BCBENCH / "tables" / "storage_network_efficiency_summary.csv", ]) resilience_df = load_csv_first([ TABLES_ASSET / "resilience_test_logs.csv", BCBENCH / "tables" / "resilience_test_logs.csv", ]) timing_summary = load_csv_first([ TABLES_ASSET / "end_to_end_FL_audit_timing_summary.csv", TIMING / "tables" / "Supplementary_Table_S14_end_to_end_FL_audit_timing_summary.csv", ]) timing_projection = load_csv_first([ TABLES_ASSET / "projected_audit_overhead_vs_training_duration.csv", TIMING / "tables" / "Supplementary_Table_S14_projected_audit_overhead_vs_training_duration.csv", ]) bc_summary = read_json_first([ JSON_ASSET / "fully_validated_blockchain_summary.json", BC / "json" / "fully_validated_blockchain_summary.json", ], default={}) bc_validation = read_json_first([ JSON_ASSET / "fully_validated_validation_report.json", BC / "json" / "fully_validated_validation_report.json", ], default={}) bc_benchmark_json = read_json_first([ JSON_ASSET / "blockchain_benchmark_summary.json", BCBENCH / "json" / "blockchain_benchmark_summary.json", ], default={}) timing_json = read_json_first([ JSON_ASSET / "end_to_end_timing_benchmark_summary.json", TIMING / "end_to_end_timing_benchmark_summary.json", ], default={}) chain_json = read_json_first([ JSON_ASSET / "fully_validated_chain.json", BC / "json" / "fully_validated_chain.json", ], default=[]) # ============================================================ # Derived metrics # ============================================================ def overview_value(metric, default=np.nan): if len(cohort_overview) == 0 or "metric" not in cohort_overview.columns or "value" not in cohort_overview.columns: return default vals = cohort_overview.loc[cohort_overview["metric"] == metric, "value"] if len(vals) == 0: return default try: return float(vals.iloc[0]) except Exception: return vals.iloc[0] n_records = int(overview_value("n_records", 0) or 0) n_pos = int(overview_value("n_node_positive", 0) or 0) n_neg = int(overview_value("n_node_negative", 0) or 0) pos_rate = overview_value("node_positive_rate", np.nan) available_schemes = [] if len(fl_results) and "client_scheme" in fl_results.columns: available_schemes = sorted(fl_results["client_scheme"].dropna().astype(str).unique().tolist()) elif len(expanded_client_summary) and "client_scheme" in expanded_client_summary.columns: available_schemes = sorted(expanded_client_summary["client_scheme"].dropna().astype(str).unique().tolist()) preferred_schemes = ["temporal_10_primary", "temporal_20_annual_sensitivity", "temporal_20_annual", "temporal_5_old"] available_schemes = [s for s in preferred_schemes if s in available_schemes] + [s for s in available_schemes if s not in preferred_schemes] if len(available_schemes) == 0: available_schemes = ["temporal_10_primary"] primary_scheme = "temporal_10_primary" if "temporal_10_primary" in available_schemes else available_schemes[0] primary_client_count = np.nan sensitivity_client_count = np.nan if len(expanded_client_summary) and "client_scheme" in expanded_client_summary.columns and "client_id" in expanded_client_summary.columns: primary_client_count = expanded_client_summary.loc[ expanded_client_summary["client_scheme"] == "temporal_10_primary", "client_id" ].nunique() sensitivity_client_count = expanded_client_summary.loc[ expanded_client_summary["client_scheme"].astype(str).str.contains("20", na=False), "client_id" ].nunique() central_auc = first_value(fl_results, "central", "AUROC", scheme=primary_scheme) normal_auc = first_value(fl_results, "^normal_fedavg$", "AUROC", scheme=primary_scheme) noise_no_audit_auc = first_value(fl_results, "random_noise_fedavg_no_audit|poisoned_random_noise_no_audit", "AUROC", scheme=primary_scheme) noise_audit_auc = first_value(fl_results, "random_noise_fedavg_with_audit|poisoned_random_noise_with_audit", "AUROC", scheme=primary_scheme) chain_valid = bc_summary.get("chain_valid", bc_validation.get("valid", "NA")) attack_passed = bc_summary.get("attack_tests_all_passed", "NA") bench_chain_valid = bc_benchmark_json.get("chain_valid", "NA") merkle_us = bc_benchmark_json.get("merkle_root_median_us", np.nan) commit_ratio = bc_benchmark_json.get("commit_certificate_to_full_block_median_ratio", np.nan) storage_saving = bc_benchmark_json.get("commit_certificate_storage_saving_median_percent", np.nan) audit_ms = timing_json.get("median_audit_ms_per_round", np.nan) audit_pct = timing_json.get("median_audit_overhead_percent_lightweight_round", np.nan) # ============================================================ # Plot functions # ============================================================ PLOT_TEMPLATE = "plotly_white" def fig_cohort_endpoint(): if len(endpoint_summary) == 0: return empty_plot("Cohort endpoint summary unavailable") label_col = "label" if "label" in endpoint_summary.columns else endpoint_summary.columns[0] value_col = "records" if "records" in endpoint_summary.columns else endpoint_summary.columns[-1] fig = px.bar( endpoint_summary, x=label_col, y=value_col, text=value_col, color=label_col, color_discrete_sequence=["#4477AA", "#EE9944"], title="Exact regional lymph-node endpoint distribution", template=PLOT_TEMPLATE, ) fig.update_traces(texttemplate="%{text:,}", textposition="outside") fig.update_layout(showlegend=False, height=430, yaxis_title="Records", xaxis_title="") return force_plot_text_black(fig) def fig_year_distribution(): if len(year_summary) == 0: return empty_plot("Year summary unavailable") fig = go.Figure() fig.add_bar(x=year_summary["year"], y=year_summary["records"], name="Records", marker_color="#4477AA", yaxis="y") if "node_positive_rate" in year_summary.columns: fig.add_trace( go.Scatter( x=year_summary["year"], y=year_summary["node_positive_rate"], name="Node-positive rate", mode="lines+markers", marker=dict(color="#EE9944"), yaxis="y2", ) ) fig.update_layout( title="Diagnosis-year distribution and node-positive rate", template=PLOT_TEMPLATE, height=450, yaxis=dict(title="Records"), yaxis2=dict(title="Node-positive rate", overlaying="y", side="right"), legend=dict(orientation="h", y=1.12), ) return force_plot_text_black(fig) def fig_expanded_client_sizes(scheme): if len(expanded_client_summary) == 0: return empty_plot("Expanded client summary unavailable") tmp = expanded_client_summary.copy() if scheme and "client_scheme" in tmp.columns: tmp = tmp[tmp["client_scheme"].astype(str) == str(scheme)] if len(tmp) == 0: return empty_plot(f"No client summary for {scheme}") tmp["client"] = tmp["client_id"].map(short_label) if "client_id" in tmp.columns else tmp.index.astype(str) sort_col = "year_min" if "year_min" in tmp.columns else "client" tmp = tmp.sort_values(sort_col) fig = px.bar( tmp, x="client", y="n", color_discrete_sequence=["#4477AA"], title=f"Temporal client sizes: {short_label(scheme)}", template=PLOT_TEMPLATE, text="n", ) fig.update_traces(texttemplate="%{text:,}", textposition="outside") fig.update_layout(height=460, xaxis_title="Temporal client", yaxis_title="Records", xaxis_tickangle=-45, showlegend=False) return force_plot_text_black(fig) def fig_expanded_client_prevalence(scheme): if len(expanded_client_summary) == 0 or "node_positive_rate" not in expanded_client_summary.columns: return empty_plot("Expanded client prevalence unavailable") tmp = expanded_client_summary.copy() if scheme and "client_scheme" in tmp.columns: tmp = tmp[tmp["client_scheme"].astype(str) == str(scheme)] if len(tmp) == 0: return empty_plot(f"No prevalence summary for {scheme}") tmp["client"] = tmp["client_id"].map(short_label) if "client_id" in tmp.columns else tmp.index.astype(str) sort_col = "year_min" if "year_min" in tmp.columns else "client" tmp = tmp.sort_values(sort_col) fig = px.bar( tmp, x="client", y="node_positive_rate", color_discrete_sequence=["#EE9944"], title=f"Node-positive prevalence by temporal client: {short_label(scheme)}", template=PLOT_TEMPLATE, text=tmp["node_positive_rate"].map(lambda x: f"{x:.3f}"), ) fig.update_traces(textposition="outside") fig.update_layout(height=460, xaxis_title="Temporal client", yaxis_title="Node-positive rate", xaxis_tickangle=-45, showlegend=False) return force_plot_text_black(fig) def fig_expanded_scheme_compare(): if len(fl_results) == 0 or "client_scheme" not in fl_results.columns: return empty_plot("Expanded FL result table unavailable") tmp = fl_results[fl_results["scenario"].isin(["centralized", "normal_fedavg", "normal_trimmed_mean"])].copy() if len(tmp) == 0: return empty_plot("Normal FL comparison unavailable") tmp["scheme"] = tmp["client_scheme"].map(short_label) tmp["Scenario"] = tmp["scenario"].map(short_label) fig = px.bar( tmp, x="scheme", y="AUROC", color="Scenario", barmode="group", title="Primary 10-client and annual 20-client FL comparison", template=PLOT_TEMPLATE, color_discrete_sequence=["#4477AA", "#66AA55", "#AA4499"], text="AUROC", ) fig.update_traces(texttemplate="%{text:.3f}", textposition="outside") fig.update_layout(height=500, xaxis_title="Temporal federation", yaxis_title="AUROC", yaxis_range=[0.45, 1.0], legend=dict(orientation="h", y=1.12)) return force_plot_text_black(fig) def fig_fl_performance_by_scheme(scheme): if len(fl_results) == 0: return empty_plot("FL result table unavailable") tmp = fl_results.copy() if scheme and "client_scheme" in tmp.columns: tmp = tmp[tmp["client_scheme"].astype(str) == str(scheme)] if len(tmp) == 0: return empty_plot(f"No FL results for {scheme}") for col in ["AUROC", "AUPRC"]: if col not in tmp.columns: tmp[col] = np.nan tmp = tmp.dropna(subset=["AUROC", "AUPRC"], how="all") tmp["Scenario"] = tmp["scenario"].map(short_label) tmp = tmp.sort_values("AUROC", ascending=True) long = tmp.melt(id_vars=["Scenario"], value_vars=["AUROC", "AUPRC"], var_name="Metric", value_name="Score") fig = px.bar( long, x="Score", y="Scenario", color="Metric", orientation="h", barmode="group", title=f"Model performance: {short_label(scheme)}", template=PLOT_TEMPLATE, color_discrete_map={"AUROC": "#4477AA", "AUPRC": "#66AA55"}, ) fig.update_layout(height=max(540, 36 * len(tmp)), xaxis_title="Score", yaxis_title="", xaxis_range=[0.2, 1.0], legend=dict(orientation="h", y=1.08)) return force_plot_text_black(fig) def fig_round_convergence(scheme): if len(fl_rounds) == 0 or "test_AUROC" not in fl_rounds.columns: return empty_plot("Round metrics unavailable") tmp = fl_rounds.copy() if scheme and "client_scheme" in tmp.columns: tmp = tmp[tmp["client_scheme"].astype(str) == str(scheme)] keep = [ "normal_fedavg", "normal_trimmed_mean", "random_noise_fedavg_no_audit", "random_noise_fedavg_with_audit", "random_noise_trimmed_mean_robust", "label_flip_fedavg_with_audit", "label_flip_trimmed_mean_robust", ] tmp = tmp[tmp["scenario"].isin(keep)].copy() if len(tmp) == 0: return empty_plot(f"No selected convergence scenarios for {scheme}") tmp["Scenario"] = tmp["scenario"].map(short_label) fig = px.line(tmp, x="round_id", y="test_AUROC", color="Scenario", markers=True, title=f"Federated learning convergence: {short_label(scheme)}", template=PLOT_TEMPLATE) fig.update_layout(height=500, xaxis_title="FL round", yaxis_title="Held-out AUROC", yaxis_range=[0.45, 1.0], legend=dict(orientation="h", y=1.15)) return force_plot_text_black(fig) def fig_robust_heatmap(scheme): required = {"attack_type", "aggregation_strategy", "AUROC"} if len(fl_results) == 0 or not required.issubset(set(fl_results.columns)): return empty_plot("Robust aggregation table unavailable") tmp = fl_results.copy() if scheme and "client_scheme" in tmp.columns: tmp = tmp[tmp["client_scheme"].astype(str) == str(scheme)] tmp = tmp[~tmp["scenario"].astype(str).str.contains("central", case=False, na=False)].copy() if len(tmp) == 0: return empty_plot(f"No poisoning/robust aggregation results for {scheme}") tmp["attack_type"] = tmp["attack_type"].fillna("none").astype(str) tmp["aggregation_strategy"] = tmp["aggregation_strategy"].fillna("unknown").astype(str) pivot = tmp.pivot_table(index="attack_type", columns="aggregation_strategy", values="AUROC", aggfunc="max") fig = px.imshow(pivot, text_auto=".3f", color_continuous_scale="Magma", zmin=0.5, zmax=1.0, title=f"Robust aggregation sensitivity: {short_label(scheme)}", template=PLOT_TEMPLATE) fig.update_layout(height=460, xaxis_title="Aggregation", yaxis_title="Attack type") return force_plot_text_black(fig) def fig_update_rejections(scheme): if len(fl_results) == 0 or "n_rejected_update_events" not in fl_results.columns: return empty_plot("Rejected-update summary unavailable") tmp = fl_results.copy() if scheme and "client_scheme" in tmp.columns: tmp = tmp[tmp["client_scheme"].astype(str) == str(scheme)] tmp = tmp[~tmp["scenario"].astype(str).str.contains("central", case=False, na=False)].copy() if len(tmp) == 0: return empty_plot(f"No update rejection results for {scheme}") tmp = tmp.sort_values("n_rejected_update_events", ascending=True) tmp["Scenario"] = tmp["scenario"].map(short_label) fig = px.bar(tmp, x="n_rejected_update_events", y="Scenario", orientation="h", title=f"Audit-gated model-update rejection: {short_label(scheme)}", template=PLOT_TEMPLATE, color_discrete_sequence=["#CC6677"], text="n_rejected_update_events") fig.update_traces(textposition="outside") fig.update_layout(height=max(500, 34 * len(tmp)), xaxis_title="Rejected update events", yaxis_title="") return force_plot_text_black(fig) def fig_update_norms(scheme): if len(fl_updates) == 0 or "update_norm_l2" not in fl_updates.columns: return empty_plot("Update-event table unavailable") tmp = fl_updates.copy() if scheme and "client_scheme" in tmp.columns: tmp = tmp[tmp["client_scheme"].astype(str) == str(scheme)] if len(tmp) == 0: return empty_plot(f"No update-event data for {scheme}") keep = tmp["scenario"].value_counts().head(8).index.tolist() tmp = tmp[tmp["scenario"].isin(keep)].copy() tmp["Scenario"] = tmp["scenario"].map(short_label) fig = px.box(tmp, x="update_norm_l2", y="Scenario", color="attack_type" if "attack_type" in tmp.columns else None, title=f"Model-update L2 norm distribution: {short_label(scheme)}", template=PLOT_TEMPLATE, points="outliers") fig.update_layout(height=max(520, 36 * tmp["Scenario"].nunique()), xaxis_title="Update L2 norm", yaxis_title="") return force_plot_text_black(fig) def fig_blockchain_blocks(): if len(blocks_df) == 0: return empty_plot("Blockchain block table unavailable") tmp = blocks_df.copy() if "block_type" in tmp.columns: tmp = tmp[tmp["block_type"].astype(str).str.contains("MODEL|GENESIS", case=False, na=False)] fig = go.Figure() if "transaction_count" in tmp.columns: fig.add_bar(x=tmp["height"], y=tmp["transaction_count"], name="Transactions", marker_color="#4477AA") if "n_commit_votes" in tmp.columns: fig.add_trace(go.Scatter(x=tmp["height"], y=tmp["n_commit_votes"], name="Commit votes", mode="lines+markers", yaxis="y2", line=dict(color="#66AA55"))) fig.update_layout(title="Committed blockchain blocks", template=PLOT_TEMPLATE, height=480, xaxis_title="Block height", yaxis=dict(title="Transactions"), yaxis2=dict(title="Commit votes", overlaying="y", side="right"), legend=dict(orientation="h", y=1.12)) return force_plot_text_black(fig) def infer_attack_pass(row): text = " ".join([str(v).lower() for v in row.to_dict().values()]) if "passed" in row.index: val = str(row["passed"]).lower() if val in ["true", "1", "yes", "passed", "valid"]: return True if val in ["false", "0", "no", "failed", "invalid"]: return False failure_words = ["failed", "invalid chain", "not_detected", "accepted_replay", "fork_accepted", "payload_hash_still_matches", "replay_not_detected", "membership_failed"] success_words = ["passed", "valid", "verified", "detected", "rejected", "0 validation errors", "n_errors=0", "transaction_membership_verified", "payload_hash_mismatch", "wrong_previous_hash_no_quorum", "replayed_model_update", "unauthorized_client", "invalid_signature", "tamper detected", "fork rejected", "replay rejected"] for w in failure_words: if w in text: return False for w in success_words: if w in text: return True return True def fig_attack_tests(): if len(attack_df) == 0: return empty_plot("Attack-test table unavailable") tmp = attack_df.copy() if "attack" in tmp.columns: label_col = "attack" elif "test" in tmp.columns: label_col = "test" elif "check" in tmp.columns: label_col = "check" else: label_col = tmp.columns[0] tmp["Passed"] = tmp.apply(infer_attack_pass, axis=1) tmp["passed_int"] = tmp["Passed"].astype(int) tmp["Status"] = np.where(tmp["Passed"], "Passed", "Failed") tmp["Test"] = tmp[label_col].astype(str).str.replace("_", " ", regex=False) tmp = tmp.sort_values(["Passed", "Test"], ascending=[True, True]) fig = px.bar(tmp, x="passed_int", y="Test", color="Status", orientation="h", title="Blockchain resilience and security checks", template=PLOT_TEMPLATE, color_discrete_map={"Passed": "#16a34a", "Failed": "#dc2626"}, text="Status", hover_data=[c for c in tmp.columns if c not in ["passed_int"]]) fig.update_traces(textposition="outside") fig.update_layout(height=max(430, 42 * len(tmp)), xaxis=dict(title="Validation status", tickmode="array", tickvals=[0, 1], ticktext=["Fail", "Pass"], range=[0, 1.25]), yaxis_title="", showlegend=True, legend=dict(orientation="h", y=1.12), margin=dict(l=20, r=80, t=80, b=40)) return force_plot_text_black(fig) def fig_benchmark_overhead(): if len(overhead_summary) == 0: return empty_plot("Benchmark overhead summary unavailable") tmp = overhead_summary.copy() tmp["Metric"] = tmp["metric"].astype(str).str.replace("_", " ") tmp = tmp[tmp["median"].notna()].copy() fig = px.bar(tmp, x="median", y="Metric", orientation="h", error_x=tmp["iqr_high"] - tmp["median"] if "iqr_high" in tmp.columns else None, title="Blockchain computational overhead, median microseconds", template=PLOT_TEMPLATE, color_discrete_sequence=["#AA4499"]) fig.update_layout(height=max(460, 38 * len(tmp)), xaxis_title="Median latency, microseconds", yaxis_title="") return force_plot_text_black(fig) def fig_storage_efficiency(): if len(storage_summary) == 0: return empty_plot("Storage-efficiency summary unavailable") metrics = ["full_block_bytes", "commit_certificate_bytes", "header_plus_commit_certificate_bytes"] tmp = storage_summary[storage_summary["metric"].isin(metrics)].copy() if len(tmp) == 0: return empty_plot("Storage metrics unavailable") tmp["Metric"] = tmp["metric"].str.replace("_", " ") fig = px.bar(tmp, x="Metric", y="median", text="median", title="Blockchain storage footprint", template=PLOT_TEMPLATE, color="Metric", color_discrete_sequence=["#4477AA", "#66AA55", "#EE9944"]) fig.update_traces(texttemplate="%{text:.0f} B", textposition="outside") fig.update_layout(height=460, showlegend=False, yaxis_title="Median bytes") return force_plot_text_black(fig) def fig_resilience_logs(): if len(resilience_df) == 0: return empty_plot("Resilience logs unavailable") tmp = resilience_df.copy() if "passed" in tmp.columns: tmp["Passed"] = tmp["passed"].astype(str).str.lower().isin(["true", "1", "yes", "passed"]) else: tmp["Passed"] = tmp.apply(infer_attack_pass, axis=1) tmp["passed_int"] = tmp["Passed"].astype(int) label_col = "test" if "test" in tmp.columns else tmp.columns[0] tmp["Test"] = tmp[label_col].astype(str).str.replace("_", " ", regex=False) tmp["Status"] = np.where(tmp["Passed"], "Passed", "Failed") fig = px.bar(tmp, x="passed_int", y="Test", color="Status", orientation="h", title="Replay, fork, tamper, Merkle-proof and full-chain resilience", template=PLOT_TEMPLATE, color_discrete_map={"Passed": "#16a34a", "Failed": "#dc2626"}, text="Status", hover_data=[c for c in tmp.columns if c not in ["passed_int"]]) fig.update_traces(textposition="outside") fig.update_layout(height=max(430, 42 * len(tmp)), xaxis=dict(title="Validation status", tickmode="array", tickvals=[0, 1], ticktext=["Fail", "Pass"], range=[0, 1.25]), yaxis_title="", legend=dict(orientation="h", y=1.12), margin=dict(l=20, r=80, t=80, b=40)) return force_plot_text_black(fig) def fig_timing_components(): if len(timing_summary) == 0: return empty_plot("End-to-end timing summary unavailable") metrics = ["local_training_total_s", "audit_total_s", "aggregation_s"] tmp = timing_summary[timing_summary["metric"].isin(metrics)].copy() if len(tmp) == 0: return empty_plot("Timing components unavailable") label_map = {"local_training_total_s": "Local training", "audit_total_s": "Audit commitment", "aggregation_s": "Aggregation"} tmp["Component"] = tmp["metric"].map(label_map) tmp["median_ms"] = tmp["median"] * 1000.0 fig = px.bar(tmp, x="Component", y="median_ms", text="median_ms", title="End-to-end FL round timing components", template=PLOT_TEMPLATE, color="Component", color_discrete_sequence=["#4477AA", "#EE9944", "#66AA55"]) fig.update_traces(texttemplate="%{text:.2f} ms", textposition="outside") fig.update_layout(height=460, showlegend=False, yaxis_title="Median time per round, ms") return force_plot_text_black(fig) def fig_projected_overhead(): if len(timing_projection) == 0: return empty_plot("Projected timing table unavailable") fig = px.line(timing_projection, x="assumed_hospital_scale_training_round_s", y="audit_overhead_percent_of_assumed_round", markers=True, title="Projected audit overhead versus longer local training rounds", template=PLOT_TEMPLATE) fig.update_xaxes(type="log", title="Assumed local training round duration, seconds") fig.update_yaxes(type="log", title="Projected audit overhead, %") fig.update_layout(height=460) return force_plot_text_black(fig) # ============================================================ # Real-time validation # ============================================================ FORBIDDEN_ON_CHAIN_FIELDS = { "Patient_ID", "patient_id", "node_positive_exact", "Regional_nodes_positive_1988", "Regional_nodes_examined_1988", "Survival_months", "Vitalstatusrecodestudycutoffuse", } def tx_replay_key(tx): p = tx.get("payload", {}) return "|".join([str(p.get("scenario")), str(p.get("round_id")), str(p.get("client_id")), str(p.get("model_update_hash"))]) def live_chain_validation(): chain = chain_json if isinstance(chain_json, list) else [] if len(chain) == 0: return { "valid": False, "n_blocks": 0, "n_model_update_blocks": 0, "n_errors": 1, "errors": [{"height": None, "error": "fully_validated_chain_json_missing"}], "resilience": [], } errors = [] replay_seen = set() for i, block in enumerate(chain): header = block.get("header", {}) block_hash = block.get("block_hash") txs = block.get("transactions", []) if sha256_json(header) != block_hash: errors.append({"height": i, "error": "block_hash_mismatch"}) if i == 0: if header.get("previous_hash") != "0" * 64: errors.append({"height": i, "error": "genesis_previous_hash_invalid"}) else: if header.get("previous_hash") != chain[i - 1].get("block_hash"): errors.append({"height": i, "error": "previous_hash_mismatch"}) tx_hashes = [tx.get("transaction_hash") for tx in txs] if header.get("transaction_hashes") != tx_hashes: errors.append({"height": i, "error": "transaction_hash_list_mismatch"}) if header.get("merkle_root") != merkle_root(tx_hashes): errors.append({"height": i, "error": "merkle_root_mismatch"}) if int(header.get("transaction_count", -1)) != len(txs): errors.append({"height": i, "error": "transaction_count_mismatch"}) quorum = int(header.get("quorum_threshold", 0)) if len(block.get("commit_certificate", [])) < quorum: errors.append({"height": i, "error": "quorum_failure"}) for tx in txs: payload = tx.get("payload", {}) if payload.get("no_patient_data_on_chain") is not True: errors.append({"height": i, "error": "patient_data_flag_false"}) for f in FORBIDDEN_ON_CHAIN_FIELDS: if f in payload: errors.append({"height": i, "error": f"forbidden_on_chain_field:{f}"}) if sha256_json(payload) != tx.get("payload_hash"): errors.append({"height": i, "error": "payload_hash_mismatch"}) expected_tx_hash = sha256_json({"payload_hash": tx.get("payload_hash"), "client_public_key": tx.get("client_public_key"), "client_signature": tx.get("client_signature")}) if expected_tx_hash != tx.get("transaction_hash"): errors.append({"height": i, "error": "transaction_hash_mismatch"}) key = tx_replay_key(tx) if key in replay_seen: errors.append({"height": i, "error": "replay_inside_committed_chain"}) replay_seen.add(key) resilience = [] first_block = None first_tx = None for b in chain: if len(b.get("transactions", [])) > 0: first_block = b first_tx = b["transactions"][0] break if first_tx is not None: committed_keys = {tx_replay_key(tx) for b in chain for tx in b.get("transactions", [])} replay_detected = tx_replay_key(first_tx) in committed_keys resilience.append({"test": "replayed_transaction", "expected": "rejected", "observed": "rejected" if replay_detected else "accepted", "passed": bool(replay_detected), "reason": "replayed_model_update_transaction" if replay_detected else "replay_not_detected"}) tampered = json.loads(json.dumps(first_tx)) tampered["payload"]["update_norm_l2"] = 999999.0 tamper_detected = sha256_json(tampered["payload"]) != tampered.get("payload_hash") resilience.append({"test": "committed_payload_tampering", "expected": "detected", "observed": "detected" if tamper_detected else "not_detected", "passed": bool(tamper_detected), "reason": "payload_hash_mismatch" if tamper_detected else "payload_hash_still_matches"}) tx_hashes = first_block.get("header", {}).get("transaction_hashes", []) proof_ok = bool(len(tx_hashes) and first_block.get("header", {}).get("merkle_root") == merkle_root(tx_hashes)) resilience.append({"test": "merkle_root_recomputation", "expected": "verified", "observed": "verified" if proof_ok else "failed", "passed": bool(proof_ok), "reason": "merkle_root_verified" if proof_ok else "merkle_root_failed"}) return { "valid": len(errors) == 0, "n_blocks": len(chain), "n_model_update_blocks": sum(1 for b in chain if b.get("header", {}).get("block_type") == "MODEL_UPDATE_BLOCK"), "n_errors": len(errors), "errors": errors, "resilience": resilience, } def run_realtime_validation(): rows = [] cohort_ok = n_records == 7823 rows.append({"domain": "Cohort", "check": "Exact-node modeling cohort loaded", "status": status_label(cohort_ok), "value": f"N={n_records:,}; positives={n_pos:,}; negatives={n_neg:,}; rate={fmt_num(pos_rate)}", "expected": "N=7,823 with binary exact-node endpoint"}) ten_ok = False twenty_ok = False ten_n = np.nan twenty_n = np.nan if len(expanded_client_summary) and "client_scheme" in expanded_client_summary.columns and "client_id" in expanded_client_summary.columns: ten_n = expanded_client_summary.loc[expanded_client_summary["client_scheme"] == "temporal_10_primary", "client_id"].nunique() twenty_n = expanded_client_summary.loc[expanded_client_summary["client_scheme"].astype(str).str.contains("20", na=False), "client_id"].nunique() ten_ok = ten_n == 10 twenty_ok = twenty_n >= 20 rows.append({"domain": "Expanded clients", "check": "Primary 10-client temporal federation", "status": status_label(ten_ok), "value": f"{ten_n} clients", "expected": "10 simulated temporal clients"}) rows.append({"domain": "Expanded clients", "check": "Annual 20-client sensitivity federation", "status": status_label(twenty_ok), "value": f"{twenty_n} clients", "expected": "20 annual clients"}) fl_ok = len(fl_results) > 0 and not pd.isna(normal_auc) rows.append({"domain": "FL performance", "check": "Primary normal FedAvg available", "status": status_label(fl_ok), "value": f"AUROC={fmt_num(normal_auc)}", "expected": "Primary temporal FedAvg result present"}) poison_ok = len(fl_results) > 0 and not pd.isna(noise_no_audit_auc) and not pd.isna(noise_audit_auc) and noise_audit_auc > noise_no_audit_auc rows.append({"domain": "Poisoning/audit", "check": "Audit improves random-noise poisoning result", "status": status_label(poison_ok), "value": f"no audit={fmt_num(noise_no_audit_auc)}; audit={fmt_num(noise_audit_auc)}", "expected": "Audit-gated AUROC > unaudited noisy AUROC"}) live_chain = live_chain_validation() rows.append({"domain": "Blockchain", "check": "Live full-chain recomputation", "status": status_label(live_chain["valid"]), "value": f"{live_chain['n_errors']} errors; {live_chain['n_blocks']} blocks", "expected": "0 errors"}) for r in live_chain["resilience"]: rows.append({"domain": "Live resilience", "check": r["test"], "status": status_label(bool(r["passed"])), "value": f"{r['observed']}; {r['reason']}", "expected": r["expected"]}) merkle_ok = not pd.isna(merkle_us) and float(merkle_us) < 1000 rows.append({"domain": "Benchmark", "check": "Merkle-root generation under 1 ms", "status": status_label(merkle_ok), "value": f"{fmt_num(merkle_us)} us", "expected": "<1000 us"}) storage_ok = not pd.isna(storage_saving) and float(storage_saving) > 80 rows.append({"domain": "Benchmark", "check": "Commit certificate storage saving", "status": status_label(storage_ok), "value": f"{fmt_num(storage_saving)}%", "expected": ">80%"}) audit_ok = not pd.isna(audit_ms) and float(audit_ms) < 100 rows.append({"domain": "Benchmark", "check": "End-to-end cryptographic audit cost", "status": status_label(audit_ok), "value": f"{fmt_num(audit_ms)} ms", "expected": "small absolute local PoC overhead"}) val_df = pd.DataFrame(rows) n_pass = int((val_df["status"] == "PASS").sum()) n_fail = int((val_df["status"] == "FAIL").sum()) summary = {"timestamp_utc": utc_now(), "n_checks": int(len(val_df)), "n_pass": n_pass, "n_fail": n_fail, "overall_status": "PASS" if n_fail == 0 else "ATTENTION_REQUIRED", "live_chain_validation": live_chain} plot_df = val_df.groupby(["domain", "status"]).size().reset_index(name="count") fig = px.bar(plot_df, x="domain", y="count", color="status", barmode="group", title="Real-time validation status by domain", template=PLOT_TEMPLATE, color_discrete_map={"PASS": "#16a34a", "FAIL": "#dc2626"}, text="count") fig.update_traces(textposition="outside") fig.update_layout(height=460, xaxis_title="", yaxis_title="Checks") fig = force_plot_text_black(fig) html = f"""

Real-time validation status: {summary['overall_status']}

{n_pass} passed, {n_fail} failed, total checks: {len(val_df)}.

Last run: {summary['timestamp_utc']}

""" return html, val_df, fig, summary # ============================================================ # HTML and CSS # ============================================================ cards_html = f"""
{make_card("Exact-node records", f"{n_records:,}", "SEER VSCC modeling cohort", "#4477AA")} {make_card("Node-positive rate", fmt_num(pos_rate), f"{n_pos:,} positive / {n_neg:,} negative", "#EE9944")} {make_card("Primary federation", f"{fmt_num(primary_client_count, 0)}", "10 temporal clients", "#66AA55")} {make_card("Sensitivity federation", f"{fmt_num(sensitivity_client_count, 0)}", "20 annual temporal clients", "#66AA55")} {make_card("Centralized AUROC", fmt_num(central_auc), "Pooled-data upper bound", "#4477AA")} {make_card("Normal FL AUROC", fmt_num(normal_auc), "Primary temporal FedAvg", "#66AA55")} {make_card("Blockchain valid", str(chain_valid), "Fully validated permissioned PoC", "#AA4499")} {make_card("Audit overhead", f"{fmt_num(audit_ms)} ms", f"Median lightweight FL round; {fmt_num(audit_pct)}%", "#EE9944")}
""" claim_html = """
Claim boundary. BlockFL-VSCC is a retrospective computational proof-of-concept using SEER registry-derived aggregate outputs and simulated temporal clients. The expanded federation uses time-stratified registry silos, not real hospitals. The primary model is registry-based nodal-status prediction using available tumor-extension/T-stage descriptors and should not be presented as pure preoperative prediction. The blockchain is a fully validated Python permissioned proof-of-concept, not a production Hyperledger Fabric, Quorum, or Corda deployment. The audit layer stores signed hashes and metadata commitments, not patient-level records, raw features, labels, predictions, or raw gradients/weights.
""" benchmark_html = f"""

Benchmark highlights

""" expanded_client_html = """

Expanded temporal-client design

""" CSS = """ :root { --body-text-color: #0f172a !important; --block-title-text-color: #0f172a !important; --input-text-color: #0f172a !important; --button-secondary-text-color: #0f172a !important; --button-primary-text-color: #ffffff !important; --link-text-color: #0f172a !important; } html, body { background: #f8fafc !important; color: #0f172a !important; } .gradio-container { max-width: 1480px !important; margin: auto !important; background: #f8fafc !important; color: #0f172a !important; } /* Global text safety */ .gradio-container, .gradio-container p, .gradio-container span, .gradio-container div, .gradio-container label, .gradio-container button, .gradio-container input, .gradio-container textarea, .gradio-container select, .gradio-container h1, .gradio-container h2, .gradio-container h3, .gradio-container h4, .gradio-container h5, .gradio-container h6, .gradio-container li, .gradio-container ul, .gradio-container ol, .gradio-container strong, .gradio-container b { color: #0f172a !important; } /* Markdown text and headings */ .gradio-container .prose, .gradio-container .prose *, .gradio-container .markdown, .gradio-container .markdown *, .gradio-container [data-testid="markdown"], .gradio-container [data-testid="markdown"] * { color: #0f172a !important; } /* Gradio tab labels */ .gradio-container [role="tab"], .gradio-container button[role="tab"], .gradio-container .tab-nav button, .gradio-container .tabs button, .gradio-container button { color: #0f172a !important; font-weight: 700 !important; } /* Selected and unselected tabs */ .gradio-container [role="tab"][aria-selected="true"], .gradio-container button[role="tab"][aria-selected="true"], .gradio-container .tab-nav button.selected, .gradio-container .tabs button.selected { color: #0f172a !important; background: #ffffff !important; border-bottom: 3px solid #2563eb !important; } .gradio-container [role="tab"][aria-selected="false"], .gradio-container button[role="tab"][aria-selected="false"], .gradio-container .tab-nav button:not(.selected), .gradio-container .tabs button:not(.selected) { color: #0f172a !important; background: #f8fafc !important; } /* Title block remains dark with white text */ #title-block { background: linear-gradient(135deg, #111827, #1f2937) !important; color: white !important; padding: 28px; border-radius: 18px; margin-bottom: 18px; } #title-block, #title-block *, #title-block h1, #title-block p { color: white !important; } #title-block h1 { margin: 0; font-size: 34px; letter-spacing: -0.03em; } #title-block p { margin-top: 8px; color: #d1d5db !important; font-size: 16px; } /* Metric cards */ .cards { display: grid; grid-template-columns: repeat(4, minmax(180px, 1fr)); gap: 14px; margin: 12px 0 18px 0; } .metric-card { background: #ffffff !important; color: #0f172a !important; border-radius: 14px; padding: 16px 18px; box-shadow: 0 1px 8px rgba(15, 23, 42, 0.08); } .metric-card, .metric-card * { color: #0f172a !important; } .metric-title { font-size: 12px; color: #0f172a !important; text-transform: uppercase; letter-spacing: 0.08em; font-weight: 800; } .metric-value { font-size: 28px; color: #0f172a !important; font-weight: 900; margin-top: 4px; } .metric-subtitle { color: #0f172a !important; font-size: 12px; margin-top: 4px; } /* Claim box */ .claim-box { background: #fff7ed !important; border: 1px solid #fed7aa !important; border-left: 5px solid #f97316 !important; padding: 16px 18px; border-radius: 12px; color: #7c2d12 !important; line-height: 1.5; } .claim-box, .claim-box * { color: #7c2d12 !important; } /* White information panels: Benchmark highlights, Expanded temporal-client design, validation panels */ .soft-panel, .validation-panel { background: #ffffff !important; border: 1px solid #e5e7eb !important; padding: 16px 18px; border-radius: 14px; box-shadow: 0 1px 8px rgba(15, 23, 42, 0.05); color: #0f172a !important; } .soft-panel *, .validation-panel * { color: #0f172a !important; } .soft-panel h1, .soft-panel h2, .soft-panel h3, .soft-panel h4, .soft-panel p, .soft-panel li, .soft-panel ul, .soft-panel ol, .soft-panel b, .soft-panel strong, .validation-panel h1, .validation-panel h2, .validation-panel h3, .validation-panel h4, .validation-panel p, .validation-panel li, .validation-panel ul, .validation-panel ol, .validation-panel b, .validation-panel strong { color: #0f172a !important; } /* Specifically force these headings and tab section titles black */ .gradio-container h3, .gradio-container .prose h3, .gradio-container .markdown h3 { color: #0f172a !important; font-weight: 800 !important; } /* Dataframe labels and component labels */ .gradio-container label, .gradio-container .label-wrap, .gradio-container .block-title, .gradio-container .block-label { color: #0f172a !important; font-weight: 700 !important; } /* Accordion labels */ .gradio-container details, .gradio-container summary, .gradio-container summary * { color: #0f172a !important; } /* Dropdown text */ .gradio-container select, .gradio-container input, .gradio-container textarea { color: #0f172a !important; background: #ffffff !important; } /* Dataframe/table text */ .gradio-container table, .gradio-container table *, .gradio-container .table-wrap, .gradio-container .table-wrap * { color: #0f172a !important; } /* Keep primary button readable */ .gradio-container button.primary, .gradio-container .primary { color: #ffffff !important; } """ CSS += """ /* ============================================================ Dataframe/table readability fix Force Gradio dataframe tables to white background + black text ============================================================ */ .gradio-container [data-testid="dataframe"], .gradio-container [data-testid="dataframe"] *, .gradio-container .dataframe, .gradio-container .dataframe *, .gradio-container .table-wrap, .gradio-container .table-wrap *, .gradio-container table, .gradio-container table *, .gradio-container thead, .gradio-container thead *, .gradio-container tbody, .gradio-container tbody *, .gradio-container tr, .gradio-container tr *, .gradio-container th, .gradio-container th *, .gradio-container td, .gradio-container td * { color: #0f172a !important; background-color: #ffffff !important; } /* Header row slightly shaded but still readable */ .gradio-container th, .gradio-container thead th, .gradio-container [data-testid="dataframe"] th { color: #0f172a !important; background-color: #f1f5f9 !important; font-weight: 800 !important; } /* Body cells */ .gradio-container td, .gradio-container tbody td, .gradio-container [data-testid="dataframe"] td { color: #0f172a !important; background-color: #ffffff !important; } /* Row hover */ .gradio-container tr:hover, .gradio-container tr:hover *, .gradio-container tbody tr:hover, .gradio-container tbody tr:hover * { color: #0f172a !important; background-color: #e0f2fe !important; } /* Dataframe scroll/container background */ .gradio-container [data-testid="dataframe"] > div, .gradio-container .table-wrap, .gradio-container .dataframe { background-color: #ffffff !important; } /* Dataframe labels remain black */ .gradio-container .block-label, .gradio-container .block-title, .gradio-container label { color: #0f172a !important; } """ # ============================================================ # Final UI readability override # Keeps tabs/panels black-on-white and fixes Gradio dataframe tables. # ============================================================ CSS += r""" /* Final dashboard text readability */ .gradio-container, .gradio-container * { text-rendering: optimizeLegibility; } /* Tabs: black readable labels on white/light background */ .gradio-container [role="tab"], .gradio-container button[role="tab"], .gradio-container .tab-nav button, .gradio-container .tabs button { color: #0f172a !important; background-color: #f8fafc !important; font-weight: 800 !important; opacity: 1 !important; } .gradio-container [role="tab"][aria-selected="true"], .gradio-container button[role="tab"][aria-selected="true"] { color: #0f172a !important; background-color: #ffffff !important; border-bottom: 3px solid #2563eb !important; } /* Markdown section headings */ .gradio-container .prose, .gradio-container .prose *, .gradio-container .markdown, .gradio-container .markdown *, .gradio-container [data-testid="markdown"], .gradio-container [data-testid="markdown"] * { color: #0f172a !important; } /* White info panels */ .soft-panel, .validation-panel { background: #ffffff !important; color: #0f172a !important; } .soft-panel *, .validation-panel * { color: #0f172a !important; } /* ============================================================ Gradio Dataframe / Tabulator readability Gradio dataframes are often rendered by Tabulator, not plain HTML tables. ============================================================ */ .gradio-container [data-testid="dataframe"], .gradio-container [data-testid="dataframe"] > div, .gradio-container [data-testid="dataframe"] div, .gradio-container .dataframe, .gradio-container .table-wrap, .gradio-container .wrap.svelte-1cl284s, .gradio-container .tabulator, .gradio-container .tabulator-tableholder, .gradio-container .tabulator-table, .gradio-container .tabulator-row, .gradio-container .tabulator-row .tabulator-cell, .gradio-container .tabulator-header, .gradio-container .tabulator-header .tabulator-col, .gradio-container .tabulator-header .tabulator-col-content, .gradio-container .tabulator-header .tabulator-col-title, .gradio-container .tabulator-footer, .gradio-container .tabulator-paginator, .gradio-container .tabulator-page, .gradio-container .gridjs, .gradio-container .gridjs-wrapper, .gradio-container .gridjs-container, .gradio-container .gridjs-table, .gradio-container .gridjs-thead, .gradio-container .gridjs-tbody, .gradio-container .gridjs-tr, .gradio-container .gridjs-th, .gradio-container .gridjs-td { background-color: #ffffff !important; color: #0f172a !important; border-color: #e2e8f0 !important; } /* Header cells */ .gradio-container .tabulator-header, .gradio-container .tabulator-header .tabulator-col, .gradio-container .tabulator-header .tabulator-col-content, .gradio-container .tabulator-header .tabulator-col-title, .gradio-container .gridjs-th, .gradio-container table thead, .gradio-container table thead *, .gradio-container th { background-color: #f1f5f9 !important; color: #0f172a !important; font-weight: 800 !important; } /* Body cells */ .gradio-container .tabulator-cell, .gradio-container .gridjs-td, .gradio-container table, .gradio-container table *, .gradio-container tbody, .gradio-container tbody *, .gradio-container tr, .gradio-container tr *, .gradio-container td, .gradio-container td * { background-color: #ffffff !important; color: #0f172a !important; } /* Alternating row and hover still readable */ .gradio-container .tabulator-row:nth-child(even), .gradio-container .tabulator-row:nth-child(even) .tabulator-cell, .gradio-container tbody tr:nth-child(even), .gradio-container tbody tr:nth-child(even) td { background-color: #f8fafc !important; color: #0f172a !important; } .gradio-container .tabulator-row:hover, .gradio-container .tabulator-row:hover .tabulator-cell, .gradio-container tbody tr:hover, .gradio-container tbody tr:hover td, .gradio-container tbody tr:hover * { background-color: #e0f2fe !important; color: #0f172a !important; } /* Dataframe controls, pagination, search/filter boxes */ .gradio-container [data-testid="dataframe"] input, .gradio-container [data-testid="dataframe"] textarea, .gradio-container [data-testid="dataframe"] select, .gradio-container .tabulator input, .gradio-container .tabulator select, .gradio-container .tabulator textarea, .gradio-container .gridjs-input, .gradio-container .gridjs-search-input { background-color: #ffffff !important; color: #0f172a !important; border-color: #cbd5e1 !important; } /* Dataframe labels */ .gradio-container .block-label, .gradio-container .block-title, .gradio-container label { color: #0f172a !important; font-weight: 700 !important; } /* Preserve primary button readability */ .gradio-container button.primary, .gradio-container .primary { color: #ffffff !important; } """ INLINE_STYLE_FIX = r""" """ def update_expanded_client_tab(scheme): if len(expanded_client_summary) and "client_scheme" in expanded_client_summary.columns: table = expanded_client_summary[expanded_client_summary["client_scheme"].astype(str) == str(scheme)] else: table = expanded_client_summary return fig_expanded_client_sizes(scheme), fig_expanded_client_prevalence(scheme), to_display_df(table) def update_fl_tab(scheme): if len(fl_results) and "client_scheme" in fl_results.columns: table = fl_results[fl_results["client_scheme"].astype(str) == str(scheme)] else: table = fl_results return fig_fl_performance_by_scheme(scheme), fig_round_convergence(scheme), to_display_df(table) def update_poisoning_tab(scheme): if len(fl_updates) and "client_scheme" in fl_updates.columns: table = fl_updates[fl_updates["client_scheme"].astype(str) == str(scheme)] else: table = fl_updates return fig_robust_heatmap(scheme), fig_update_rejections(scheme), fig_update_norms(scheme), to_display_df(table, max_rows=600) # ============================================================ # Gradio app # ============================================================ with gr.Blocks( title="BlockFL-VSCC Dashboard", css=CSS, theme=gr.themes.Soft(primary_hue="blue", neutral_hue="slate"), ) as app: gr.HTML(INLINE_STYLE_FIX) gr.HTML( """

BlockFL-VSCC Dashboard

Blockchain-audited federated learning for registry-based prediction of regional lymph-node positivity in vulvar squamous cell carcinoma.

""" ) gr.HTML(cards_html) gr.HTML(claim_html) with gr.Tabs(): with gr.Tab("Executive overview"): gr.Markdown("### Study summary") with gr.Row(): gr.Plot(value=fig_cohort_endpoint()) gr.Plot(value=fig_expanded_scheme_compare()) with gr.Row(): gr.Plot(value=fig_fl_performance_by_scheme(primary_scheme)) gr.Plot(value=fig_attack_tests()) gr.HTML(benchmark_html) with gr.Tab("Cohort"): gr.Markdown("### Aggregate SEER VSCC exact-node cohort") with gr.Row(): gr.Plot(value=fig_year_distribution()) gr.Plot(value=fig_cohort_endpoint()) gr.Dataframe(value=to_display_df(year_summary), label="Year-level aggregate cohort summary", interactive=False, wrap=True) with gr.Tab("Expanded federated clients"): gr.Markdown("### Temporal client expansion") gr.HTML(expanded_client_html) scheme_selector_clients = gr.Dropdown(choices=available_schemes, value=primary_scheme, label="Federated-client scheme") with gr.Row(): client_size_plot = gr.Plot(value=fig_expanded_client_sizes(primary_scheme)) client_prev_plot = gr.Plot(value=fig_expanded_client_prevalence(primary_scheme)) if len(expanded_client_summary) and "client_scheme" in expanded_client_summary.columns: init_client_table = expanded_client_summary[expanded_client_summary["client_scheme"].astype(str) == str(primary_scheme)] else: init_client_table = expanded_client_summary client_summary_table = gr.Dataframe(value=to_display_df(init_client_table), label="Expanded temporal-client summary", interactive=False, wrap=True) gr.Plot(value=fig_expanded_scheme_compare()) scheme_selector_clients.change(fn=update_expanded_client_tab, inputs=scheme_selector_clients, outputs=[client_size_plot, client_prev_plot, client_summary_table]) with gr.Tab("FL performance"): gr.Markdown("### Centralized, federated, and expanded temporal-client performance") scheme_selector_fl = gr.Dropdown(choices=available_schemes, value=primary_scheme, label="Federated-client scheme") with gr.Row(): fl_perf_plot = gr.Plot(value=fig_fl_performance_by_scheme(primary_scheme)) fl_conv_plot = gr.Plot(value=fig_round_convergence(primary_scheme)) gr.Plot(value=fig_expanded_scheme_compare()) if len(fl_results) and "client_scheme" in fl_results.columns: init_fl_table = fl_results[fl_results["client_scheme"].astype(str) == str(primary_scheme)] else: init_fl_table = fl_results fl_result_table = gr.Dataframe(value=to_display_df(init_fl_table), label="FL result table", interactive=False, wrap=True) scheme_selector_fl.change(fn=update_fl_tab, inputs=scheme_selector_fl, outputs=[fl_perf_plot, fl_conv_plot, fl_result_table]) with gr.Tab("Poisoning/audit"): gr.Markdown( """ ### Poisoning, audit-gated validation and robust aggregation The poisoning experiments are simplified stress tests. They evaluate label-flip and random-noise update attacks under unaudited FedAvg, audit-gated FedAvg, and robust aggregation sensitivity. """ ) scheme_selector_poison = gr.Dropdown(choices=available_schemes, value=primary_scheme, label="Federated-client scheme") with gr.Row(): poison_heatmap = gr.Plot(value=fig_robust_heatmap(primary_scheme)) reject_plot = gr.Plot(value=fig_update_rejections(primary_scheme)) norm_plot = gr.Plot(value=fig_update_norms(primary_scheme)) if len(fl_updates) and "client_scheme" in fl_updates.columns: init_update_table = fl_updates[fl_updates["client_scheme"].astype(str) == str(primary_scheme)] else: init_update_table = fl_updates update_table = gr.Dataframe(value=to_display_df(init_update_table, max_rows=600), label="Client update audit records", interactive=False, wrap=True) scheme_selector_poison.change(fn=update_poisoning_tab, inputs=scheme_selector_poison, outputs=[poison_heatmap, reject_plot, norm_plot, update_table]) with gr.Tab("Blockchain validation"): gr.Markdown("### Fully validated permissioned blockchain proof-of-concept") with gr.Row(): gr.Plot(value=fig_blockchain_blocks()) gr.Plot(value=fig_attack_tests()) with gr.Row(): gr.Dataframe(value=to_display_df(blocks_df), label="Committed blocks", interactive=False, wrap=True) with gr.Row(): gr.Dataframe(value=to_display_df(attack_df), label="Attack and validation tests", interactive=False, wrap=True) with gr.Accordion("Validation summary JSON", open=False): gr.JSON(value=bc_summary if bc_summary else bc_validation) with gr.Tab("Benchmark overhead"): gr.Markdown("### Computational overhead, storage efficiency and resilience") gr.HTML(benchmark_html) with gr.Row(): gr.Plot(value=fig_benchmark_overhead()) gr.Plot(value=fig_storage_efficiency()) with gr.Row(): gr.Plot(value=fig_resilience_logs()) gr.Plot(value=fig_timing_components()) gr.Plot(value=fig_projected_overhead()) with gr.Row(): gr.Dataframe(value=to_display_df(overhead_summary), label="Computational overhead summary", interactive=False, wrap=True) gr.Dataframe(value=to_display_df(storage_summary), label="Storage/network efficiency summary", interactive=False, wrap=True) with gr.Tab("Real-time validation"): gr.Markdown( """ ### Real-time validation framework This panel reruns validation checks directly from the exported assets. It recomputes blockchain hashes, previous-hash links, Merkle roots, transaction hashes, replay checks, expanded-client counts, FL sanity checks, poisoning/audit behavior and benchmark thresholds. """ ) run_button = gr.Button("Run validation now", variant="primary") realtime_html = gr.HTML(value="

Validation not run yet

Click the button to run live checks.

") realtime_table = gr.Dataframe(value=pd.DataFrame({"message": ["Click Run validation now."]}), label="Validation checks", interactive=False, wrap=True) realtime_plot = gr.Plot(value=empty_plot("Validation not run yet")) realtime_json = gr.JSON(value={}) run_button.click(fn=run_realtime_validation, inputs=[], outputs=[realtime_html, realtime_table, realtime_plot, realtime_json]) if __name__ == "__main__": app.launch()