Spaces:
Sleeping
Sleeping
| 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() | |