BlockFL-VSCC / app.py
ashaddams's picture
Update app.py
386f72d verified
Raw
History Blame Contribute Delete
73.8 kB
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"""
<div class="metric-card" style="border-left: 5px solid {accent};">
<div class="metric-title">{title}</div>
<div class="metric-value">{value}</div>
<div class="metric-subtitle">{subtitle}</div>
</div>
"""
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"""
<div class="validation-panel" style="border-left: 6px solid {'#16a34a' if n_fail == 0 else '#dc2626'};">
<h3>Real-time validation status: {summary['overall_status']}</h3>
<p><b>{n_pass}</b> passed, <b>{n_fail}</b> failed, total checks: <b>{len(val_df)}</b>.</p>
<p>Last run: {summary['timestamp_utc']}</p>
</div>
"""
return html, val_df, fig, summary
# ============================================================
# HTML and CSS
# ============================================================
cards_html = f"""
<div class="cards">
{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")}
</div>
"""
claim_html = """
<div class="claim-box">
<b>Claim boundary.</b>
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.
</div>
"""
benchmark_html = f"""
<div class="soft-panel">
<h3>Benchmark highlights</h3>
<ul>
<li><b>Chain validation:</b> {bench_chain_valid}; validation errors: {bc_benchmark_json.get("chain_validation_errors", "NA")}</li>
<li><b>Median Merkle-root generation:</b> {fmt_num(merkle_us)} microseconds per block.</li>
<li><b>Median commit certificate / full block ratio:</b> {fmt_num(100 * commit_ratio if not pd.isna(commit_ratio) else np.nan)}%.</li>
<li><b>Median storage saving vs full block replication:</b> {fmt_num(storage_saving)}%.</li>
<li><b>Replay/fork/tamper/Merkle resilience:</b> {bc_benchmark_json.get("resilience_tests_all_passed", "NA")}.</li>
<li><b>End-to-end audit overhead:</b> {fmt_num(audit_ms)} ms per lightweight FL round; projected to become negligible as local hospital-scale training duration increases.</li>
</ul>
</div>
"""
expanded_client_html = """
<div class="soft-panel">
<h3>Expanded temporal-client design</h3>
<ul>
<li><b>Primary federation:</b> 10 simulated temporal clients, mostly two-year diagnosis windows from 2004-2005 through 2022-2023.</li>
<li><b>Sensitivity federation:</b> 20 annual temporal clients, one client per diagnosis year from 2004 through 2023.</li>
<li><b>Interpretation:</b> these are time-stratified registry silos, not hospitals or prospective institutional deployments.</li>
</ul>
</div>
"""
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"""
<style>
[role="tab"], button[role="tab"], .tab-nav button, .tabs button {
color: #0f172a !important;
background-color: #f8fafc !important;
font-weight: 800 !important;
opacity: 1 !important;
}
[role="tab"][aria-selected="true"], button[role="tab"][aria-selected="true"] {
color: #0f172a !important;
background-color: #ffffff !important;
border-bottom: 3px solid #2563eb !important;
}
.prose, .prose *, .markdown, .markdown *, [data-testid="markdown"], [data-testid="markdown"] * {
color: #0f172a !important;
}
.soft-panel, .soft-panel *, .validation-panel, .validation-panel * {
color: #0f172a !important;
}
[data-testid="dataframe"], [data-testid="dataframe"] *,
.dataframe, .dataframe *, .table-wrap, .table-wrap *,
.tabulator, .tabulator *, .gridjs, .gridjs *,
table, table *, thead, tbody, tr, th, td {
color: #0f172a !important;
background-color: #ffffff !important;
border-color: #e2e8f0 !important;
}
.tabulator-header, .tabulator-header *, .tabulator-col, .tabulator-col *,
th, thead th, .gridjs-th {
background-color: #f1f5f9 !important;
color: #0f172a !important;
font-weight: 800 !important;
}
.tabulator-row:nth-child(even), .tabulator-row:nth-child(even) .tabulator-cell,
tbody tr:nth-child(even), tbody tr:nth-child(even) td {
background-color: #f8fafc !important;
color: #0f172a !important;
}
</style>
"""
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(
"""
<div id="title-block">
<h1>BlockFL-VSCC Dashboard</h1>
<p>Blockchain-audited federated learning for registry-based prediction of regional lymph-node positivity in vulvar squamous cell carcinoma.</p>
</div>
"""
)
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="<div class='validation-panel'><h3>Validation not run yet</h3><p>Click the button to run live checks.</p></div>")
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()