diff --git a/.gitignore b/.gitignore index d5fb9145..9d07e71f 100644 --- a/.gitignore +++ b/.gitignore @@ -160,7 +160,7 @@ cython_debug/ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ +.idea/ # datasets metadata/ diff --git a/aidrin/structured_data_metrics/add_noise.py b/aidrin/structured_data_metrics/add_noise.py index a1686b8d..ac339494 100644 --- a/aidrin/structured_data_metrics/add_noise.py +++ b/aidrin/structured_data_metrics/add_noise.py @@ -17,7 +17,7 @@ def add_laplace_noise(data, epsilon): raise Exception("Epsilon cannot be 0") -def return_noisy_stats(add_noise_columns, epsilon, file_info): +def return_noisy_stats(add_noise_columns, epsilon, file_info, save_output=True, include_visualization=True): # Convert JSON back to DataFrame if needed, otherwise use DataFrame directly import pandas as pd @@ -43,18 +43,20 @@ def return_noisy_stats(add_noise_columns, epsilon, file_info): num_cols = min(num_columns, max_columns_per_row) # Create subplots for the box plots - fig, axes = plt.subplots(num_rows, num_cols, figsize=(8, 8)) + if include_visualization: + fig, axes = plt.subplots(num_rows, num_cols, figsize=(8, 8)) for i, column in enumerate(add_noise_columns): - if num_rows == 1 and num_cols == 1: - current_ax = axes - elif num_rows == 1: - current_ax = axes[i % num_cols] - elif num_cols == 1: - current_ax = axes[i % num_rows, 0] - else: - row, col = divmod(i, num_cols) - current_ax = axes[row, col] + if include_visualization: + if num_rows == 1 and num_cols == 1: + current_ax = axes + elif num_rows == 1: + current_ax = axes[i % num_cols] + elif num_cols == 1: + current_ax = axes[i % num_rows, 0] + else: + row, col = divmod(i, num_cols) + current_ax = axes[row, col] noisy_feature = add_laplace_noise(df_drop_na[column], epsilon) @@ -85,36 +87,39 @@ def return_noisy_stats(add_noise_columns, epsilon, file_info): ) df_drop_na[f'noisy_{column}'] = noisy_feature - # Box plot for the normal feature - current_ax.boxplot( - df_drop_na[column], positions=[0], widths=0.6, showfliers=False - ) - current_ax.set_title(f"Normal vs Noisy representations: Feature {column}") - current_ax.set_ylabel("Value") - - # Box plot for the noisy feature - current_ax.boxplot(noisy_feature, positions=[1], widths=0.6, showfliers=False) - current_ax.set_ylabel("Value") - - # Adjust the spacing between subplots - plt.tight_layout() - - # Save the chart as BytesIO - img_buf = BytesIO() - plt.savefig(img_buf, format="png") - img_buf.seek(0) - - # Encode the combined image as base64 - combined_image_base64 = base64.b64encode(img_buf.getvalue()).decode("utf-8") - img_buf.close() - try: - # Create the new directory - os.makedirs("noisy", exist_ok=True) - df_drop_na.to_csv("noisy/noisy_data.csv", index=False) - stat_dict["Noisy file saved"] = "Successful" - except Exception: - stat_dict["Noisy file saved"] = "Error" - - stat_dict["DP Statistics Visualization"] = combined_image_base64 + if include_visualization: + # Box plot for the normal feature + current_ax.boxplot( + df_drop_na[column], positions=[0], widths=0.6, showfliers=False + ) + current_ax.set_title(f"Normal vs Noisy representations: Feature {column}") + current_ax.set_ylabel("Value") + + # Box plot for the noisy feature + current_ax.boxplot(noisy_feature, positions=[1], widths=0.6, showfliers=False) + current_ax.set_ylabel("Value") + + if include_visualization: + # Adjust the spacing between subplots + plt.tight_layout() + + # Save the chart as BytesIO + img_buf = BytesIO() + plt.savefig(img_buf, format="png") + img_buf.seek(0) + + # Encode the combined image as base64 + combined_image_base64 = base64.b64encode(img_buf.getvalue()).decode("utf-8") + img_buf.close() + stat_dict["DP Statistics Visualization"] = combined_image_base64 + if save_output: + try: + os.makedirs("noisy", exist_ok=True) + df_drop_na.to_csv("noisy/noisy_data.csv", index=False) + stat_dict["Noisy file saved"] = "Successful" + except Exception: + stat_dict["Noisy file saved"] = "Error" + else: + stat_dict["Noisy file saved"] = "Skipped (readiness report preview only)" return stat_dict diff --git a/aidrin/structured_data_metrics/completeness.py b/aidrin/structured_data_metrics/completeness.py index 3f27e259..efca48e1 100644 --- a/aidrin/structured_data_metrics/completeness.py +++ b/aidrin/structured_data_metrics/completeness.py @@ -12,7 +12,7 @@ @shared_task(bind=True, ignore_result=False) -def completeness(self: Task, file_info): +def completeness(self: Task, file_info, include_visualization=True): """Compute per-column and overall completeness (non-missing rate) for a dataset. Reads the file, calculates the proportion of non-null values for every @@ -64,46 +64,47 @@ def completeness(self: Task, file_info): result_dict["Completeness scores"] = completeness_scores result_dict["Overall Completeness"] = overall_completeness - # Horizontal bar chart — grows vertically with feature count - labels = list(completeness_scores.keys()) - values = list(completeness_scores.values()) - n = len(labels) - text_color = "#6b7280" - - fig_height = max(3, n * 0.3) - fig, ax = plt.subplots(figsize=(8, fig_height)) - fig.patch.set_alpha(0) - ax.set_facecolor("none") - - bars = ax.barh(range(n), values, color="#4485F4", height=0.7) - ax.set_xlabel("Completeness Score", fontsize=10, color=text_color) - ax.set_yticks(range(n)) - ax.set_yticklabels(labels, fontsize=9, color=text_color) - ax.tick_params(axis="x", colors=text_color, labelsize=8) - ax.set_xlim(0, 1.12) - ax.invert_yaxis() - ax.set_ylim(n - 0.5, -0.5) - - for spine in ax.spines.values(): - spine.set_color(text_color) - - for bar, val in zip(bars, values): - if val > 0.15: - ax.text(val - 0.01, bar.get_y() + bar.get_height() / 2, - f'{val:.2f}', ha='right', va='center', fontsize=8, color='white', fontweight='bold') - else: - ax.text(val + 0.01, bar.get_y() + bar.get_height() / 2, - f'{val:.2f}', ha='left', va='center', fontsize=8, color=text_color) - - fig.tight_layout(pad=0.5) - - img_buf = io.BytesIO() - fig.savefig(img_buf, format="png", dpi=150, transparent=True) - img_buf.seek(0) - - img_base64 = base64.b64encode(img_buf.read()).decode("utf-8") - result_dict["Completeness Visualization"] = img_base64 - plt.close(fig) + if include_visualization: + # Horizontal bar chart — grows vertically with feature count + labels = list(completeness_scores.keys()) + values = list(completeness_scores.values()) + n = len(labels) + text_color = "#6b7280" + + fig_height = max(3, n * 0.3) + fig, ax = plt.subplots(figsize=(8, fig_height)) + fig.patch.set_alpha(0) + ax.set_facecolor("none") + + bars = ax.barh(range(n), values, color="#4485F4", height=0.7) + ax.set_xlabel("Completeness Score", fontsize=10, color=text_color) + ax.set_yticks(range(n)) + ax.set_yticklabels(labels, fontsize=9, color=text_color) + ax.tick_params(axis="x", colors=text_color, labelsize=8) + ax.set_xlim(0, 1.12) + ax.invert_yaxis() + ax.set_ylim(n - 0.5, -0.5) + + for spine in ax.spines.values(): + spine.set_color(text_color) + + for bar, val in zip(bars, values): + if val > 0.15: + ax.text(val - 0.01, bar.get_y() + bar.get_height() / 2, + f'{val:.2f}', ha='right', va='center', fontsize=8, color='white', fontweight='bold') + else: + ax.text(val + 0.01, bar.get_y() + bar.get_height() / 2, + f'{val:.2f}', ha='left', va='center', fontsize=8, color=text_color) + + fig.tight_layout(pad=0.5) + + img_buf = io.BytesIO() + fig.savefig(img_buf, format="png", dpi=150, transparent=True) + img_buf.seek(0) + + img_base64 = base64.b64encode(img_buf.read()).decode("utf-8") + result_dict["Completeness Visualization"] = img_base64 + plt.close(fig) logger.info("Completeness task completed: %d columns, overall=%.4f", len(completeness_scores), overall_completeness) return result_dict diff --git a/aidrin/structured_data_metrics/correlation_score.py b/aidrin/structured_data_metrics/correlation_score.py index 17051f0f..de1b3669 100644 --- a/aidrin/structured_data_metrics/correlation_score.py +++ b/aidrin/structured_data_metrics/correlation_score.py @@ -56,7 +56,7 @@ def _is_column_normal(series: pd.Series) -> bool: @shared_task(bind=True, ignore_result=False) -def calc_correlations(self: Task, columns: List[str], file_info): +def calc_correlations(self: Task, columns: List[str], file_info, include_visualization=True): df = read_file(file_info) try: # Separate categorical and numerical columns @@ -77,51 +77,52 @@ def calc_correlations(self: Task, columns: List[str], file_info): ) logger.debug("Categorical correlation matrix computed:\n%s", categorical_correlation["corr"]) - corr_matrix = categorical_correlation["corr"] - n = len(corr_matrix.columns) - fig_size = max(6, n * 0.7) - text_color = "#6b7280" - - fig, ax = plt.subplots(figsize=(fig_size, fig_size)) - fig.patch.set_alpha(0) - ax.set_facecolor("none") - - annot_size = max(7, min(10, 80 // max(n, 1))) - _ = sns.heatmap( - corr_matrix, annot=True, cmap="coolwarm", fmt=".2f", ax=ax, - annot_kws={"size": annot_size}, - linewidths=0.5, linecolor="#e5e7eb", - cbar=False, - ) - - # Truncate long labels - x_labels = [t.get_text()[:12] + "..." if len(t.get_text()) > 12 else t.get_text() for t in ax.get_xticklabels()] - y_labels = [t.get_text()[:12] + "..." if len(t.get_text()) > 12 else t.get_text() for t in ax.get_yticklabels()] - ax.set_xticklabels(x_labels, rotation=45, ha="right", fontsize=9, color=text_color) - ax.set_yticklabels(y_labels, rotation=0, fontsize=9, color=text_color) - - fig.tight_layout(pad=0.5) - - # Save the plot to a BytesIO object - image_stream_cat = BytesIO() - fig.savefig(image_stream_cat, format="png", dpi=150, transparent=True) - plt.close(fig) - - # Convert the plot to base64 - base64_image_cat = base64.b64encode(image_stream_cat.getvalue()).decode( - "utf-8" - ) - - # Close the BytesIO stream - image_stream_cat.close() - - result_dict["Correlations Analysis Categorical"][ - "Correlations Analysis Categorical Visualization" - ] = base64_image_cat - result_dict["Correlations Analysis Categorical"]["Description"] = ( - "Categorical correlations are calculated using Theil's U, with values ranging from 0 to 1. " - "A value of 1 indicates a perfect correlation, while a value of 0 indicates no correlation" - ) + if include_visualization: + corr_matrix = categorical_correlation["corr"] + n = len(corr_matrix.columns) + fig_size = max(6, n * 0.7) + text_color = "#6b7280" + + fig, ax = plt.subplots(figsize=(fig_size, fig_size)) + fig.patch.set_alpha(0) + ax.set_facecolor("none") + + annot_size = max(7, min(10, 80 // max(n, 1))) + _ = sns.heatmap( + corr_matrix, annot=True, cmap="coolwarm", fmt=".2f", ax=ax, + annot_kws={"size": annot_size}, + linewidths=0.5, linecolor="#e5e7eb", + cbar=False, + ) + + # Truncate long labels + x_labels = [t.get_text()[:12] + "..." if len(t.get_text()) > 12 else t.get_text() for t in ax.get_xticklabels()] + y_labels = [t.get_text()[:12] + "..." if len(t.get_text()) > 12 else t.get_text() for t in ax.get_yticklabels()] + ax.set_xticklabels(x_labels, rotation=45, ha="right", fontsize=9, color=text_color) + ax.set_yticklabels(y_labels, rotation=0, fontsize=9, color=text_color) + + fig.tight_layout(pad=0.5) + + # Save the plot to a BytesIO object + image_stream_cat = BytesIO() + fig.savefig(image_stream_cat, format="png", dpi=150, transparent=True) + plt.close(fig) + + # Convert the plot to base64 + base64_image_cat = base64.b64encode(image_stream_cat.getvalue()).decode( + "utf-8" + ) + + # Close the BytesIO stream + image_stream_cat.close() + + result_dict["Correlations Analysis Categorical"][ + "Correlations Analysis Categorical Visualization" + ] = base64_image_cat + result_dict["Correlations Analysis Categorical"]["Description"] = ( + "Categorical correlations are calculated using Theil's U, with values ranging from 0 to 1. " + "A value of 1 indicates a perfect correlation, while a value of 0 indicates no correlation" + ) # Check if there are numerical features if not numerical_columns.empty: @@ -135,50 +136,51 @@ def calc_correlations(self: Task, columns: List[str], file_info): # Numerical-numerical correlations are computed dynamically based on normality. numerical_correlation = numerical_df.corr(method=corr_method) - n = len(numerical_correlation.columns) - fig_size = max(6, n * 0.7) - text_color = "#6b7280" - - fig, ax = plt.subplots(figsize=(fig_size, fig_size)) - fig.patch.set_alpha(0) - ax.set_facecolor("none") - - annot_size = max(7, min(10, 80 // max(n, 1))) - _ = sns.heatmap( - numerical_correlation, annot=True, cmap="coolwarm", fmt=".2f", ax=ax, - annot_kws={"size": annot_size}, - linewidths=0.5, linecolor="#e5e7eb", - cbar=False, - ) - - x_labels = [t.get_text()[:12] + "..." if len(t.get_text()) > 12 else t.get_text() for t in ax.get_xticklabels()] - y_labels = [t.get_text()[:12] + "..." if len(t.get_text()) > 12 else t.get_text() for t in ax.get_yticklabels()] - ax.set_xticklabels(x_labels, rotation=45, ha="right", fontsize=9, color=text_color) - ax.set_yticklabels(y_labels, rotation=0, fontsize=9, color=text_color) - - fig.tight_layout(pad=0.5) - - # Save the plot to a BytesIO object - image_stream_num = BytesIO() - fig.savefig(image_stream_num, format="png", dpi=150, transparent=True) - plt.close(fig) - - # Convert the plot to base64 - base64_image_num = base64.b64encode(image_stream_num.getvalue()).decode( - "utf-8" - ) - - # Close the BytesIO stream - image_stream_num.close() - - result_dict["Correlations Analysis Numerical"][ - "Correlations Analysis Numerical Visualization" - ] = base64_image_num - result_dict["Correlations Analysis Numerical"]["Description"] = ( - f"Numerical correlations are calculated using {corr_method.title()}'s correlation coefficient, with values " - "ranging from -1 to 1. A value of 1 indicates a perfect positive correlation, -1 indicates a perfect " - "negative correlation, and 0 indicates no correlation" - ) + if include_visualization: + n = len(numerical_correlation.columns) + fig_size = max(6, n * 0.7) + text_color = "#6b7280" + + fig, ax = plt.subplots(figsize=(fig_size, fig_size)) + fig.patch.set_alpha(0) + ax.set_facecolor("none") + + annot_size = max(7, min(10, 80 // max(n, 1))) + _ = sns.heatmap( + numerical_correlation, annot=True, cmap="coolwarm", fmt=".2f", ax=ax, + annot_kws={"size": annot_size}, + linewidths=0.5, linecolor="#e5e7eb", + cbar=False, + ) + + x_labels = [t.get_text()[:12] + "..." if len(t.get_text()) > 12 else t.get_text() for t in ax.get_xticklabels()] + y_labels = [t.get_text()[:12] + "..." if len(t.get_text()) > 12 else t.get_text() for t in ax.get_yticklabels()] + ax.set_xticklabels(x_labels, rotation=45, ha="right", fontsize=9, color=text_color) + ax.set_yticklabels(y_labels, rotation=0, fontsize=9, color=text_color) + + fig.tight_layout(pad=0.5) + + # Save the plot to a BytesIO object + image_stream_num = BytesIO() + fig.savefig(image_stream_num, format="png", dpi=150, transparent=True) + plt.close(fig) + + # Convert the plot to base64 + base64_image_num = base64.b64encode(image_stream_num.getvalue()).decode( + "utf-8" + ) + + # Close the BytesIO stream + image_stream_num.close() + + result_dict["Correlations Analysis Numerical"][ + "Correlations Analysis Numerical Visualization" + ] = base64_image_num + result_dict["Correlations Analysis Numerical"]["Description"] = ( + f"Numerical correlations are calculated using {corr_method.title()}'s correlation coefficient, with values " + "ranging from -1 to 1. A value of 1 indicates a perfect positive correlation, -1 indicates a perfect " + "negative correlation, and 0 indicates no correlation" + ) result_dict["Correlations Analysis Numerical"]["Method"] = ( corr_method.title() ) diff --git a/aidrin/structured_data_metrics/outliers.py b/aidrin/structured_data_metrics/outliers.py index 5f8a128f..2c09ba4e 100644 --- a/aidrin/structured_data_metrics/outliers.py +++ b/aidrin/structured_data_metrics/outliers.py @@ -13,7 +13,7 @@ @shared_task(bind=True, ignore_result=False) -def outliers(self: Task, file_info): +def outliers(self: Task, file_info, include_visualization=True): """Detect outliers in numerical columns using the IQR method. For each numerical column, computes the inter-quartile range (IQR) and @@ -92,7 +92,7 @@ def outliers(self: Task, file_info): k: v for k, v in proportions_dict.items() if k != "Overall outlier score" } - if feature_scores: # only plot if there are valid features + if feature_scores and include_visualization: # only plot if there are valid features labels = list(feature_scores.keys()) values = list(feature_scores.values()) n = len(labels) diff --git a/aidrin/structured_data_metrics/privacy_measure.py b/aidrin/structured_data_metrics/privacy_measure.py index 47d488a9..107e279d 100644 --- a/aidrin/structured_data_metrics/privacy_measure.py +++ b/aidrin/structured_data_metrics/privacy_measure.py @@ -13,7 +13,7 @@ logger = logging.getLogger(__name__) -def generate_single_attribute_MM_risk_scores(df, id_col, eval_cols, task=None): +def generate_single_attribute_MM_risk_scores(df, id_col, eval_cols, task=None, include_visualization=True): """Compute Marketer/Prosecutor model re-identification risk for each quasi-identifier. For every column in *eval_cols*, calculates the proportion of individuals @@ -176,32 +176,34 @@ def generate_single_attribute_MM_risk_scores(df, id_col, eval_cols, task=None): descriptive_stats_dict[key] = stats_dict # Stage 4: Generate visualization (85-100%) - if task: - task.update_state( - state='PROGRESS', - meta={'current': 90, 'total': 100, 'status': 'Generating visualization...'} - ) + if include_visualization: + if task: + task.update_state( + state='PROGRESS', + meta={'current': 90, 'total': 100, 'status': 'Generating visualization...'} + ) - # Create a box plot - plt.figure(figsize=(8, 8)) - plt.boxplot(list(sing_res.values()), tick_labels=list(sing_res.keys())) - plt.title("Box plot of single feature risk scores") - plt.xlabel("Feature") - plt.ylabel("Risk Score") + # Create a box plot + plt.figure(figsize=(8, 8)) + plt.boxplot(list(sing_res.values()), tick_labels=list(sing_res.keys())) + plt.title("Box plot of single feature risk scores") + plt.xlabel("Feature") + plt.ylabel("Risk Score") - # Save the plot as a PNG image in memory - image_stream = io.BytesIO() - plt.tight_layout() - plt.savefig(image_stream, format="png", bbox_inches='tight', dpi=300) - plt.close() + # Save the plot as a PNG image in memory + image_stream = io.BytesIO() + plt.tight_layout() + plt.savefig(image_stream, format="png", bbox_inches='tight', dpi=300) + plt.close() - # Convert the image to a base64 string - image_stream.seek(0) - base64_image = base64.b64encode(image_stream.read()).decode("utf-8") - image_stream.close() + # Convert the image to a base64 string + image_stream.seek(0) + base64_image = base64.b64encode(image_stream.read()).decode("utf-8") + image_stream.close() result_dict["Descriptive statistics of the risk scores"] = descriptive_stats_dict - result_dict["Single attribute risk scoring Visualization"] = base64_image + if include_visualization: + result_dict["Single attribute risk scoring Visualization"] = base64_image result_dict["Description"] = ( "This metric quantifies the re-identification risk for each " "quasi-identifier. Lower values are preferred, indicating " @@ -233,7 +235,7 @@ def generate_single_attribute_MM_risk_scores(df, id_col, eval_cols, task=None): return result_dict -def generate_multiple_attribute_MM_risk_scores(df, id_col, eval_cols, task=None): +def generate_multiple_attribute_MM_risk_scores(df, id_col, eval_cols, task=None, include_visualization=True): """Compute combined Marketer/Prosecutor re-identification risk across all quasi-identifiers. Unlike :func:`generate_single_attribute_MM_risk_scores`, this function @@ -434,31 +436,390 @@ def generate_multiple_attribute_MM_risk_scores(df, id_col, eval_cols, task=None) } # Stage 5: Generate visualization (90-100%) + if include_visualization: + if task: + task.update_state( + state='PROGRESS', + meta={'current': 95, 'total': 100, 'status': 'Generating visualization...'} + ) + + x_label = ",".join(eval_cols) + # Create a box plot + plt.figure(figsize=(8, 8)) + plt.boxplot(risk_scores, orientation="vertical") + plt.title('Box Plot of Multiple Attribute Risk Scores') + plt.ylabel('Risk Score') + plt.xlabel('Feature Combination') + plt.xticks([1], [x_label]) + + # Save the plot as a PNG image in memory + image_stream = io.BytesIO() + plt.tight_layout() + plt.savefig(image_stream, format='png', bbox_inches='tight', dpi=300) + plt.close() + + # Convert the image to a base64 string + image_stream.seek(0) + base64_image = base64.b64encode(image_stream.read()).decode('utf-8') + image_stream.close() + + result_dict["Description"] = ( + "This metric evaluates the joint risk posed by combinations of " + "quasi-identifiers. Lower values are preferred, as they indicate " + "that the selected set of features does not easily allow " + "re-identification." + ) + result_dict["Graph interpretation"] = ( + "The box plot shows the distribution of combined risk scores. A distribution concentrated at lower values indicates better privacy." + ) + result_dict["Descriptive statistics of the risk scores"] = stats_dict + if include_visualization: + result_dict["Multiple attribute risk scoring Visualization"] = base64_image + result_dict['Dataset Risk Score'] = normalized_distance + + except SoftTimeLimitExceeded: + raise Exception("Multiple Attribute Risk task timed out. The dataset may be too large or complex.") + except ValueError as ve: + # Handle specific validation errors + result_dict["Error"] = str(ve) + result_dict["Multiple attribute risk scoring Visualization"] = "" + result_dict["Description"] = f"Validation Error: {str(ve)}" + result_dict["Graph interpretation"] = "No visualization available due to validation error." + result_dict["ErrorType"] = "Validation Error" + except Exception as e: + # Handle other unexpected errors + result_dict["Error"] = f"Processing error: {str(e)}" + result_dict["Multiple attribute risk scoring Visualization"] = "" + result_dict["Description"] = f"Processing Error: {str(e)}" + result_dict["Graph interpretation"] = "No visualization available due to processing error." + result_dict["ErrorType"] = "Processing Error" + + return result_dict + + +def _mm_descriptive_stats_array(values): + """Summary stats for a 1-D risk score array (used by groupby MM helpers).""" + return { + "mean": np.mean(values), + "std": np.std(values), + "min": np.min(values), + "25%": np.percentile(values, 25), + "50%": np.median(values), + "75%": np.percentile(values, 75), + "max": np.max(values), + } + + +def _vectorized_mm_risk_for_column(series, n_rows): + """Per-row MM risk for one quasi-identifier (unique ID rows).""" + attr1_tot = series.groupby(series, dropna=False).transform("count") + if (attr1_tot == 0).any(): + raise ValueError( + f"Column '{series.name}' has unexpected data structure causing division by zero." + ) + priv_prob_mm = (attr1_tot / n_rows) * (1.0 - 1.0 / attr1_tot) + return np.round(1 - priv_prob_mm, 2).to_numpy() + + +def _vectorized_mm_multi_risk(selected_df, eval_cols): + """Per-row combined MM risk via groupby counts (unique ID rows).""" + n_rows = len(selected_df) + priv_prob = np.ones(n_rows, dtype=float) + + if len(eval_cols) == 1: + col = eval_cols[0] + attr1_tot = selected_df.groupby(col, dropna=False)[col].transform("count") + if (attr1_tot == 0).any(): + raise ValueError( + f"Column '{col}' has unexpected data structure causing division by zero." + ) + priv_prob *= ((attr1_tot / n_rows) * (1.0 - 1.0 / attr1_tot)).to_numpy() + else: + for idx in range(1, len(eval_cols)): + col_a = eval_cols[idx - 1] + col_b = eval_cols[idx] + attr1_tot = selected_df.groupby(col_a, dropna=False)[col_a].transform("count") + if (attr1_tot == 0).any(): + raise ValueError( + f"Column '{col_a}' has unexpected data structure causing division by zero." + ) + joint_pair = selected_df.groupby( + [col_a, col_b], dropna=False + )[col_a].transform("count") + attr2_tot = selected_df.groupby(col_b, dropna=False)[col_b].transform("count") + if (attr2_tot == 0).any(): + raise ValueError( + f"Column '{col_b}' has unexpected data structure causing division by zero." + ) + step = ( + (attr1_tot / n_rows) + * (1.0 - 1.0 / attr1_tot) + * (joint_pair / attr1_tot) + * (1.0 - 1.0 / attr2_tot) + ) + priv_prob *= step.to_numpy() + + return np.round(1 - priv_prob, 2) + + +def generate_single_attribute_MM_risk_scores_groupby( + df, id_col, eval_cols, task=None, include_visualization=True +): + """Groupby-accelerated MM single-attribute risk (readiness report path). + + Same return contract as :func:`generate_single_attribute_MM_risk_scores`. + """ + result_dict = {} + + try: if task: task.update_state( state='PROGRESS', - meta={'current': 95, 'total': 100, 'status': 'Generating visualization...'} + meta={'current': 5, 'total': 100, 'status': 'Data validation & preprocessing...'} ) - x_label = ",".join(eval_cols) - # Create a box plot - plt.figure(figsize=(8, 8)) - plt.boxplot(risk_scores, orientation="vertical") - plt.title('Box Plot of Multiple Attribute Risk Scores') - plt.ylabel('Risk Score') - plt.xlabel('Feature Combination') - plt.xticks([1], [x_label]) - - # Save the plot as a PNG image in memory - image_stream = io.BytesIO() - plt.tight_layout() - plt.savefig(image_stream, format='png', bbox_inches='tight', dpi=300) - plt.close() - - # Convert the image to a base64 string - image_stream.seek(0) - base64_image = base64.b64encode(image_stream.read()).decode('utf-8') - image_stream.close() + if df.empty: + raise ValueError("Dataset is empty. Please upload a dataset with data.") + + if isinstance(eval_cols, str): + eval_cols = [col.strip() for col in eval_cols.split(",") if col.strip()] + elif isinstance(eval_cols, list): + eval_cols = [col.strip() for col in eval_cols if col.strip()] + else: + raise ValueError("Quasi-identifiers must be provided as a string or list.") + + if not eval_cols: + raise ValueError("No valid quasi-identifiers provided.") + + missing_cols = [col for col in eval_cols if col not in df.columns] + if missing_cols: + raise ValueError(f"Quasi-identifier columns not found in dataset: {', '.join(missing_cols)}") + + if not id_col or id_col not in df.columns: + raise ValueError(f"ID column '{id_col}' not found in dataset.") + + if df[id_col].nunique() != len(df): + raise ValueError(f"ID column '{id_col}' must contain unique values for each row.") + + selected_columns = [id_col] + eval_cols + selected_df = df[selected_columns].dropna() + rows_after_dropna = len(selected_df) + logger.debug("Rows remaining after dropna: %d", rows_after_dropna) + if rows_after_dropna == 0: + raise ValueError( + "After removing missing values, no data remains. Please check your data quality or select different columns." + ) + + non_categorical_cols = [] + for col in eval_cols: + if pd.api.types.is_numeric_dtype(df[col]) and df[col].nunique() > 100: + non_categorical_cols.append(col) + + if non_categorical_cols: + raise ValueError( + f"Columns {', '.join(non_categorical_cols)} appear to be numerical with too many unique values." + "Quasi-identifiers should be categorical." + ) + + if task: + task.update_state( + state='PROGRESS', + meta={'current': 15, 'total': 100, 'status': 'Calculating risk scores...'} + ) + + sing_res = {} + total_columns = len(eval_cols) + n_rows = len(selected_df) + + for col_idx, col in enumerate(eval_cols): + if task: + progress = 15 + (col_idx / total_columns) * 55 + task.update_state( + state='PROGRESS', + meta={ + 'current': int(progress), + 'total': 100, + 'status': f'Calculating risk scores for {col}... ({col_idx + 1}/{total_columns})', + }, + ) + + if selected_df[col].nunique() <= 1: + raise ValueError( + f"Column '{col}' has only one unique value, making risk assessment meaningless." + ) + + sing_res[col] = _vectorized_mm_risk_for_column(selected_df[col], n_rows) + + if task: + task.update_state( + state='PROGRESS', + meta={'current': 75, 'total': 100, 'status': 'Calculating descriptive statistics...'} + ) + + descriptive_stats_dict = { + key: _mm_descriptive_stats_array(value) for key, value in sing_res.items() + } + + if include_visualization: + if task: + task.update_state( + state='PROGRESS', + meta={'current': 90, 'total': 100, 'status': 'Generating visualization...'} + ) + + plt.figure(figsize=(8, 8)) + plt.boxplot(list(sing_res.values()), tick_labels=list(sing_res.keys())) + plt.title("Box plot of single feature risk scores") + plt.xlabel("Feature") + plt.ylabel("Risk Score") + + image_stream = io.BytesIO() + plt.tight_layout() + plt.savefig(image_stream, format="png", bbox_inches='tight', dpi=300) + plt.close() + + image_stream.seek(0) + base64_image = base64.b64encode(image_stream.read()).decode("utf-8") + image_stream.close() + + result_dict["Descriptive statistics of the risk scores"] = descriptive_stats_dict + if include_visualization: + result_dict["Single attribute risk scoring Visualization"] = base64_image + result_dict["Description"] = ( + "This metric quantifies the re-identification risk for each " + "quasi-identifier. Lower values are preferred, indicating " + "features that are less likely to uniquely identify individuals. " + "High-risk features may require further anonymization or removal." + ) + result_dict["Graph interpretation"] = ( + "The box plot displays the distribution of risk scores for each feature. Features with " + "higher medians or more outliers indicate greater privacy risk. A compact, lower box is desirable." + ) + + except SoftTimeLimitExceeded: + raise Exception("Single Attribute Risk task timed out. The dataset may be too large or complex.") + except ValueError as ve: + result_dict["Error"] = str(ve) + result_dict["Single attribute risk scoring Visualization"] = "" + result_dict["Description"] = f"Validation Error: {str(ve)}" + result_dict["Graph interpretation"] = "No visualization available due to validation error." + result_dict["ErrorType"] = "Validation Error" + except Exception as e: + result_dict["Error"] = f"Processing error: {str(e)}" + result_dict["Single attribute risk scoring Visualization"] = "" + result_dict["Description"] = f"Processing Error: {str(e)}" + result_dict["Graph interpretation"] = "No visualization available due to processing error." + result_dict["ErrorType"] = "Processing Error" + + return result_dict + + +def generate_multiple_attribute_MM_risk_scores_groupby( + df, id_col, eval_cols, task=None, include_visualization=True +): + """Groupby-accelerated MM multi-attribute risk (readiness report path). + + Same return contract as :func:`generate_multiple_attribute_MM_risk_scores`. + """ + result_dict = {} + + try: + if task: + task.update_state( + state='PROGRESS', + meta={'current': 5, 'total': 100, 'status': 'Data validation & preprocessing...'} + ) + + if df.empty: + raise ValueError("Input DataFrame is empty.") + + if isinstance(eval_cols, str): + eval_cols = [col.strip() for col in eval_cols.split(',') if col.strip()] + elif isinstance(eval_cols, list): + eval_cols = [col.strip() for col in eval_cols if col.strip()] + else: + raise ValueError("eval_cols must be a string or list") + + if not eval_cols: + raise ValueError("No valid columns provided in eval_cols after processing") + + missing_cols = [col for col in eval_cols if col not in df.columns] + if missing_cols: + raise ValueError(f"Columns not found in dataset: {missing_cols}") + + if not id_col or id_col not in df.columns: + raise ValueError(f"ID column '{id_col}' not found in dataset") + + selected_columns = [id_col] + eval_cols + selected_df = df[selected_columns].dropna() + rows_after_dropna = len(selected_df) + + if rows_after_dropna == 0: + logger.debug("No data remains after dropna — raising ValueError") + raise ValueError( + "After removing missing values, no data remains. Please check your data quality or select different columns." + ) + + for col in eval_cols: + if col in df.columns and df[col].nunique() <= 1: + raise ValueError( + f"Column '{col}' has only one unique value, making risk assessment meaningless." + ) + + if df[id_col].nunique() != len(df): + raise ValueError(f"ID column '{id_col}' must contain unique values for each row.") + + if task: + task.update_state( + state='PROGRESS', + meta={'current': 15, 'total': 100, 'status': 'Starting risk score calculations...'} + ) + + risk_scores = _vectorized_mm_multi_risk(selected_df, eval_cols) + + if task: + task.update_state( + state='PROGRESS', + meta={'current': 75, 'total': 100, 'status': 'Calculating dataset privacy level...'} + ) + + min_risk_scores = np.zeros(len(risk_scores)) + euclidean_distance = np.linalg.norm(risk_scores - min_risk_scores) + max_risk_scores = np.ones(len(risk_scores)) + max_euclidean_distance = np.linalg.norm(max_risk_scores - min_risk_scores) + normalized_distance = euclidean_distance / max_euclidean_distance + + if task: + task.update_state( + state='PROGRESS', + meta={'current': 85, 'total': 100, 'status': 'Calculating descriptive statistics...'} + ) + + stats_dict = _mm_descriptive_stats_array(risk_scores) + + if include_visualization: + if task: + task.update_state( + state='PROGRESS', + meta={'current': 95, 'total': 100, 'status': 'Generating visualization...'} + ) + + x_label = ",".join(eval_cols) + plt.figure(figsize=(8, 8)) + plt.boxplot(risk_scores, orientation="vertical") + plt.title('Box Plot of Multiple Attribute Risk Scores') + plt.ylabel('Risk Score') + plt.xlabel('Feature Combination') + plt.xticks([1], [x_label]) + + image_stream = io.BytesIO() + plt.tight_layout() + plt.savefig(image_stream, format='png', bbox_inches='tight', dpi=300) + plt.close() + + image_stream.seek(0) + base64_image = base64.b64encode(image_stream.read()).decode('utf-8') + image_stream.close() result_dict["Description"] = ( "This metric evaluates the joint risk posed by combinations of " @@ -470,20 +831,19 @@ def generate_multiple_attribute_MM_risk_scores(df, id_col, eval_cols, task=None) "The box plot shows the distribution of combined risk scores. A distribution concentrated at lower values indicates better privacy." ) result_dict["Descriptive statistics of the risk scores"] = stats_dict - result_dict["Multiple attribute risk scoring Visualization"] = base64_image + if include_visualization: + result_dict["Multiple attribute risk scoring Visualization"] = base64_image result_dict['Dataset Risk Score'] = normalized_distance except SoftTimeLimitExceeded: raise Exception("Multiple Attribute Risk task timed out. The dataset may be too large or complex.") except ValueError as ve: - # Handle specific validation errors result_dict["Error"] = str(ve) result_dict["Multiple attribute risk scoring Visualization"] = "" result_dict["Description"] = f"Validation Error: {str(ve)}" result_dict["Graph interpretation"] = "No visualization available due to validation error." result_dict["ErrorType"] = "Validation Error" except Exception as e: - # Handle other unexpected errors result_dict["Error"] = f"Processing error: {str(e)}" result_dict["Multiple attribute risk scoring Visualization"] = "" result_dict["Description"] = f"Processing Error: {str(e)}" @@ -493,7 +853,7 @@ def generate_multiple_attribute_MM_risk_scores(df, id_col, eval_cols, task=None) return result_dict -def compute_k_anonymity(quasi_identifiers: List[str], file_info): +def compute_k_anonymity(quasi_identifiers: List[str], file_info, include_visualization=True): """Measure k-anonymity for the given quasi-identifier columns. Groups records by the combination of *quasi_identifiers* and returns the @@ -557,27 +917,27 @@ def compute_k_anonymity(quasi_identifiers: List[str], file_info): # Histogram of equivalence class sizes hist_data = counts.value_counts().sort_index().to_dict() - plt.figure(figsize=(8, 5)) - plt.bar(hist_data.keys(), hist_data.values(), color="skyblue") - plt.xlabel("Equivalence Class Size (k)") - plt.ylabel("Number of Equivalence Classes") - plt.title("Distribution of Equivalence Class Sizes") - plt.grid(axis="y", alpha=0.75) - # Save histogram to base64 - img_stream = io.BytesIO() - plt.tight_layout() - plt.savefig(img_stream, format="png", bbox_inches='tight', dpi=300) - plt.close() - img_stream.seek(0) - base64_image = base64.b64encode(img_stream.read()).decode("utf-8") - img_stream.close() + if include_visualization: + plt.figure(figsize=(8, 5)) + plt.bar(hist_data.keys(), hist_data.values(), color="skyblue") + plt.xlabel("Equivalence Class Size (k)") + plt.ylabel("Number of Equivalence Classes") + plt.title("Distribution of Equivalence Class Sizes") + plt.grid(axis="y", alpha=0.75) + # Save histogram to base64 + img_stream = io.BytesIO() + plt.tight_layout() + plt.savefig(img_stream, format="png", bbox_inches='tight', dpi=300) + plt.close() + img_stream.seek(0) + base64_image = base64.b64encode(img_stream.read()).decode("utf-8") + img_stream.close() # Final result result_dict = { "k-Value": k_anonymity, "descriptive_statistics": desc_stats, "histogram_data": hist_data, - "k-Anonymity Visualization": base64_image, "Description": ( "k-anonymity measures the minimum group size sharing the same quasi-identifier values. " "Higher k values are preferred, as they indicate stronger anonymity." @@ -587,6 +947,8 @@ def compute_k_anonymity(quasi_identifiers: List[str], file_info): "class sizes (higher k) is desirable for privacy." ), } + if include_visualization: + result_dict["k-Anonymity Visualization"] = base64_image except SoftTimeLimitExceeded: raise Exception("K anonymity task timed out.") except ValueError as ve: @@ -609,6 +971,7 @@ def compute_l_diversity( quasi_identifiers: list, sensitive_column: str, file_info, + include_visualization=True, ): """Quantify l-diversity within groups defined by quasi-identifiers. @@ -685,29 +1048,29 @@ def compute_l_diversity( # or use: (l_diversities / 2).round() * 2 for bin size of 2 binned_l_diversities = l_diversities.round() hist_data = binned_l_diversities.value_counts().sort_index() - plt.figure(figsize=(8, 8)) - plt.bar(hist_data.index, hist_data.values, color="skyblue") - plt.xlabel("Number of Distinct Sensitive Values (l)") - plt.ylabel("Number of Equivalence Classes") - plt.title("Distribution of l-Diversity Across Equivalence Classes") - plt.xticks(sorted(hist_data.index)) - plt.grid(axis="y", alpha=0.75) - - # Save plot to base64 string - img_stream = io.BytesIO() - plt.tight_layout() - plt.savefig(img_stream, format="png", bbox_inches='tight', dpi=300) - plt.close() - img_stream.seek(0) - base64_image = base64.b64encode(img_stream.read()).decode("utf-8") - img_stream.close() + if include_visualization: + plt.figure(figsize=(8, 8)) + plt.bar(hist_data.index, hist_data.values, color="skyblue") + plt.xlabel("Number of Distinct Sensitive Values (l)") + plt.ylabel("Number of Equivalence Classes") + plt.title("Distribution of l-Diversity Across Equivalence Classes") + plt.xticks(sorted(hist_data.index)) + plt.grid(axis="y", alpha=0.75) + + # Save plot to base64 string + img_stream = io.BytesIO() + plt.tight_layout() + plt.savefig(img_stream, format="png", bbox_inches='tight', dpi=300) + plt.close() + img_stream.seek(0) + base64_image = base64.b64encode(img_stream.read()).decode("utf-8") + img_stream.close() # Compose result dictionary result_dict = { "l-Value": min_l_diversity, "descriptive_statistics": desc_stats, "histogram_data": hist_data.to_dict(), - "l-Diversity Visualization": base64_image, "Description": ( "l-diversity quantifies the diversity of sensitive attributes within each group. " "Higher l values are preferred, indicating less risk of attribute disclosure." @@ -716,6 +1079,8 @@ def compute_l_diversity( "The histogram displays the spread of l-diversity values. A distribution concentrated at higher l values is optimal." ), } + if include_visualization: + result_dict["l-Diversity Visualization"] = base64_image except SoftTimeLimitExceeded: raise Exception("L Diversity task timed out.") except ValueError as ve: @@ -738,6 +1103,7 @@ def compute_t_closeness( quasi_identifiers: List[str], sensitive_column: str, file_info, + include_visualization=True, ): """Measure t-closeness between each group's and the global sensitive attribute distribution. @@ -825,26 +1191,26 @@ def tvd(p, q): # Histogram plot hist_data = t_series.round(2).value_counts().sort_index() - plt.figure(figsize=(8, 5)) - plt.bar(hist_data.index, hist_data.values, color="salmon") - plt.xlabel("t-Closeness Value (TVD)") - plt.ylabel("Number of Equivalence Classes") - plt.title("Distribution of T-Closeness Across Equivalence Classes") - plt.grid(axis="y", alpha=0.75) - - img_stream = io.BytesIO() - plt.tight_layout() - plt.savefig(img_stream, format="png", bbox_inches='tight', dpi=300) - plt.close() - img_stream.seek(0) - base64_image = base64.b64encode(img_stream.read()).decode("utf-8") - img_stream.close() + if include_visualization: + plt.figure(figsize=(8, 5)) + plt.bar(hist_data.index, hist_data.values, color="salmon") + plt.xlabel("t-Closeness Value (TVD)") + plt.ylabel("Number of Equivalence Classes") + plt.title("Distribution of T-Closeness Across Equivalence Classes") + plt.grid(axis="y", alpha=0.75) + + img_stream = io.BytesIO() + plt.tight_layout() + plt.savefig(img_stream, format="png", bbox_inches='tight', dpi=300) + plt.close() + img_stream.seek(0) + base64_image = base64.b64encode(img_stream.read()).decode("utf-8") + img_stream.close() result_dict = { "t-Value": max_t, "descriptive_statistics": desc_stats, "histogram_data": hist_data.to_dict(), - "t-Closeness Visualization": base64_image, "Description": ( "t-closeness measures the distance between the distribution of sensitive attributes " "in a group and the overall distribution. Lower t values are preferred, indicating less information leakage." @@ -853,6 +1219,8 @@ def tvd(p, q): "The histogram shows the distribution of t values. Lower t values across groups indicate stronger privacy." ), } + if include_visualization: + result_dict["t-Closeness Visualization"] = base64_image except SoftTimeLimitExceeded: raise Exception("T Closeness task timed out.") except ValueError as ve: @@ -871,7 +1239,7 @@ def tvd(p, q): return result_dict -def compute_entropy_risk(quasi_identifiers, file_info): +def compute_entropy_risk(quasi_identifiers, file_info, include_visualization=True): """Calculate entropy-based re-identification risk for quasi-identifier columns. Groups records by the combination of *quasi_identifiers* and computes the @@ -938,20 +1306,21 @@ def compute_entropy_risk(quasi_identifiers, file_info): # Histogram plot of entropy values hist_data = entropy_series.round(2).value_counts().sort_index() - plt.figure(figsize=(8, 5)) - plt.bar(hist_data.index, hist_data.values, color="royalblue") - plt.xlabel("Entropy Value") - plt.ylabel("Number of Equivalence Classes") - plt.title("Distribution of Entropy Across Equivalence Classes") - plt.grid(axis="y", alpha=0.75) - - img_stream = io.BytesIO() - plt.tight_layout() - plt.savefig(img_stream, format="png", bbox_inches='tight', dpi=300) - plt.close() - img_stream.seek(0) - base64_image = base64.b64encode(img_stream.read()).decode("utf-8") - img_stream.close() + if include_visualization: + plt.figure(figsize=(8, 5)) + plt.bar(hist_data.index, hist_data.values, color="royalblue") + plt.xlabel("Entropy Value") + plt.ylabel("Number of Equivalence Classes") + plt.title("Distribution of Entropy Across Equivalence Classes") + plt.grid(axis="y", alpha=0.75) + + img_stream = io.BytesIO() + plt.tight_layout() + plt.savefig(img_stream, format="png", bbox_inches='tight', dpi=300) + plt.close() + img_stream.seek(0) + base64_image = base64.b64encode(img_stream.read()).decode("utf-8") + img_stream.close() desc_stats = { "min": round(entropy_series.min(), 4), @@ -964,7 +1333,6 @@ def compute_entropy_risk(quasi_identifiers, file_info): "Entropy-Value": rounded_entropy, "descriptive_statistics": desc_stats, "histogram_data": hist_data.to_dict(), - "Entropy Risk Visualization": base64_image, "Description": ( "Entropy risk quantifies the uncertainty in identifying individuals within equivalence classes. " "Higher entropy values are preferred, indicating greater anonymity and lower re-identification risk." @@ -974,6 +1342,8 @@ def compute_entropy_risk(quasi_identifiers, file_info): "indicate better privacy; left-skewed distributions suggest higher risk." ), } + if include_visualization: + result_dict["Entropy Risk Visualization"] = base64_image except SoftTimeLimitExceeded: raise Exception("Entropy Risk task timed out.") except ValueError as ve: diff --git a/aidrin/structured_data_metrics/statistical_rate.py b/aidrin/structured_data_metrics/statistical_rate.py index b01d9359..1393d2d8 100644 --- a/aidrin/structured_data_metrics/statistical_rate.py +++ b/aidrin/structured_data_metrics/statistical_rate.py @@ -20,7 +20,7 @@ @shared_task(bind=True, ignore_result=False) def calculate_statistical_rates( - self: Task, y_true_column, sensitive_attribute_column, file_info + self: Task, y_true_column, sensitive_attribute_column, file_info, include_visualization=True ): try: logger.info("Statistical Rate task started: target=%r, sensitive=%r", y_true_column, sensitive_attribute_column) @@ -71,59 +71,60 @@ def calculate_statistical_rates( for class_label, proportion in tsd.items(): tsd[class_label] = np.std(proportion) - # Set up the plot - fig, ax = plt.subplots(figsize=(8, 8)) + if include_visualization: + # Set up the plot + fig, ax = plt.subplots(figsize=(8, 8)) - # Calculate the total number of classes and sensitive attribute values - num_classes = len(unique_class_labels) - num_sensitive_values = len(unique_sensitive_values) + # Calculate the total number of classes and sensitive attribute values + num_classes = len(unique_class_labels) + num_sensitive_values = len(unique_sensitive_values) - # Calculate the width of each bar and the total width of each group - bar_width = 0.1 - group_width = bar_width * num_classes + # Calculate the width of each bar and the total width of each group + bar_width = 0.1 + group_width = bar_width * num_classes - # Calculate the offset for each bar within a group - bar_offset = np.arange(num_sensitive_values) * group_width - ( - group_width * (num_classes - 1) / 2 - ) - - # Iterate through each unique class label - for i, class_label in enumerate(unique_class_labels): - # Extract proportions for the current class label - proportions = [ - class_proportions[sensitive_value].get(class_label, 0) - for sensitive_value in unique_sensitive_values - ] - - # Plot the bars for each sensitive attribute value with the adjusted position - bar_positions = bar_offset + i * bar_width - ax.bar( - bar_positions, - proportions, - width=bar_width, - label=f"Class: {class_label}", + # Calculate the offset for each bar within a group + bar_offset = np.arange(num_sensitive_values) * group_width - ( + group_width * (num_classes - 1) / 2 ) - # Set up labels and title - ax.set_xticks(bar_offset + (num_classes - 1) * bar_width / 2) - # Adjust fontsize and rotation - ax.set_xticklabels(unique_sensitive_values, rotation=30, ha="right", fontsize=8) - ax.set_xlabel("Sensitive Attribute") - ax.set_ylabel("Proportion") - ax.set_title("Class Proportions for Each Sensitive Attribute") - ax.legend() - - # Adjust the bottom margin to avoid xticks being cropped - plt.subplots_adjust(bottom=0.25) - - # Save the plot as a base64 string - buffer = io.BytesIO() - plt.savefig(buffer, format="png") - buffer.seek(0) - base64_plot = base64.b64encode(buffer.read()).decode("utf-8") - # Close the figure and BytesIO stream to free memory - plt.close(fig) - buffer.close() + # Iterate through each unique class label + for i, class_label in enumerate(unique_class_labels): + # Extract proportions for the current class label + proportions = [ + class_proportions[sensitive_value].get(class_label, 0) + for sensitive_value in unique_sensitive_values + ] + + # Plot the bars for each sensitive attribute value with the adjusted position + bar_positions = bar_offset + i * bar_width + ax.bar( + bar_positions, + proportions, + width=bar_width, + label=f"Class: {class_label}", + ) + + # Set up labels and title + ax.set_xticks(bar_offset + (num_classes - 1) * bar_width / 2) + # Adjust fontsize and rotation + ax.set_xticklabels(unique_sensitive_values, rotation=30, ha="right", fontsize=8) + ax.set_xlabel("Sensitive Attribute") + ax.set_ylabel("Proportion") + ax.set_title("Class Proportions for Each Sensitive Attribute") + ax.legend() + + # Adjust the bottom margin to avoid xticks being cropped + plt.subplots_adjust(bottom=0.25) + + # Save the plot as a base64 string + buffer = io.BytesIO() + plt.savefig(buffer, format="png") + buffer.seek(0) + base64_plot = base64.b64encode(buffer.read()).decode("utf-8") + # Close the figure and BytesIO stream to free memory + plt.close(fig) + buffer.close() # Full disclosure: This workaround is from stackoverflow. # Recasts all numpy types to their native Python types so Celery can pass the data correctly. @@ -137,23 +138,24 @@ def to_serializable(obj): else: return obj - cleaned_payload = to_serializable( - { - "Statistical Rates": class_proportions, - "TSD scores": tsd, - "Description": "The TSD values are calculated by getting the standard deviation of the " - "proportions of each group across the different classes...", - "Statistical Rate Visualization": base64_plot, - } - ) + serializable_payload = { + "Statistical Rates": class_proportions, + "TSD scores": tsd, + "Description": "The TSD values are calculated by getting the standard deviation of the " + "proportions of each group across the different classes...", + } + if include_visualization: + serializable_payload["Statistical Rate Visualization"] = base64_plot + cleaned_payload = to_serializable(serializable_payload) result = { "Statistical Rates": cleaned_payload["Statistical Rates"], "TSD scores": cleaned_payload["TSD scores"], "Description": cleaned_payload["Description"], - "Statistical Rate Visualization": cleaned_payload[ - "Statistical Rate Visualization" - ], } + if include_visualization: + result["Statistical Rate Visualization"] = cleaned_payload[ + "Statistical Rate Visualization" + ] logger.info("Statistical Rate task completed: %d sensitive groups, %d classes", len(unique_sensitive_values), len(unique_class_labels)) return result except SoftTimeLimitExceeded: diff --git a/pyproject.toml b/pyproject.toml index 1fa433be..29cc9e88 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,7 @@ dependencies = [ "openpyxl>=3.1.5", "pgeocode>=0.5.0", "pyarrow>=15.0.0", + "weasyprint>=62.0", ] [project.optional-dependencies] diff --git a/tests/integration/test_readiness_pdf.py b/tests/integration/test_readiness_pdf.py new file mode 100644 index 00000000..30f852a1 --- /dev/null +++ b/tests/integration/test_readiness_pdf.py @@ -0,0 +1,26 @@ +"""Integration tests for readiness report PDF export.""" + +from unittest.mock import MagicMock, patch + + +@patch("web.readiness.pdf.HTML") +@patch("web.readiness.pdf.CSS") +def test_readiness_report_pdf_download(mock_css, mock_html, uploaded_client): + mock_instance = MagicMock() + mock_instance.write_pdf.return_value = b"%PDF-1.4 test" + mock_html.return_value = mock_instance + + response = uploaded_client.get("/readiness-report/pdf") + assert response.status_code == 200 + assert response.mimetype == "application/pdf" + assert response.data.startswith(b"%PDF") + content_disp = response.headers.get("Content-Disposition", "") + assert "readiness-report-" in content_disp + assert content_disp.endswith(".pdf") + + +def test_readiness_report_pdf_requires_upload(client): + response = client.get("/readiness-report/pdf") + assert response.status_code == 400 + data = response.get_json() + assert data["success"] is False diff --git a/tests/unit/test_privacy.py b/tests/unit/test_privacy.py index 05907ea5..7da04f38 100644 --- a/tests/unit/test_privacy.py +++ b/tests/unit/test_privacy.py @@ -31,7 +31,9 @@ class _FakeDist: from aidrin.structured_data_metrics.privacy_measure import ( # noqa: E402 generate_single_attribute_MM_risk_scores, + generate_single_attribute_MM_risk_scores_groupby, generate_multiple_attribute_MM_risk_scores, + generate_multiple_attribute_MM_risk_scores_groupby, compute_k_anonymity, compute_l_diversity, compute_t_closeness, @@ -51,6 +53,29 @@ def _make_df(n_rows=20, n_categories=4): return pd.DataFrame({"id": ids, "qi": qi}) +def _make_multi_qi_df(n_rows=500, seed=42): + """Larger frame with three quasi-identifiers for parity checks.""" + rng = np.random.default_rng(seed) + return pd.DataFrame({ + "id": np.arange(n_rows), + "qi1": rng.choice(["A", "B", "C", "D"], size=n_rows), + "qi2": rng.choice(["X", "Y", "Z"], size=n_rows), + "qi3": rng.choice(["P", "Q"], size=n_rows), + }) + + +def _assert_stats_equal(test_case, original_stats, groupby_stats, context): + """Assert descriptive-stat dicts from legacy vs groupby MM paths match.""" + test_case.assertEqual(set(original_stats.keys()), set(groupby_stats.keys()), context) + for key in original_stats: + test_case.assertAlmostEqual( + original_stats[key], + groupby_stats[key], + places=9, + msg=f"{context}: stat {key}", + ) + + # =========================================================================== # generate_single_attribute_MM_risk_scores # =========================================================================== @@ -242,6 +267,127 @@ def test_visualization_is_base64(self): base64.b64decode(vis) +# =========================================================================== +# groupby MM risk parity (readiness-report fast path vs legacy) +# =========================================================================== + + +class TestMMRiskGroupbyParity(unittest.TestCase): + """Groupby implementations must match legacy MM risk outputs.""" + + def test_single_attribute_matches_legacy_one_qi(self): + df = _make_df(n_rows=80, n_categories=5) + cols = ["qi"] + self._assert_single_parity(df, cols) + + def test_single_attribute_matches_legacy_multiple_qis(self): + df = _make_multi_qi_df() + for cols in (["qi1"], ["qi1", "qi2"], ["qi1", "qi2", "qi3"]): + with self.subTest(cols=cols): + self._assert_single_parity(df, cols) + + def test_single_attribute_string_eval_cols_parity(self): + df = _make_multi_qi_df(n_rows=120, seed=7) + legacy = generate_single_attribute_MM_risk_scores( + df, "id", "qi1, qi2", include_visualization=False + ) + groupby = generate_single_attribute_MM_risk_scores_groupby( + df, "id", "qi1, qi2", include_visualization=False + ) + self.assertNotIn("Error", legacy) + self.assertNotIn("Error", groupby) + for col in ("qi1", "qi2"): + _assert_stats_equal( + self, + legacy["Descriptive statistics of the risk scores"][col], + groupby["Descriptive statistics of the risk scores"][col], + f"single {col}", + ) + + def test_multiple_attribute_matches_legacy(self): + df = _make_multi_qi_df() + for cols in (["qi1"], ["qi1", "qi2"], ["qi1", "qi2", "qi3"]): + with self.subTest(cols=cols): + self._assert_multi_parity(df, cols) + + def test_multiple_attribute_string_eval_cols_parity(self): + df = _make_multi_qi_df(n_rows=200, seed=11) + legacy = generate_multiple_attribute_MM_risk_scores( + df, "id", "qi1,qi2", include_visualization=False + ) + groupby = generate_multiple_attribute_MM_risk_scores_groupby( + df, "id", "qi1,qi2", include_visualization=False + ) + self.assertNotIn("Error", legacy) + self.assertNotIn("Error", groupby) + _assert_stats_equal( + self, + legacy["Descriptive statistics of the risk scores"], + groupby["Descriptive statistics of the risk scores"], + "multi qi1,qi2", + ) + self.assertAlmostEqual( + legacy["Dataset Risk Score"], + groupby["Dataset Risk Score"], + places=9, + ) + + def test_error_parity_non_unique_id(self): + df = pd.DataFrame({ + "id": [1, 1, 2, 3], + "qi1": ["A", "B", "A", "C"], + "qi2": ["X", "Y", "X", "Z"], + }) + legacy = generate_multiple_attribute_MM_risk_scores( + df, "id", ["qi1", "qi2"], include_visualization=False + ) + groupby = generate_multiple_attribute_MM_risk_scores_groupby( + df, "id", ["qi1", "qi2"], include_visualization=False + ) + self.assertIn("Error", legacy) + self.assertIn("Error", groupby) + self.assertEqual(legacy["Error"], groupby["Error"]) + + def _assert_single_parity(self, df, cols): + legacy = generate_single_attribute_MM_risk_scores( + df, "id", cols, include_visualization=False + ) + groupby = generate_single_attribute_MM_risk_scores_groupby( + df, "id", cols, include_visualization=False + ) + self.assertNotIn("Error", legacy, legacy) + self.assertNotIn("Error", groupby, groupby) + for col in cols: + _assert_stats_equal( + self, + legacy["Descriptive statistics of the risk scores"][col], + groupby["Descriptive statistics of the risk scores"][col], + f"single {col}", + ) + + def _assert_multi_parity(self, df, cols): + legacy = generate_multiple_attribute_MM_risk_scores( + df, "id", cols, include_visualization=False + ) + groupby = generate_multiple_attribute_MM_risk_scores_groupby( + df, "id", cols, include_visualization=False + ) + self.assertNotIn("Error", legacy, legacy) + self.assertNotIn("Error", groupby, groupby) + _assert_stats_equal( + self, + legacy["Descriptive statistics of the risk scores"], + groupby["Descriptive statistics of the risk scores"], + f"multi {cols}", + ) + self.assertAlmostEqual( + legacy["Dataset Risk Score"], + groupby["Dataset Risk Score"], + places=9, + msg=f"Dataset Risk Score for {cols}", + ) + + # =========================================================================== # compute_k_anonymity # =========================================================================== diff --git a/tests/unit/test_readiness_cache.py b/tests/unit/test_readiness_cache.py new file mode 100644 index 00000000..2db5f411 --- /dev/null +++ b/tests/unit/test_readiness_cache.py @@ -0,0 +1,243 @@ +"""Unit tests for readiness report server-side section cache.""" + +import time +import unittest +from unittest.mock import MagicMock, patch + +from flask import Flask + +from web.routes.metrics import ( + _cache_readiness_fair_compliance, + _cache_readiness_payload, + _fair_metadata_fingerprint, + _get_cached_readiness_fair_compliance, + _get_cached_readiness_payload, + _readiness_fair_cache_key, + _readiness_section_cache_key, + get_cached_readiness_fair_report, + get_cached_readiness_report, +) + + +class TestReadinessSectionCacheKey(unittest.TestCase): + @patch("web.routes.metrics.get_current_user_id", return_value="user-1") + def test_section_key_matches_cached_result_pattern(self, _mock_user): + key = _readiness_section_cache_key("data.csv", "data-quality") + self.assertEqual(key, "user:user-1:file:data.csv:readiness_report:data-quality") + + @patch("web.routes.metrics.get_current_user_id", return_value="user-1") + def test_visualization_key_is_per_section(self, _mock_user): + key = _readiness_section_cache_key("data.csv", "dataset-overview", viz=True) + self.assertEqual( + key, + "user:user-1:file:data.csv:readiness_report:dataset-overview:visualizations", + ) + + +class TestReadinessSectionCacheStore(unittest.TestCase): + def setUp(self): + self.app = Flask(__name__) + self.app.TEMP_RESULTS_CACHE = {} + self.app_context = self.app.app_context() + self.app_context.push() + + def tearDown(self): + self.app_context.pop() + + @patch("web.routes.metrics.get_current_user_id", return_value="user-1") + @patch("web.routes.metrics.current_app") + def test_store_and_retrieve_section_payload(self, mock_current_app, _mock_user): + mock_current_app.TEMP_RESULTS_CACHE = self.app.TEMP_RESULTS_CACHE + payload = {"grade": 0.9, "kpis": []} + _cache_readiness_payload("data.csv", "data-quality", payload, 1.23) + + cached, build_time = _get_cached_readiness_payload("data.csv", "data-quality") + self.assertEqual(cached, payload) + self.assertEqual(build_time, 1.23) + + @patch("web.routes.metrics.get_current_user_id", return_value="user-1") + @patch("web.routes.metrics.current_app") + def test_error_sections_are_not_cached(self, mock_current_app, _mock_user): + mock_current_app.TEMP_RESULTS_CACHE = self.app.TEMP_RESULTS_CACHE + _cache_readiness_payload( + "data.csv", "impact-on-ai", {"error": "failed"}, 0.5 + ) + cached, _ = _get_cached_readiness_payload("data.csv", "impact-on-ai") + self.assertIsNone(cached) + + @patch("web.routes.metrics.get_current_user_id", return_value="user-1") + @patch("web.routes.metrics.current_app") + def test_expired_entries_are_evicted(self, mock_current_app, _mock_user): + mock_current_app.TEMP_RESULTS_CACHE = self.app.TEMP_RESULTS_CACHE + key = _readiness_section_cache_key("data.csv", "data-quality") + self.app.TEMP_RESULTS_CACHE[key] = { + "data": {"grade": 1.0}, + "timestamp": time.time() - 7200, + "expires_at": time.time() - 1, + "build_time_seconds": 2.0, + } + cached, _ = _get_cached_readiness_payload("data.csv", "data-quality") + self.assertIsNone(cached) + self.assertNotIn(key, self.app.TEMP_RESULTS_CACHE) + + @patch("web.routes.metrics.get_current_user_id", return_value="user-1") + @patch("web.routes.metrics.current_app") + def test_upload_style_clear_removes_user_keys(self, mock_current_app, _mock_user): + mock_current_app.TEMP_RESULTS_CACHE = self.app.TEMP_RESULTS_CACHE + _cache_readiness_payload("data.csv", "data-quality", {"grade": 0.8}, 1.0) + _cache_readiness_payload( + "data.csv", "dataset-overview", {"histograms": {}}, 2.0, viz=True + ) + self.assertEqual(len(self.app.TEMP_RESULTS_CACHE), 2) + + keys_to_remove = [ + key + for key in self.app.TEMP_RESULTS_CACHE + if key.startswith("user:user-1") + ] + for key in keys_to_remove: + self.app.TEMP_RESULTS_CACHE.pop(key, None) + + cached, _ = _get_cached_readiness_payload("data.csv", "data-quality") + self.assertIsNone(cached) + + +class TestReadinessAggregatedCache(unittest.TestCase): + def setUp(self): + self.app = Flask(__name__) + self.app.TEMP_RESULTS_CACHE = {} + self.app_context = self.app.app_context() + self.app_context.push() + + def tearDown(self): + self.app_context.pop() + + @patch("web.routes.metrics.get_current_user_id", return_value="user-1") + @patch("web.routes.metrics.current_app") + def test_get_cached_readiness_report_returns_all_sections( + self, mock_current_app, _mock_user + ): + mock_current_app.TEMP_RESULTS_CACHE = self.app.TEMP_RESULTS_CACHE + sections = { + "dataset-overview": {"rows": 10}, + "data-quality": {"grade": 0.9}, + "impact-on-ai": {"grade": 0.8}, + "fairness-bias": {"grade": 0.7}, + "data-governance": {"grade": 0.6}, + } + for slug, payload in sections.items(): + _cache_readiness_payload("data.csv", slug, payload, 1.0) + + result = get_cached_readiness_report("data.csv") + self.assertIsNotNone(result) + self.assertTrue(result["cached"]) + self.assertEqual(set(result["sections"].keys()), set(sections.keys())) + self.assertEqual(result["sections"]["data-quality"]["grade"], 0.9) + self.assertEqual(result["sections"]["data-quality"]["build_time_seconds"], 1.0) + self.assertTrue(all(result["sections_cached"].values())) + + @patch("web.routes.metrics.get_current_user_id", return_value="user-1") + @patch("web.routes.metrics.current_app") + def test_get_cached_readiness_report_requires_every_section( + self, mock_current_app, _mock_user + ): + mock_current_app.TEMP_RESULTS_CACHE = self.app.TEMP_RESULTS_CACHE + _cache_readiness_payload("data.csv", "data-quality", {"grade": 0.9}, 1.0) + + self.assertIsNone(get_cached_readiness_report("data.csv")) + + +class TestReadinessFairCache(unittest.TestCase): + def setUp(self): + self.app = Flask(__name__) + self.app.TEMP_RESULTS_CACHE = {} + self.app_context = self.app.app_context() + self.app_context.push() + + def tearDown(self): + self.app_context.pop() + + @patch("web.routes.metrics.get_current_user_id", return_value="user-1") + def test_fair_cache_key_matches_pattern(self, _mock_user): + key = _readiness_fair_cache_key("data.csv") + self.assertEqual(key, "user:user-1:file:data.csv:readiness_report:fair") + + def test_metadata_fingerprint_changes_with_type(self): + payload = b'{"title": "example"}' + dcat = _fair_metadata_fingerprint(payload, "DCAT") + datacite = _fair_metadata_fingerprint(payload, "Datacite") + self.assertNotEqual(dcat, datacite) + + @patch("web.routes.metrics.get_current_user_id", return_value="user-1") + @patch("web.routes.metrics.current_app") + def test_store_and_retrieve_fair_compliance(self, mock_current_app, _mock_user): + mock_current_app.TEMP_RESULTS_CACHE = self.app.TEMP_RESULTS_CACHE + payload = {"FAIR Compliance Checks": {"Total Checks": "10/19"}} + _cache_readiness_fair_compliance( + "data.csv", + payload, + metadata_type="DCAT", + metadata_filename="meta.json", + metadata_fingerprint="abc123", + build_time_seconds=0.75, + ) + + cached = _get_cached_readiness_fair_compliance("data.csv") + self.assertIsNotNone(cached) + self.assertEqual(cached["data"], payload) + self.assertEqual(cached["metadata_type"], "DCAT") + self.assertEqual(cached["metadata_filename"], "meta.json") + self.assertEqual(cached["metadata_fingerprint"], "abc123") + self.assertEqual(cached["build_time_seconds"], 0.75) + + @patch("web.routes.metrics.get_current_user_id", return_value="user-1") + @patch("web.routes.metrics.current_app") + def test_get_cached_readiness_fair_report(self, mock_current_app, _mock_user): + mock_current_app.TEMP_RESULTS_CACHE = self.app.TEMP_RESULTS_CACHE + _cache_readiness_fair_compliance( + "data.csv", + {"Findable": {}}, + metadata_type="Datacite", + metadata_filename="dc.json", + metadata_fingerprint="fp1", + build_time_seconds=1.0, + ) + + result = get_cached_readiness_fair_report("data.csv") + self.assertIsNotNone(result) + self.assertTrue(result["cached"]) + self.assertEqual(result["fair_compliance"]["metadata_filename"], "dc.json") + + @patch("web.routes.metrics.get_current_user_id", return_value="user-1") + @patch("web.routes.metrics.current_app") + def test_aggregated_report_includes_optional_fair_compliance( + self, mock_current_app, _mock_user + ): + mock_current_app.TEMP_RESULTS_CACHE = self.app.TEMP_RESULTS_CACHE + for slug in ( + "dataset-overview", + "data-quality", + "impact-on-ai", + "fairness-bias", + "data-governance", + ): + _cache_readiness_payload("data.csv", slug, {"grade": 0.5}, 1.0) + _cache_readiness_fair_compliance( + "data.csv", + {"FAIR Compliance Checks": {"Total Checks": "5/19"}}, + metadata_type="DCAT", + metadata_filename="meta.json", + metadata_fingerprint="fp2", + build_time_seconds=0.5, + ) + + result = get_cached_readiness_report("data.csv") + self.assertIn("fair_compliance", result) + self.assertEqual( + result["fair_compliance"]["data"]["FAIR Compliance Checks"]["Total Checks"], + "5/19", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_readiness_overview.py b/tests/unit/test_readiness_overview.py new file mode 100644 index 00000000..75300850 --- /dev/null +++ b/tests/unit/test_readiness_overview.py @@ -0,0 +1,89 @@ +"""Unit tests for readiness report dataset-overview helpers.""" + +import unittest + +import pandas as pd + +from web.routes.metrics import ( + _build_categorical_distributions, + _build_numerical_summary_for_overview, + _cap_detail_list, + _dataframe_for_overview_detail_charts, + _prepare_feature_profiles_for_display, +) + + +class TestCapDetailList(unittest.TestCase): + def test_no_truncation_when_under_cap(self): + items = [{"feature": f"c{i}"} for i in range(10)] + capped, meta = _cap_detail_list(items, max_items=50) + self.assertEqual(len(capped), 10) + self.assertFalse(meta["truncated"]) + + def test_truncation_when_over_cap(self): + items = [{"feature": f"c{i}"} for i in range(100)] + capped, meta = _cap_detail_list(items, max_items=50) + self.assertEqual(len(capped), 50) + self.assertTrue(meta["truncated"]) + self.assertEqual(meta["total"], 100) + + +class TestBuildNumericalSummaryForOverview(unittest.TestCase): + def test_caps_wide_numerical_summary(self): + df = pd.DataFrame({f"n{i}": range(5) for i in range(600)}) + summary, meta = _build_numerical_summary_for_overview(df) + self.assertEqual(len(summary), 500) + self.assertTrue(meta["truncated"]) + self.assertEqual(meta["total"], 600) + + +class TestBuildCategoricalDistributions(unittest.TestCase): + def test_caps_wide_categorical_distributions(self): + df = pd.DataFrame({f"c{i}": ["a", "b", "a"] for i in range(80)}) + dists, meta = _build_categorical_distributions(df, max_columns=50) + self.assertEqual(len(dists), 50) + self.assertTrue(meta["truncated"]) + self.assertEqual(meta["total"], 80) + + +class TestDataframeForOverviewDetailCharts(unittest.TestCase): + def test_uses_capped_profile_features_only(self): + profiles = ( + [{"feature": f"p{i}", "status": "poor", "type": "numerical"} for i in range(3)] + + [{"feature": f"g{i}", "status": "good", "type": "numerical"} for i in range(10)] + ) + df = pd.DataFrame({p["feature"]: range(5) for p in profiles}) + display, meta = _prepare_feature_profiles_for_display(profiles, max_profiles=5) + self.assertTrue(meta["truncated"]) + chart_df = _dataframe_for_overview_detail_charts(df, display) + self.assertEqual(list(chart_df.columns), [p["feature"] for p in display]) + + +class TestPrepareFeatureProfilesForDisplay(unittest.TestCase): + def test_no_truncation_when_under_cap(self): + profiles = [ + {"feature": "a", "status": "good"}, + {"feature": "b", "status": "poor"}, + ] + shown, meta = _prepare_feature_profiles_for_display(profiles, max_profiles=500) + self.assertEqual(len(shown), 2) + self.assertFalse(meta["truncated"]) + self.assertEqual(meta["status_counts"], {"poor": 1, "warning": 0, "good": 1}) + + def test_truncation_prioritizes_poor_then_warning_then_good(self): + profiles = ( + [{"feature": f"g{i}", "status": "good"} for i in range(10)] + + [{"feature": f"w{i}", "status": "warning"} for i in range(5)] + + [{"feature": f"p{i}", "status": "poor"} for i in range(3)] + ) + shown, meta = _prepare_feature_profiles_for_display(profiles, max_profiles=8) + self.assertTrue(meta["truncated"]) + self.assertEqual(meta["total"], 18) + self.assertEqual(meta["shown"], 8) + statuses = [p["status"] for p in shown] + self.assertEqual(statuses[:3], ["poor", "poor", "poor"]) + self.assertEqual(statuses[3:8], ["warning"] * 5) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_readiness_pdf.py b/tests/unit/test_readiness_pdf.py new file mode 100644 index 00000000..c0505f4b --- /dev/null +++ b/tests/unit/test_readiness_pdf.py @@ -0,0 +1,233 @@ +"""Unit tests for readiness report PDF context and rendering.""" + +import unittest +from pathlib import Path +from unittest.mock import MagicMock, patch + +from web.readiness.pdf import ( + FootnoteRegistry, + _cap_na_rows, + _fair_value_rows, + _na_row, + _prepare_fair_compliance, + _prepare_governance, + _prepare_overview, + build_pdf_context, + fmt_pct, + pdf_filename, + readiness_pdf_logo_uri, + render_readiness_report_pdf, +) + + +class TestReadinessPdfFormatters(unittest.TestCase): + def test_fmt_pct(self): + self.assertEqual(fmt_pct(0.912), "91.20%") + self.assertEqual(fmt_pct(None), "N/A") + + +class TestReadinessPdfContext(unittest.TestCase): + def test_prepare_overview_enriches_profiles(self): + overview = _prepare_overview( + { + "file_metadata": {"datetime_count": 1, "boolean_count": 2}, + "feature_profiles": [ + { + "feature": "age", + "type": "numerical", + "dtype": "int64", + "status": "good", + } + ], + } + ) + self.assertEqual(overview["other_count"], 3) + self.assertEqual(overview["profiles"][0]["type_abbr"], "N") + self.assertEqual(overview["profiles"][0]["status_label"], "Good") + + def test_build_pdf_context_includes_overview(self): + sections = { + "dataset-overview": { + "file_metadata": {"file_name": "data.csv", "rows": 10, "columns": 2}, + "feature_profiles": [ + { + "feature": "age", + "type": "numerical", + "dtype": "int64", + "pct_missing": 0.0, + "n_unique": 10, + "pct_dominant": 0.1, + "status": "good", + "summary": "ok", + } + ], + "feature_profiles_meta": {"total": 1, "shown": 1, "truncated": False}, + }, + "data-quality": { + "grade": 0.95, + "grade_status": "good", + "kpis": [ + { + "id": "completeness", + "label": "Completeness", + "value": 0.95, + "status": "good", + "hint": "hint", + } + ], + "auto_selection": { + "selection_criteria": { + "analysis_scope": { + "selected": "all columns", + "rule": "rule", + } + } + }, + "needs_attention": {}, + }, + "impact-on-ai": { + "grade": 0.8, + "grade_status": "warning", + "kpis": [], + "auto_selection": {"selection_criteria": {"columns_analyzed": {}}}, + "needs_attention": {}, + }, + "fairness-bias": { + "grade": 0.8, + "grade_status": "warning", + "kpis": [], + "auto_selection": {"selection_criteria": {}}, + "needs_attention": {}, + }, + "data-governance": { + "grade": 0.8, + "grade_status": "warning", + "kpis": [], + "auto_selection": {"selection_criteria": {}}, + "needs_attention": {}, + }, + } + context = build_pdf_context(file_name="data.csv", sections=sections) + self.assertEqual(context["file_name"], "data.csv") + self.assertIn("overview", context) + self.assertEqual(context["overview"]["profiles"][0]["type_abbr"], "N") + self.assertTrue(context["glossary"]) + + def test_footnote_registry_scopes_per_section(self): + registry = FootnoteRegistry() + dq = registry.section("data_quality") + impact = registry.section("impact") + self.assertIn("1", str(dq.ref("pct_missing"))) + self.assertIn("1", str(impact.ref("leakage_safety"))) + self.assertEqual(len(dq.entries()), 1) + self.assertEqual(len(impact.entries()), 1) + dq.ref("n_unique") + self.assertEqual(len(dq.entries()), 2) + self.assertEqual(dq.entries()[1]["term"], "# unique") + + def test_cap_na_rows_limits_tall_blocks(self): + rows = [ + _na_row("a", secondary="detail"), + _na_row("b", secondary="detail"), + _na_row("c", secondary="detail"), + _na_row("d", secondary="detail"), + _na_row("e", secondary="detail"), + _na_row("f", secondary="detail"), + ] + kept, more = _cap_na_rows(rows, line_budget=8.0, max_rows=6, min_rows=2) + self.assertLessEqual(len(kept), 4) + self.assertGreater(more, 0) + + def test_cap_na_rows_keeps_simple_rows_up_to_max(self): + rows = [_na_row(f"feature-{i}", value="1.00%") for i in range(6)] + kept, more = _cap_na_rows(rows, line_budget=10.0, max_rows=6, min_rows=2) + self.assertEqual(len(kept), 6) + self.assertEqual(more, 0) + + def test_governance_high_linkage_risk_single_block(self): + gov = { + "kpis": [], + "auto_selection": {"selection_criteria": {}}, + "needs_attention": { + "high_linkage_risk": [ + {"metric": "MM Prosecutor", "feature": "zip", "mean_risk": 0.42}, + {"metric": "MM Marketer", "feature": "dob", "mean_risk": 0.31}, + ] + }, + } + prepared = _prepare_governance(gov) + linkage_blocks = [ + b for b in prepared["needs_attention"] if b.get("glossary_key") == "high_linkage_risk" + ] + self.assertEqual(len(linkage_blocks), 1) + self.assertEqual(len(linkage_blocks[0]["rows"]), 2) + self.assertIn("(2)", linkage_blocks[0]["title"]) + + def test_fair_value_rows_marks_failed_checks(self): + rows = _fair_value_rows( + { + "identifier": "doi:10.1234/example", + "title": "CHECK FAILED ❌", + } + ) + self.assertEqual(len(rows), 2) + self.assertTrue(rows[0]["found"]) + self.assertFalse(rows[1]["found"]) + self.assertEqual(rows[1]["status_label"], "Missing") + + def test_prepare_fair_compliance_includes_principle_rows(self): + fair = { + "FAIR Compliance Checks": { + "Total Checks": "2/4", + "Findable Checks": "1/2", + "Accessible Checks": "1/2", + "Interoperable Checks": "0/0", + "Reusable Checks": "0/0", + }, + "Findable": {"identifier": "x", "title": "CHECK FAILED ❌"}, + } + prepared = _prepare_fair_compliance(fair) + self.assertIsNotNone(prepared) + findable = prepared["principles"][0] + self.assertEqual(findable["name"], "Findable") + self.assertEqual(len(findable["rows"]), 2) + + def test_pdf_filename_sanitizes_name(self): + name = pdf_filename("my data (1).csv") + self.assertTrue(name.startswith("readiness-report-my_data__1_")) + self.assertTrue(name.endswith(".pdf")) + full_name = pdf_filename("my data (1).csv", full=True) + self.assertTrue(full_name.startswith("readiness-report-full-my_data__1_")) + + +class TestReadinessPdfLogo(unittest.TestCase): + def test_readiness_pdf_logo_uri_resolves_aidrin_image(self): + import web.readiness.pdf as pdf_module + + app = MagicMock() + app.root_path = str(Path(pdf_module.__file__).resolve().parent.parent) + uri = readiness_pdf_logo_uri(app) + self.assertTrue(uri.startswith("file:")) + self.assertTrue(uri.endswith("logoNoBackground.png")) + + +class TestReadinessPdfRender(unittest.TestCase): + @patch("weasyprint.HTML") + @patch("weasyprint.CSS") + @patch("web.readiness.pdf.render_template", return_value="") + @patch("web.readiness.pdf.readiness_pdf_logo_uri", return_value="file:///tmp/logo.png") + def test_render_readiness_report_pdf(self, _mock_logo, _mock_template, _mock_css, mock_html): + mock_instance = MagicMock() + mock_instance.write_pdf.return_value = b"%PDF-1.4" + mock_html.return_value = mock_instance + app = MagicMock() + app.root_path = "/tmp/web" + result = render_readiness_report_pdf(app, {"file_name": "data.csv"}) + self.assertEqual(result, b"%PDF-1.4") + mock_instance.write_pdf.assert_called_once() + _mock_template.assert_called_once() + self.assertEqual(_mock_template.call_args.kwargs["logo_url"], "file:///tmp/logo.png") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_readiness_pdf_details.py b/tests/unit/test_readiness_pdf_details.py new file mode 100644 index 00000000..35ce6cbd --- /dev/null +++ b/tests/unit/test_readiness_pdf_details.py @@ -0,0 +1,58 @@ +"""Unit tests for full readiness report PDF details.""" + +import unittest + +from web.readiness.pdf_details import ( + build_pdf_section_details, + prepare_governance_details, + prepare_overview_details, +) + + +class TestReadinessPdfDetails(unittest.TestCase): + def test_prepare_overview_details_includes_table_and_charts(self): + section = { + "numerical_summary": {"age": {"count": 10, "mean": 35.5}}, + "numerical_summary_meta": {"total": 1, "shown": 1}, + "feature_profiles_meta": {"truncated": False}, + } + viz = { + "categorical_charts": {"city": "abc123"}, + "histograms": {"age_light": "def456"}, + } + details = prepare_overview_details(section, viz) + self.assertIsNotNone(details) + types = [b["type"] for b in details["blocks"]] + self.assertIn("table", types) + self.assertIn("chart_group", types) + + def test_governance_high_linkage_single_block_in_pdf_context(self): + section = { + "details": { + "single_attribute_risk": { + "by_quasi_identifier": {"zip": {"mean_risk": 0.42}}, + } + }, + "auto_selection": {"selection_criteria": {"quasi_identifiers": {}}}, + } + details = prepare_governance_details(section, {}) + self.assertIsNotNone(details) + self.assertTrue(any(b["type"] == "table" for b in details["blocks"])) + + def test_build_pdf_section_details_keys(self): + sections = { + "dataset-overview": {}, + "data-quality": {}, + "impact-on-ai": {}, + "fairness-bias": {}, + "data-governance": {}, + } + result = build_pdf_section_details(sections, {}) + self.assertEqual( + set(result.keys()), + {"overview", "data_quality", "impact", "fairness", "governance"}, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/web/readiness/__init__.py b/web/readiness/__init__.py new file mode 100644 index 00000000..13f65d59 --- /dev/null +++ b/web/readiness/__init__.py @@ -0,0 +1 @@ +"""Readiness report server-side utilities.""" diff --git a/web/readiness/pdf.py b/web/readiness/pdf.py new file mode 100644 index 00000000..622d5ac1 --- /dev/null +++ b/web/readiness/pdf.py @@ -0,0 +1,796 @@ +"""Build view-model context and render readiness report PDF with WeasyPrint.""" + +from __future__ import annotations + +import re +from datetime import datetime, timezone +from html import escape +from pathlib import Path +from typing import Any + +from flask import render_template +from markupsafe import Markup + +from aidrin._version import __version__ +from web.readiness.pdf_glossary import ( + FOOTNOTE_LABELS, + READINESS_METRIC_GLOSSARY, + _FEATURE_TYPE_ABBR, + _STATUS_LABELS, +) + +NEEDS_ATTENTION_TOP_N = 6 +NEEDS_ATTENTION_MIN_N = 2 +# Approximate printable lines per sub-category in a half-column PDF block. +NEEDS_ATTENTION_LINE_BUDGET = 10 +_FAIR_PRINCIPLES = ("Findable", "Accessible", "Interoperable", "Reusable") + + +class SectionFootnotes: + """Scoped footnote refs and definitions for one report section.""" + + def __init__(self, registry: "FootnoteRegistry", scope: str) -> None: + self._registry = registry + self._scope = scope + + def ref(self, key: str | None) -> Markup: + return self._registry.ref(key, self._scope) + + def entries(self) -> list[dict[str, Any]]: + return self._registry.entries(self._scope) + + +class FootnoteRegistry: + """Collect numbered footnote refs per section while the PDF template renders.""" + + def __init__(self) -> None: + self._scopes: dict[str, list[str]] = {} + + def section(self, scope: str) -> SectionFootnotes: + return SectionFootnotes(self, scope) + + def ref(self, key: str | None, scope: str) -> Markup: + if not key or key not in READINESS_METRIC_GLOSSARY: + return Markup("") + order = self._scopes.setdefault(scope, []) + if key not in order: + order.append(key) + num = order.index(key) + 1 + return Markup(f'{num}') + + def entries(self, scope: str) -> list[dict[str, Any]]: + return [ + { + "number": idx, + "term": FOOTNOTE_LABELS.get(key, key.replace("_", " ").title()), + "definition": READINESS_METRIC_GLOSSARY[key], + } + for idx, key in enumerate(self._scopes.get(scope, []), start=1) + ] + + +def _status_label(score: float | None) -> str: + if score is None: + return "unknown" + if score >= 0.9: + return "good" + if score >= 0.7: + return "warning" + return "poor" + + +def fmt_pct(value: float | None, decimals: int = 2) -> str: + if value is None: + return "N/A" + try: + return f"{float(value) * 100:.{decimals}f}%" + except (TypeError, ValueError): + return "N/A" + + +def fmt_num(value: float | None, decimals: int = 2) -> str: + if value is None: + return "N/A" + try: + return f"{float(value):.{decimals}f}" + except (TypeError, ValueError): + return "N/A" + + +def fmt_bytes(value: int | float | None) -> str: + if value is None: + return "—" + try: + nbytes = float(value) + except (TypeError, ValueError): + return "—" + if nbytes < 1024: + return f"{int(nbytes)} B" + if nbytes < 1024 * 1024: + return f"{nbytes / 1024:.1f} KB" + return f"{nbytes / (1024 * 1024):.1f} MB" + + +def _e(text: Any) -> str: + return escape("" if text is None else str(text)) + + +def _na_row(primary: str, secondary: str | None = None, value: str | None = None) -> dict: + return {"primary": Markup(primary), "secondary": Markup(secondary) if secondary else None, "value": value} + + +def _na_row_line_estimate(row: dict) -> float: + """Rough vertical cost of one needs-attention list row in the PDF.""" + return 2.0 if row.get("secondary") else 1.0 + + +def _cap_na_rows( + rows: list[dict], + *, + max_rows: int = NEEDS_ATTENTION_TOP_N, + line_budget: float = NEEDS_ATTENTION_LINE_BUDGET, + min_rows: int = NEEDS_ATTENTION_MIN_N, +) -> tuple[list[dict], int]: + """Trim rows so a needs-attention sub-category is less likely to span a full page.""" + if not rows: + return [], 0 + + kept: list[dict] = [] + used = 0.0 + for row in rows: + est = _na_row_line_estimate(row) + if kept and (used + est > line_budget or len(kept) >= max_rows): + break + kept.append(row) + used += est + + if not kept: + kept = [rows[0]] + used = _na_row_line_estimate(rows[0]) + + while len(kept) > min_rows and used > line_budget: + removed = kept.pop() + used -= _na_row_line_estimate(removed) + + return kept, max(0, len(rows) - len(kept)) + + +def _na_block( + title: str, + glossary_key: str | None, + context: str | None, + rows: list[dict], + *, + tone: str = "default", +) -> dict: + header_lines = 1.5 + (1.5 if context else 0.0) + line_budget = max(float(NEEDS_ATTENTION_MIN_N), NEEDS_ATTENTION_LINE_BUDGET - header_lines) + kept, more_count = _cap_na_rows(rows, line_budget=line_budget) + return { + "title": title, + "glossary_key": glossary_key, + "context": Markup(context) if context else None, + "rows": kept, + "more_count": more_count, + "tone": tone, + } + + +def _kpi_tiles(section: dict) -> list[dict]: + tiles = [] + for kpi in section.get("kpis") or []: + value = kpi.get("value") + display = fmt_pct(value) + kpi_id = kpi.get("id") + if kpi_id == "label_balance" and kpi.get("raw_imbalance_degree") is not None: + display = f"ID {fmt_num(kpi['raw_imbalance_degree'])}" + elif kpi.get("raw_count") is not None: + display = f"{kpi['raw_count']} flagged" + elif kpi_id == "anonymity_k" and kpi.get("raw_k") is not None: + display = f"k={kpi['raw_k']}" + elif kpi_id == "diversity_l" and kpi.get("raw_l") is not None: + display = f"l={kpi['raw_l']}" + elif kpi_id == "distribution_t" and kpi.get("raw_t") is not None: + display = f"t={fmt_num(kpi['raw_t'])}" + elif kpi_id == "single_linkage_risk" and kpi.get("raw_worst_mean") is not None: + display = f"{fmt_num(kpi['raw_worst_mean'])} risk" + elif kpi_id == "linkage_risk" and kpi.get("raw_mean") is not None: + display = f"{fmt_num(kpi['raw_mean'])} risk" + elif kpi_id == "phi_exposure" and kpi.get("columns_flagged") is not None: + display = "None" if kpi["columns_flagged"] == 0 else f"{kpi['columns_flagged']} col(s)" + width = 0 if value is None else max(0, min(100, round(float(value) * 100))) + tiles.append( + { + "id": kpi_id, + "label": kpi.get("label", ""), + "hint": kpi.get("hint", ""), + "status": kpi.get("status") or _status_label(value), + "display": display, + "width_pct": width, + } + ) + return tiles + + +def _enrich_profile(profile: dict) -> dict: + feat_type = profile.get("type") or "" + return { + **profile, + "type_abbr": _FEATURE_TYPE_ABBR.get( + feat_type, feat_type[:1].upper() if feat_type else "—" + ), + "status_label": _STATUS_LABELS.get(profile.get("status", ""), "—"), + } + + +def _prepare_overview(overview: dict) -> dict: + meta = overview.get("file_metadata") or {} + profiles = overview.get("feature_profiles") or [] + profile_meta = overview.get("feature_profiles_meta") or {} + status_counts = profile_meta.get("status_counts") or {} + other_count = (meta.get("datetime_count") or 0) + (meta.get("boolean_count") or 0) + return { + "meta": meta, + "profiles": [_enrich_profile(p) for p in profiles], + "profile_meta": profile_meta, + "other_count": other_count, + "poor_count": status_counts.get("poor", sum(1 for p in profiles if p.get("status") == "poor")), + "warn_count": status_counts.get( + "warning", sum(1 for p in profiles if p.get("status") == "warning") + ), + } + + +def _prepare_data_quality(dq: dict) -> dict: + na = dq.get("needs_attention") or {} + incomplete = na.get("incomplete_features") or [] + outlier_feats = na.get("outlier_features") or [] + dup_rows = na.get("duplicate_rows") or 0 + blocks = [] + if incomplete: + rows = [ + _na_row(_e(f.get("feature")), value=f"{fmt_pct(f.get('completeness'))} complete") + for f in incomplete + ] + blocks.append(_na_block(f"Incomplete features ({len(incomplete)})", "completeness", None, rows)) + if outlier_feats: + rows = [ + _na_row(_e(f.get("feature")), value=f"{fmt_pct(f.get('outlier_proportion'))} outliers") + for f in outlier_feats + ] + blocks.append( + _na_block(f"Features with outliers ({len(outlier_feats)})", "outlier_cleanliness", None, rows) + ) + if dup_rows: + blocks.append( + { + "title": "Duplicate rows", + "glossary_key": "uniqueness", + "context": None, + "rows": [], + "more_count": 0, + "message": f"{fmt_pct(dup_rows)} of rows are exact duplicates.", + } + ) + scope = (dq.get("auto_selection") or {}).get("selection_criteria", {}).get("analysis_scope", {}) + return { + "grade": dq.get("grade"), + "grade_status": dq.get("grade_status"), + "kpis": _kpi_tiles(dq), + "auto_selection": scope, + "needs_attention": blocks, + "empty_message": "No data quality issues detected — all features complete, no duplicates, no outliers.", + } + + +def _pair_rows(pairs: list[dict], fmt_score) -> list[dict]: + return [ + _na_row( + f"{_e(p.get('a'))}{_e(p.get('b'))}", + value=f"|score| {fmt_score(p.get('score'))}", + ) + for p in pairs + ] + + +def _prepare_impact(impact: dict) -> dict: + auto_sel = impact.get("auto_selection") or {} + crit = auto_sel.get("selection_criteria") or {} + col_crit = crit.get("columns_analyzed") or {} + thresholds = crit.get("thresholds") or {} + na = impact.get("needs_attention") or {} + leakage = na.get("leakage_pairs") or impact.get("leakage_pairs") or [] + redundant = na.get("redundant_pairs") or impact.get("redundant_pairs") or [] + isolated = na.get("isolated_features") or impact.get("isolated_features") or [] + selected_cols = col_crit.get("selected") or [] + preview = ", ".join(selected_cols[:8]) + ("…" if len(selected_cols) > 8 else "") + blocks = [] + if leakage: + blocks.append( + _na_block( + f"Leakage risk (|score| ≥ 0.95) ({len(leakage)})", + "leakage_risk_pairs", + None, + _pair_rows(leakage, fmt_num), + tone="red", + ) + ) + if redundant: + blocks.append( + _na_block( + f"Redundant pairs (|score| ≥ 0.8) ({len(redundant)})", + "redundant_pairs", + None, + _pair_rows(redundant, fmt_num), + tone="amber", + ) + ) + if isolated: + rows = [_na_row(f"{_e(f)}") for f in isolated] + blocks.append( + _na_block( + f"Isolated features ({len(isolated)})", + "isolated_features", + None, + rows, + tone="amber", + ) + ) + return { + "grade": impact.get("grade"), + "grade_status": impact.get("grade_status"), + "kpis": _kpi_tiles(impact), + "columns_analyzed": impact.get("columns_analyzed") or len(selected_cols), + "columns_preview": preview or "none", + "columns_rule": col_crit.get("rule", ""), + "excluded_count": len(col_crit.get("excluded") or impact.get("columns_dropped") or []), + "thresholds": thresholds, + "needs_attention": blocks, + "empty_message": "No redundancy, leakage risk, or isolated features detected.", + } + + +def _prepare_fairness(fb: dict) -> dict: + sel = fb.get("auto_selection") or {} + criteria = sel.get("selection_criteria") or {} + sens_crit = criteria.get("sensitive_attributes") or {} + target_crit = criteria.get("target_column") or {} + pos_crit = criteria.get("positive_class") or {} + thresholds = criteria.get("thresholds") or {} + na = fb.get("needs_attention") or {} + blocks = [] + + rep_imbalance = na.get("representation_imbalance") or [] + if rep_imbalance: + rows = [] + for item in rep_imbalance: + hint = "" + pairs = item.get("flagged_pairs") or [] + if pairs: + hint = f"Worst pair: {pairs[0].get('pair')} (ratio {fmt_num(pairs[0].get('ratio'))})" + rows.append( + _na_row( + f"{_e(item.get('column'))}", + hint, + f"max ratio {fmt_num(item.get('max_ratio'))}", + ) + ) + blocks.append( + _na_block( + f"Representation imbalance ({len(rep_imbalance)})", + "representation_imbalance", + "Sensitive attributes with extreme category probability ratios", + rows, + tone="amber", + ) + ) + + minorities = na.get("minority_classes") or [] + if minorities: + target_col = minorities[0].get("target_column") or target_crit.get("selected") or "target" + rows = [ + _na_row( + _e(m.get("class")), + f"Class in {_e(target_col)}", + f"{fmt_pct(m.get('share'))} share", + ) + for m in minorities + ] + blocks.append( + _na_block( + f"Minority classes ({len(minorities)})", + "minority_classes", + f"Target column: {_e(target_col)}", + rows, + tone="amber", + ) + ) + + outcome_disp = na.get("outcome_disparities") or [] + if outcome_disp: + sens_col = outcome_disp[0].get("sensitive_column") or sel.get("primary_sensitive") or "—" + tgt_col = outcome_disp[0].get("target_column") or target_crit.get("selected") or "—" + rows = [ + _na_row( + f"{_e(d.get('target_column') or tgt_col)} = {_e(d.get('class'))}", + f"Outcome rates vary by sensitive {_e(d.get('sensitive_column') or sens_col)}", + f"TSD {fmt_num(d.get('tsd'))}", + ) + for d in outcome_disp + ] + blocks.append( + _na_block( + f"Outcome-rate disparities ({len(outcome_disp)})", + "outcome_disparities", + f"Sensitive {_e(sens_col)} × target {_e(tgt_col)}", + rows, + tone="amber", + ) + ) + + cdd_disp = na.get("cdd_disparities") or [] + if cdd_disp: + sens_col = cdd_disp[0].get("sensitive_column") or sel.get("primary_sensitive") or "—" + tgt_col = cdd_disp[0].get("target_column") or target_crit.get("selected") or "—" + pos_class = cdd_disp[0].get("positive_class") or pos_crit.get("selected") or "—" + rows = [ + _na_row( + f"{_e(d.get('sensitive_column') or sens_col)} = {_e(d.get('group'))}", + ( + f"CDD vs target {_e(d.get('target_column') or tgt_col)} " + f"(positive: {_e(d.get('positive_class', pos_class))})" + ), + ) + for d in cdd_disp + ] + blocks.append( + _na_block( + f"CDD flagged groups ({len(cdd_disp)})", + "cdd_disparities", + f"Sensitive {_e(sens_col)} × target {_e(tgt_col)}", + rows, + tone="red", + ) + ) + + return { + "grade": fb.get("grade"), + "grade_status": fb.get("grade_status"), + "kpis": _kpi_tiles(fb), + "sensitive_attributes": sens_crit, + "target_column": target_crit, + "positive_class": pos_crit, + "primary_sensitive": sel.get("primary_sensitive"), + "thresholds": thresholds, + "needs_attention": blocks, + "empty_message": "No fairness issues detected under the automated thresholds.", + } + + +def _prepare_governance(gov: dict) -> dict: + sel = gov.get("auto_selection") or {} + criteria = sel.get("selection_criteria") or {} + qi_crit = criteria.get("quasi_identifiers") or {} + sens_crit = criteria.get("sensitive_attribute") or {} + id_crit = criteria.get("id_column") or {} + hipaa_crit = criteria.get("hipaa_scan_columns") or {} + thresholds = criteria.get("thresholds") or {} + na = gov.get("needs_attention") or {} + blocks = [] + + low_anon = na.get("low_anonymity") or [] + if low_anon: + rows = [] + for item in low_anon: + rows.append( + _na_row( + f"{_e(item.get('metric'))}: k = {item.get('value')}", + item.get("detail"), + ( + f"{item.get('singleton_count')} singleton group(s)" + if item.get("singleton_count") is not None + else None + ), + ) + ) + if item.get("worst_single_qi"): + wsq = item["worst_single_qi"] + rows.append( + _na_row( + f"Highest single-QI risk: {_e(wsq.get('feature'))}", + "May contribute to low k when combined with other quasi-identifiers", + f"risk {fmt_num(wsq.get('mean_risk'))}", + ) + ) + qi_list = low_anon[0].get("quasi_identifiers") or [] + ctx = ( + f"Quasi-identifiers: {_e(', '.join(qi_list))}" + if qi_list + else None + ) + blocks.append( + _na_block(f"Low anonymity ({len(low_anon)})", "low_anonymity", ctx, rows, tone="red") + ) + + hipaa_phi = na.get("hipaa_phi") or [] + if hipaa_phi: + rows = [ + _na_row( + f"{_e(x.get('column'))}", + ", ".join(x.get("types") or []) or "Pattern match", + f"{x.get('total_flags')} flag(s)", + ) + for x in hipaa_phi + ] + blocks.append( + _na_block( + f"HIPAA pattern matches ({len(hipaa_phi)})", + "hipaa_phi", + "Scanned text-like columns for HIPAA-style identifier patterns", + rows, + tone="red", + ) + ) + + linkage_na = na.get("high_linkage_risk") or [] + if linkage_na: + rows = [] + for item in linkage_na: + qis = item.get("quasi_identifiers") or item.get("features") or [] + feat = item.get("feature") + label = ( + f"{_e(feat)}" + if feat + else f"{_e(', '.join(qis))}" + ) + rows.append( + _na_row( + f"{_e(item.get('metric'))}: {label}", + item.get("detail") or (f"Quasi-identifiers: {', '.join(qis)}" if qis else None), + f"risk {fmt_num(item.get('mean_risk'))}", + ) + ) + blocks.append( + _na_block( + f"High linkage risk ({len(linkage_na)})", + "high_linkage_risk", + None, + rows, + tone="amber", + ) + ) + + attr_disc = na.get("attribute_disclosure") or [] + if attr_disc: + rows = [ + _na_row( + f"{_e(x.get('metric'))} = {fmt_num(x.get('value'))}", + x.get("detail"), + ) + for x in attr_disc + ] + sens = attr_disc[0].get("sensitive_attribute") + ctx = f"Sensitive: {_e(sens)}" if sens else None + blocks.append( + _na_block( + f"Attribute disclosure risk ({len(attr_disc)})", + "attribute_disclosure", + ctx, + rows, + tone="amber", + ) + ) + + hipaa_selected = hipaa_crit.get("selected") or [] + return { + "grade": gov.get("grade"), + "grade_status": gov.get("grade_status"), + "kpis": _kpi_tiles(gov), + "quasi_identifiers": qi_crit, + "sensitive_attribute": sens_crit, + "id_column": id_crit, + "hipaa_scan": hipaa_selected, + "thresholds": thresholds, + "small_sample_warning": gov.get("small_sample_warning"), + "needs_attention": blocks, + "empty_message": "No governance issues detected under the automated thresholds.", + } + + +def _fair_check_failed(value: Any) -> bool: + if value is False: + return True + s = str(value) + return s in ("Fail", "No") or "CHECK FAILED" in s + + +def _fair_value_rows(obj: Any) -> list[dict[str, Any]]: + """Flatten a FAIR principle detail object into printable table rows.""" + rows: list[dict[str, Any]] = [] + if not isinstance(obj, dict): + return rows + for key, val in obj.items(): + if isinstance(val, dict): + rows.append({"label": key, "is_group": True}) + rows.extend(_fair_value_rows(val)) + else: + failed = _fair_check_failed(val) + rows.append( + { + "label": key, + "is_group": False, + "found": not failed, + "status_label": "Missing" if failed else "Found", + } + ) + return rows + + +def _prepare_fair_compliance(fair_data: dict | None) -> dict | None: + if not fair_data or fair_data.get("error"): + return None + checks = fair_data.get("FAIR Compliance Checks") or {} + total_check = checks.get("Total Checks", "") + match = re.search(r"(\d+)/(\d+)", str(total_check)) + total_passed = int(match.group(1)) if match else 0 + total_expected = int(match.group(2)) if match else 1 + total_pct = round((total_passed / total_expected) * 100) if total_expected else 0 + principles = [] + for principle in _FAIR_PRINCIPLES: + check_str = checks.get(f"{principle} Checks", "0/0") + m = re.search(r"(\d+)/(\d+)", str(check_str)) + passed = int(m.group(1)) if m else 0 + total = int(m.group(2)) if m else 1 + pct = round((passed / total) * 100) if total else 0 + detail = fair_data.get(principle) + rows = _fair_value_rows(detail) if isinstance(detail, dict) else [] + principles.append( + { + "name": principle, + "passed": passed, + "total": total, + "pct": pct, + "check_str": check_str, + "detail": detail, + "rows": rows, + } + ) + return { + "checks": checks, + "total_passed": total_passed, + "total_expected": total_expected, + "total_pct": total_pct, + "principles": principles, + } + + +def _collect_glossary_keys( + prepared: dict[str, dict], fair_data: dict | None +) -> list[dict[str, Any]]: + keys = { + "feature_profile", + "feature_type_codes", + "pct_missing", + "n_unique", + "pct_dominant", + "profile_status", + "overall_dq_grade", + "analysis_scope", + "overall_impact_grade", + "overall_fairness_grade", + "overall_governance_grade", + } + for section_key in ("data_quality", "impact", "fairness", "governance"): + section = prepared.get(section_key) or {} + for kpi in section.get("kpis") or []: + if kpi.get("id"): + keys.add(kpi["id"]) + for block in section.get("needs_attention") or []: + if block.get("glossary_key"): + keys.add(block["glossary_key"]) + glossary = [] + for key in sorted(keys): + definition = READINESS_METRIC_GLOSSARY.get(key) + if definition: + glossary.append( + { + "term": FOOTNOTE_LABELS.get(key, key.replace("_", " ").title()), + "definition": definition, + } + ) + if fair_data: + glossary.append( + { + "term": "FAIR compliance", + "definition": READINESS_METRIC_GLOSSARY["fair_compliance"], + } + ) + return glossary + + +def build_pdf_context( + *, + file_name: str, + sections: dict[str, dict], + fair_data: dict | None = None, + include_details: bool = False, + visualizations: dict[str, dict] | None = None, +) -> dict[str, Any]: + """Assemble Jinja context for the readiness report PDF.""" + overview = _prepare_overview(sections.get("dataset-overview") or {}) + data_quality = _prepare_data_quality(sections.get("data-quality") or {}) + impact = _prepare_impact(sections.get("impact-on-ai") or {}) + fairness = _prepare_fairness(sections.get("fairness-bias") or {}) + governance = _prepare_governance(sections.get("data-governance") or {}) + fair_compliance = _prepare_fair_compliance(fair_data) + prepared = { + "data_quality": data_quality, + "impact": impact, + "fairness": fairness, + "governance": governance, + } + glossary = _collect_glossary_keys(prepared, fair_data) + section_details: dict[str, dict[str, Any] | None] = {} + if include_details: + from web.readiness.pdf_details import build_pdf_section_details + + section_details = build_pdf_section_details(sections, visualizations or {}) + return { + "file_name": file_name, + "generated_at": datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC"), + "app_version": __version__, + "include_details": include_details, + "overview": overview, + "data_quality": data_quality, + "impact": impact, + "fairness": fairness, + "governance": governance, + "fair_compliance": fair_compliance, + "section_details": section_details, + "glossary": glossary, + "fmt_pct": fmt_pct, + "fmt_num": fmt_num, + "fmt_bytes": fmt_bytes, + } + + +def pdf_filename(file_name: str, *, full: bool = False) -> str: + stem = re.sub(r"\.[^.]+$", "", file_name or "dataset") + stem = re.sub(r"[^\w.-]+", "_", stem) or "dataset" + date = datetime.now(timezone.utc).strftime("%Y-%m-%d") + prefix = "readiness-report-full" if full else "readiness-report" + return f"{prefix}-{stem}-{date}.pdf" + + +def readiness_pdf_logo_uri(app) -> str: + """Return a file URI for the AIDRIN logo used in readiness report PDFs.""" + images_dir = Path(app.root_path).resolve().parent / "aidrin" / "images" + for name in ("logoNoBackground.png", "logo.png"): + logo_path = images_dir / name + if logo_path.is_file(): + return logo_path.as_uri() + raise RuntimeError(f"AIDRIN logo not found under {images_dir}") + + +def render_readiness_report_pdf(app, context: dict[str, Any]) -> bytes: + """Render PDF bytes from a prepared context dict.""" + try: + from weasyprint import CSS, HTML + except ImportError as exc: + raise RuntimeError( + "WeasyPrint is not installed. Install with: pip install weasyprint" + ) from exc + + footnotes = FootnoteRegistry() + render_context = { + **context, + "footnotes": footnotes, + "logo_url": readiness_pdf_logo_uri(app), + } + html = render_template("readiness_report/pdf.html", **render_context) + static_root = Path(app.root_path) / "static" + css_path = static_root / "css" / "readiness_report_print.css" + base_url = static_root.as_uri() + "/" + return HTML(string=html, base_url=base_url).write_pdf( + stylesheets=[CSS(filename=str(css_path))] + ) diff --git a/web/readiness/pdf_details.py b/web/readiness/pdf_details.py new file mode 100644 index 00000000..e906725c --- /dev/null +++ b/web/readiness/pdf_details.py @@ -0,0 +1,371 @@ +"""Prepare 'Show details' blocks for the full readiness report PDF.""" + +from __future__ import annotations + +from html import escape +from typing import Any + +_MAX_DETAIL_TABLE_ROWS = 50 +_MAX_DETAIL_LIST_ITEMS = 50 +_NUM_STAT_ORDER = ( + "count", + "min", + "25th percentile", + "50th percentile", + "mean", + "75th percentile", + "max", + "std", +) +_VIZ_LABELS = { + "completeness": "Completeness by feature", + "outliers": "Outliers by feature", + "numerical_correlation": "Numerical correlation", + "categorical_correlation": "Categorical correlation (Theil's U)", + "class_imbalance": "Class imbalance", + "statistical_rate": "Statistical rate", + "k_anonymity": "k-Anonymity", + "l_diversity": "l-Diversity", + "t_closeness": "t-Closeness", + "entropy_risk": "Entropy risk", + "multiple_attribute_risk": "Multiple-attribute linkage risk", + "differential_privacy": "Differential privacy (illustrative)", +} + + +def _e(text: Any) -> str: + return escape("" if text is None else str(text)) + + +def _fmt_num(value: float | None, decimals: int = 2) -> str: + if value is None: + return "N/A" + try: + return f"{float(value):.{decimals}f}" + except (TypeError, ValueError): + return "N/A" + + +def _heading(text: str) -> dict[str, Any]: + return {"type": "subheading", "text": text} + + +def _note(text: str) -> dict[str, Any]: + return {"type": "note", "text": text} + + +def _table( + title: str, + headers: list[str], + rows: list[list[str]], + *, + note: str | None = None, +) -> dict[str, Any]: + return { + "type": "table", + "title": title, + "headers": headers, + "rows": rows, + "note": note, + } + + +def _list_block(title: str, entries: list[dict[str, str]]) -> dict[str, Any]: + return {"type": "list", "title": title, "entries": entries} + + +def _chart_group(title: str, charts: list[dict[str, str]]) -> dict[str, Any] | None: + if not charts: + return None + return {"type": "chart_group", "title": title, "charts": charts} + + +def _chart_wide(title: str, image_b64: str) -> dict[str, Any] | None: + if not image_b64: + return None + return {"type": "chart_wide", "title": title, "image_b64": image_b64} + + +def _charts_from_mapping( + mapping: dict[str, str] | None, + *, + label_fn=None, +) -> list[dict[str, str]]: + if not mapping: + return [] + charts = [] + for key, b64 in mapping.items(): + if not b64: + continue + if label_fn: + label = label_fn(key) + else: + label = key.removesuffix("_light").replace("_", " ") + charts.append({"label": label, "image_b64": b64}) + return charts + + +def _viz_chart(viz: dict[str, str] | None, key: str, title: str | None = None) -> dict[str, Any] | None: + if not viz or not viz.get(key): + return None + return _chart_wide(title or _VIZ_LABELS.get(key, key.replace("_", " ").title()), viz[key]) + + +def _section_blocks(blocks: list[dict[str, Any] | None], heading: str) -> dict[str, Any] | None: + kept = [b for b in blocks if b] + if not kept: + return None + return {"heading": heading, "blocks": kept} + + +def prepare_overview_details(section: dict, viz: dict[str, Any] | None) -> dict[str, Any] | None: + blocks: list[dict[str, Any] | None] = [] + num_summary = section.get("numerical_summary") or {} + num_meta = section.get("numerical_summary_meta") or {} + features = list(num_summary.keys())[:_MAX_DETAIL_TABLE_ROWS] + if features: + all_stats = list((num_summary[features[0]] or {}).keys()) + stat_keys = [s for s in _NUM_STAT_ORDER if s in all_stats] + stat_keys += [s for s in all_stats if s not in stat_keys] + rows = [] + for feat in features: + row = [feat] + for stat in stat_keys: + val = num_summary[feat].get(stat) + if isinstance(val, float): + row.append(_fmt_num(val)) + elif val is None: + row.append("—") + else: + row.append(str(val)) + rows.append(row) + note = None + total = num_meta.get("total") or len(num_summary) + if total > len(features): + note = f"Showing first {len(features)} of {total} numerical features." + blocks.append(_table("Numerical summary statistics", ["Feature", *stat_keys], rows, note=note)) + + profile_meta = section.get("feature_profiles_meta") or {} + if profile_meta.get("truncated"): + blocks.append( + _note( + "Distribution charts use the same prioritized features as the profile table " + f"({profile_meta.get('shown', 0):,} of {profile_meta.get('total', 0):,})." + ) + ) + + cat_charts = (viz or {}).get("categorical_charts") or section.get("categorical_charts") or {} + hist_charts = (viz or {}).get("histograms") or section.get("histograms") or {} + cat_group = _chart_group("Categorical value distributions", _charts_from_mapping(cat_charts)) + hist_group = _chart_group( + "Feature distributions (numerical)", + _charts_from_mapping(hist_charts), + ) + if cat_group: + blocks.append(cat_group) + if hist_group: + blocks.append(hist_group) + + return _section_blocks(blocks, "Detailed statistics & distributions") + + +def prepare_data_quality_details(section: dict, viz: dict[str, Any] | None) -> dict[str, Any] | None: + blocks: list[dict[str, Any] | None] = [] + det = section.get("details") or {} + compl_b64 = (viz or {}).get("completeness") or (det.get("completeness") or {}).get("visualization") + out_b64 = (viz or {}).get("outliers") or (det.get("outliers") or {}).get("visualization") + if compl_b64: + blocks.append(_chart_wide("Completeness by feature", compl_b64)) + elif (det.get("completeness") or {}).get("error"): + blocks.append(_note(f"Completeness: {det['completeness']['error']}")) + if out_b64: + blocks.append(_chart_wide("Outliers by feature", out_b64)) + elif (det.get("outliers") or {}).get("error"): + blocks.append(_note(f"Outliers: {det['outliers']['error']}")) + return _section_blocks(blocks, "Detailed charts") + + +def prepare_impact_details(section: dict, viz: dict[str, Any] | None) -> dict[str, Any] | None: + blocks: list[dict[str, Any] | None] = [] + top_pairs = section.get("top_pairs") or [] + if top_pairs: + rows = [ + [p.get("a", ""), p.get("b", ""), _fmt_num(p.get("score"))] + for p in top_pairs[:_MAX_DETAIL_TABLE_ROWS] + ] + blocks.append(_table("Most-related feature pairs", ["Feature A", "Feature B", "Score"], rows)) + + det = section.get("details") or {} + num_b64 = (viz or {}).get("numerical_correlation") or det.get("numerical_visualization") + cat_b64 = (viz or {}).get("categorical_correlation") or det.get("categorical_visualization") + method = det.get("numerical_method") + num_title = f"Numerical correlation ({method})" if method else "Numerical correlation" + if num_b64: + blocks.append(_chart_wide(num_title, num_b64)) + if cat_b64: + blocks.append(_chart_wide("Categorical correlation (Theil's U)", cat_b64)) + + col_crit = ((section.get("auto_selection") or {}).get("selection_criteria") or {}).get( + "columns_analyzed" + ) or {} + excluded = col_crit.get("excluded") or section.get("columns_dropped") or [] + if excluded: + items = [ + {"primary": e.get("feature", ""), "secondary": e.get("reason", "")} + for e in excluded[:_MAX_DETAIL_LIST_ITEMS] + ] + total = (col_crit.get("excluded_meta") or {}).get("total") or len(excluded) + title = f"Excluded columns ({total})" + if len(excluded) > len(items): + items.append({"primary": f"+{len(excluded) - len(items)} more not shown", "secondary": ""}) + blocks.append(_list_block(title, items)) + + return _section_blocks(blocks, "Detailed charts & tables") + + +def prepare_fairness_details(section: dict, viz: dict[str, Any] | None) -> dict[str, Any] | None: + blocks: list[dict[str, Any] | None] = [] + det = section.get("details") or {} + viz = viz or {} + + rep_charts = [] + for key, b64 in viz.items(): + if key.startswith("representation_rate.") and b64: + col = key.split(".", 1)[1] + rep_charts.append({"label": col, "image_b64": b64}) + rep_vis = (det.get("representation_rate") or {}).get("visualizations") or {} + if not rep_charts: + rep_charts = _charts_from_mapping(rep_vis) + rep_group = _chart_group("Representation rate by sensitive attribute", rep_charts) + if rep_group: + blocks.append(rep_group) + elif (det.get("representation_rate") or {}).get("error"): + blocks.append(_note(f"Representation rate: {det['representation_rate']['error']}")) + + target = ((section.get("auto_selection") or {}).get("selection_criteria") or {}).get( + "target_column" + ) or {} + target_name = target.get("selected") or "target" + ci_b64 = viz.get("class_imbalance") or (det.get("class_imbalance") or {}).get("visualization") + if ci_b64: + blocks.append(_chart_wide(f"Class imbalance — {target_name}", ci_b64)) + elif (det.get("class_imbalance") or {}).get("error"): + blocks.append(_note(f"Class imbalance: {det['class_imbalance']['error']}")) + + sr = det.get("statistical_rate") or {} + sr_b64 = viz.get("statistical_rate") or sr.get("visualization") + if sr_b64: + blocks.append( + _chart_wide( + f"Statistical rate — {sr.get('sensitive', 'sensitive')} × {sr.get('target', target_name)}", + sr_b64, + ) + ) + elif sr.get("error"): + blocks.append(_note(f"Statistical rate: {sr['error']}")) + + cdd = det.get("cdd") or {} + disparities = cdd.get("disparities") or {} + if disparities and not cdd.get("error"): + rows = [[str(grp), str(info.get("disparity", ""))] for grp, info in disparities.items()] + blocks.append(_table("Conditional demographic disparity (CDD)", ["Group", "Disparity"], rows)) + elif cdd.get("error"): + blocks.append(_note(f"CDD: {cdd['error']}")) + + return _section_blocks(blocks, "Detailed charts & tables") + + +def prepare_governance_details(section: dict, viz: dict[str, Any] | None) -> dict[str, Any] | None: + blocks: list[dict[str, Any] | None] = [] + det = section.get("details") or {} + viz = viz or {} + + small_charts = [] + for key, title in _VIZ_LABELS.items(): + if key in ("k_anonymity", "l_diversity", "t_closeness", "entropy_risk", "differential_privacy"): + b64 = viz.get(key) or (det.get(key) or {}).get("visualization") + if b64: + small_charts.append({"label": title, "image_b64": b64}) + elif (det.get(key) or {}).get("error"): + blocks.append(_note(f"{title}: {det[key]['error']}")) + mm_b64 = viz.get("multiple_attribute_risk") or (det.get("multiple_attribute_risk") or {}).get( + "visualization" + ) + if mm_b64: + small_charts.append({"label": _VIZ_LABELS["multiple_attribute_risk"], "image_b64": mm_b64}) + mm_group = _chart_group("Privacy & linkage charts", small_charts) + if mm_group: + blocks.append(mm_group) + + single_risk = (det.get("single_attribute_risk") or {}).get("by_quasi_identifier") or {} + if single_risk: + rows = [ + [q, _fmt_num(v.get("mean_risk"))] + for q, v in single_risk.items() + if v.get("mean_risk") is not None + ] + if rows: + blocks.append( + _table( + "Single-attribute MM risk by quasi-identifier", + ["Quasi-identifier", "Mean risk"], + rows[:_MAX_DETAIL_TABLE_ROWS], + ) + ) + + hipaa_det = (det.get("hipaa") or {}).get("detected") or {} + if hipaa_det: + rows = [ + [ + col, + ", ".join(info.get("potential_types_detected") or []), + str(info.get("total_flags", "")), + ] + for col, info in hipaa_det.items() + ] + blocks.append(_table("HIPAA scan results", ["Column", "Types", "Flags"], rows[:_MAX_DETAIL_TABLE_ROWS])) + + qi_crit = ((section.get("auto_selection") or {}).get("selection_criteria") or {}).get( + "quasi_identifiers" + ) or {} + qi_excluded = qi_crit.get("excluded") or [] + if qi_excluded: + total = (qi_crit.get("excluded_meta") or {}).get("total") or len(qi_excluded) + items = [ + {"primary": e.get("feature", ""), "secondary": e.get("reason", "")} + for e in qi_excluded[:_MAX_DETAIL_LIST_ITEMS] + ] + if len(qi_excluded) > len(items): + items.append({"primary": f"+{len(qi_excluded) - len(items)} more not shown", "secondary": ""}) + blocks.append(_list_block(f"Excluded quasi-identifier candidates ({total})", items)) + + return _section_blocks(blocks, "Detailed charts & tables") + + +def build_pdf_section_details( + sections: dict[str, dict], + visualizations: dict[str, dict[str, Any]], +) -> dict[str, dict[str, Any] | None]: + """Return detail block trees keyed for the PDF template.""" + return { + "overview": prepare_overview_details( + sections.get("dataset-overview") or {}, + visualizations.get("dataset-overview"), + ), + "data_quality": prepare_data_quality_details( + sections.get("data-quality") or {}, + visualizations.get("data-quality"), + ), + "impact": prepare_impact_details( + sections.get("impact-on-ai") or {}, + visualizations.get("impact-on-ai"), + ), + "fairness": prepare_fairness_details( + sections.get("fairness-bias") or {}, + visualizations.get("fairness-bias"), + ), + "governance": prepare_governance_details( + sections.get("data-governance") or {}, + visualizations.get("data-governance"), + ), + } diff --git a/web/readiness/pdf_glossary.py b/web/readiness/pdf_glossary.py new file mode 100644 index 00000000..2fcf825f --- /dev/null +++ b/web/readiness/pdf_glossary.py @@ -0,0 +1,187 @@ +"""Metric definitions for readiness report PDF footnotes.""" + +READINESS_METRIC_GLOSSARY = { + "feature_profile": ( + "A per-column snapshot of whether each feature is usable for modeling. " + "Combines missingness, cardinality, and value balance into a readiness status." + ), + "feature_type_codes": ( + "Feature type abbreviations in the profile table: " + "N = Numerical, C = Categorical, D = Datetime, B = Boolean." + ), + "pct_missing": ( + "Share of rows where this feature is missing (null/NaN). " + "High missingness reduces reliability and may require imputation or dropping the column." + ), + "n_unique": ( + "Number of distinct non-missing values. Very low values suggest constants; " + "very high values relative to row count may indicate IDs or free text." + ), + "pct_dominant": ( + "Share of rows taken by the most frequent value (the mode). " + "Values near 100% mean the column is almost constant." + ), + "profile_status": ( + "Readiness verdict for this feature. Poor: high missingness, constant, or ID-like. " + "Warning: moderate issues. Good: no major issues detected." + ), + "overall_dq_grade": ( + "Average of the data-quality KPIs (completeness, uniqueness, outlier-cleanliness). " + "Higher is better." + ), + "analysis_scope": ( + "This section evaluates every column automatically; no features or targets are chosen by the user." + ), + "completeness": ( + "Overall share of non-missing values across all features." + ), + "uniqueness": ( + "One minus the proportion of duplicate rows. Low uniqueness means many exact duplicate records." + ), + "outlier_cleanliness": ( + "One minus the mean outlier proportion across numerical features (IQR method)." + ), + "features_analyzed": ( + "Number of columns included in the automated correlation scan after pruning." + ), + "leakage_risk_pairs": ( + "Feature pairs with correlation |score| ≥ 0.95 — nearly duplicate or derived from each other." + ), + "redundant_pairs": ( + "Feature pairs with correlation |score| between 0.8 and 0.95 — likely redundant." + ), + "isolated_features": ( + "Features whose strongest correlation to any other feature is below 0.1." + ), + "most_related_pairs": ( + "The feature pairs with the highest absolute correlation scores from the automated scan." + ), + "overall_impact_grade": ( + "Average of impact KPIs (leakage safety, redundancy, informativeness). Higher is better." + ), + "leakage_safety": ( + "Whether any feature pairs exceed the leakage-risk correlation threshold (|score| ≥ 0.95)." + ), + "redundancy": ( + "Derived from the count of highly correlated redundant pairs (|score| ≥ 0.8)." + ), + "informativeness": ( + "Share of analyzed features that have at least one meaningful correlation to another feature." + ), + "overall_fairness_grade": ( + "Average of fairness KPIs (representation balance, label balance, outcome parity). Higher is better." + ), + "representation_balance": ( + "1 divided by the worst group probability ratio across auto-selected sensitive attributes." + ), + "label_balance": ( + "Derived from the Imbalance Degree of the auto-selected target column." + ), + "outcome_parity": ( + "1 minus the maximum TSD (standard deviation of class rates across sensitive groups)." + ), + "representation_imbalance": ( + "Sensitive attributes where the largest group probability ratio exceeds the threshold." + ), + "minority_classes": ( + "Target classes that make up less than 5% of rows." + ), + "outcome_disparities": ( + "Target classes whose outcome rates vary most across sensitive groups (high TSD)." + ), + "cdd_disparities": ( + "Sensitive groups flagged by Conditional Demographic Disparity." + ), + "overall_governance_grade": ( + "Average of governance KPIs (anonymity, diversity, distribution leakage, linkage risk, PHI exposure)." + ), + "anonymity_k": ( + "Minimum equivalence-class size (k) on auto-selected quasi-identifiers." + ), + "diversity_l": ( + "Minimum l-diversity on the auto-selected sensitive attribute within QI groups." + ), + "distribution_t": ( + "Maximum t-closeness (TVD) between group and global sensitive-attribute distributions." + ), + "single_linkage_risk": ( + "Worst mean Marketer/Prosecutor re-identification risk across single quasi-identifiers." + ), + "linkage_risk": ( + "Mean MM re-identification risk when all auto-selected quasi-identifiers are combined." + ), + "phi_exposure": ( + "HIPAA-style pattern scan on auto-selected text columns. Not a regulatory certification." + ), + "low_anonymity": ( + "Privacy metrics (e.g. k-Anonymity) below warning thresholds." + ), + "hipaa_phi": ( + "Columns where HIPAA-like identifier patterns were detected during the automated scan." + ), + "high_linkage_risk": ( + "Quasi-identifiers or QI combinations with high Marketer/Prosecutor re-identification risk." + ), + "attribute_disclosure": ( + "l-Diversity or t-Closeness signals suggesting sensitive-attribute values may be inferable." + ), + "fair_compliance": ( + "Optional metadata assessment (DCAT or Datacite JSON); not derived from the dataset file." + ), +} + +FOOTNOTE_LABELS = { + "feature_profile": "Per-feature readiness profile", + "feature_type_codes": "Feature type codes", + "pct_missing": "% missing", + "n_unique": "# unique", + "pct_dominant": "% dominant", + "profile_status": "Status", + "overall_dq_grade": "Overall data quality grade", + "analysis_scope": "Analysis scope", + "completeness": "Completeness", + "uniqueness": "Uniqueness", + "outlier_cleanliness": "Outlier cleanliness", + "features_analyzed": "Features analyzed", + "leakage_risk_pairs": "Leakage risk pairs", + "redundant_pairs": "Redundant pairs", + "isolated_features": "Isolated features", + "most_related_pairs": "Most related pairs", + "overall_impact_grade": "Overall impact grade", + "leakage_safety": "Leakage safety", + "redundancy": "Redundancy", + "informativeness": "Informativeness", + "overall_fairness_grade": "Overall fairness grade", + "representation_balance": "Representation balance", + "label_balance": "Label balance", + "outcome_parity": "Outcome parity", + "representation_imbalance": "Representation imbalance", + "minority_classes": "Minority classes", + "outcome_disparities": "Outcome disparities", + "cdd_disparities": "CDD disparities", + "overall_governance_grade": "Overall governance grade", + "anonymity_k": "Anonymity (k)", + "diversity_l": "Diversity (l)", + "distribution_t": "Distribution (t)", + "single_linkage_risk": "Single linkage risk", + "linkage_risk": "Linkage risk", + "phi_exposure": "PHI exposure", + "low_anonymity": "Low anonymity", + "hipaa_phi": "HIPAA PHI", + "high_linkage_risk": "High linkage risk", + "attribute_disclosure": "Attribute disclosure", + "fair_compliance": "FAIR compliance", +} + +_FEATURE_TYPE_ABBR = { + "numerical": "N", + "categorical": "C", + "datetime": "D", + "boolean": "B", +} + +_STATUS_LABELS = { + "good": "Good", + "warning": "Warning", + "poor": "Poor", +} diff --git a/web/routes/core.py b/web/routes/core.py index c5b18b6e..c0da2a2d 100644 --- a/web/routes/core.py +++ b/web/routes/core.py @@ -300,6 +300,22 @@ def cached_result(metric_name): if not file_name: return jsonify({"cached": False}) + if metric_name == "readiness_report": + from web.routes.metrics import get_cached_readiness_report + + cached_report = get_cached_readiness_report(file_name) + if cached_report: + return jsonify(ensure_json_serializable(cached_report)) + return jsonify({"cached": False}) + + if metric_name == "readiness_report_fair": + from web.routes.metrics import get_cached_readiness_fair_report + + cached_fair = get_cached_readiness_fair_report(file_name) + if cached_fair: + return jsonify(ensure_json_serializable(cached_fair)) + return jsonify({"cached": False}) + cache_key = f"user:{user_id}:file:{file_name}:{metric_name}" entry = current_app.TEMP_RESULTS_CACHE.get(cache_key) if entry and entry.get("data"): diff --git a/web/routes/metrics.py b/web/routes/metrics.py index 6e3a8def..533e72cd 100644 --- a/web/routes/metrics.py +++ b/web/routes/metrics.py @@ -1,7 +1,12 @@ +import hashlib +import io import json import logging +import math +import os import time +import pandas as pd from celery.result import AsyncResult from web.telemetry import get_tracer, trace_metric from flask import ( @@ -10,6 +15,7 @@ jsonify, redirect, request, + send_file, session, url_for, ) @@ -44,6 +50,8 @@ compute_k_anonymity, compute_l_diversity, compute_t_closeness, + generate_multiple_attribute_MM_risk_scores_groupby, + generate_single_attribute_MM_risk_scores_groupby, ) from aidrin.structured_data_metrics.representation_rate import ( calculate_representation_rate, @@ -54,9 +62,12 @@ ensure_json_serializable, format_dict_values, generate_metric_cache_key, + get_current_user_id, get_result_or_default, is_metric_cache_valid, store_result, + summary_histograms, + categorical_distribution_charts, ) metrics_bp = Blueprint("metrics", __name__) @@ -137,6 +148,2419 @@ def data_quality(): return get_result_or_default("metrics.data_quality", file_path, file_name) +# --------------------------------------------------------------------------- +# Readiness Report (aggregated, non-interactive) +# --------------------------------------------------------------------------- + +def _grade_label(score): + """Map a 0–1 readiness score to a coarse status label.""" + if score is None: + return "unknown" + if score >= 0.9: + return "good" + if score >= 0.7: + return "warning" + return "poor" + + +# Dataset-overview readiness thresholds (per-feature profile) +_OVERVIEW_MISSING_WARNING = 0.05 +_OVERVIEW_MISSING_POOR = 0.20 +_OVERVIEW_DOMINANT_WARNING = 0.95 +_OVERVIEW_HIGH_CARDINALITY = 50 +_OVERVIEW_ID_UNIQUE_RATIO = 0.9 +_OVERVIEW_CAT_TOP_N = 5 +_OVERVIEW_MAX_FEATURE_PROFILES = 500 +_OVERVIEW_MAX_NUMERICAL_SUMMARY_COLS = 500 +_OVERVIEW_MAX_CATEGORICAL_DIST_COLS = 50 +_READINESS_MAX_DETAIL_LIST_ITEMS = 50 +_PROFILE_STATUS_RANK = {"poor": 0, "warning": 1, "good": 2} + + +def _cap_detail_list(items, max_items): + """Return a capped list plus metadata for UI/PDF truncation notes.""" + total = len(items) + if total <= max_items: + return items, {"total": total, "shown": total, "truncated": False} + return items[:max_items], { + "total": total, + "shown": max_items, + "truncated": True, + } + + +def _build_numerical_summary_for_overview(num_df): + """Build describe() summary for overview details, capped on wide datasets.""" + if num_df.empty: + return {}, {"total": 0, "shown": 0, "truncated": False} + + total = len(num_df.columns) + cols = num_df.columns + truncated = total > _OVERVIEW_MAX_NUMERICAL_SUMMARY_COLS + if truncated: + cols = cols[:_OVERVIEW_MAX_NUMERICAL_SUMMARY_COLS] + + numerical_summary = num_df[cols].describe().to_dict() + for v in numerical_summary.values(): + for old_key in list(v.keys()): + if old_key in ["25%", "50%", "75%"]: + new_key = old_key.replace("%", "th percentile") + v[new_key] = float(v.pop(old_key)) + for stat_key, stat_val in list(v.items()): + if stat_val is not None and not isinstance(stat_val, str): + v[stat_key] = float(stat_val) + + return numerical_summary, { + "total": total, + "shown": len(cols), + "truncated": truncated, + } + + +def _classify_feature_type(series): + """Map a pandas Series to a coarse feature type label.""" + if pd.api.types.is_bool_dtype(series): + return "boolean" + if pd.api.types.is_datetime64_any_dtype(series): + return "datetime" + if pd.api.types.is_numeric_dtype(series): + return "numerical" + return "categorical" + + +def _feature_readiness_status(pct_missing, n_unique, n_rows, feat_type, pct_dominant): + """Derive a per-feature readiness status from profile statistics.""" + if n_unique <= 1: + return "poor" + if pct_missing is not None and pct_missing > _OVERVIEW_MISSING_POOR: + return "poor" + if n_rows > 0 and n_unique / n_rows >= _OVERVIEW_ID_UNIQUE_RATIO: + return "poor" + if pct_missing is not None and pct_missing > _OVERVIEW_MISSING_WARNING: + return "warning" + if feat_type == "categorical" and n_unique > _OVERVIEW_HIGH_CARDINALITY: + return "warning" + if pct_dominant is not None and pct_dominant > _OVERVIEW_DOMINANT_WARNING: + return "warning" + return "good" + + +def _feature_summary(series, feat_type, n_unique): + """Build a compact, type-specific summary string for one feature.""" + non_null = series.dropna() + if len(non_null) == 0: + return "all missing" + + if feat_type == "numerical": + mean = non_null.mean() + std = non_null.std() + if pd.notna(std) and std > 0: + return f"mean {mean:.2g}, std {std:.2g}" + return f"min {non_null.min():.2g} – max {non_null.max():.2g}" + + if feat_type == "categorical": + vc = non_null.value_counts(normalize=True) + top_val = vc.index[0] + top_pct = vc.iloc[0] * 100 + return f"{top_val} ({top_pct:.0f}%), {n_unique} categories" + + if feat_type == "datetime": + return f"{non_null.min()} – {non_null.max()}" + + if feat_type == "boolean": + true_pct = (non_null.astype(bool)).mean() * 100 + return f"True {true_pct:.0f}%, False {100 - true_pct:.0f}%" + + return f"{n_unique} unique values" + + +def _build_feature_profiles(df): + """Compute a per-column readiness profile for every feature in *df*.""" + n_rows = len(df) + profiles = [] + type_counts = {"numerical": 0, "categorical": 0, "datetime": 0, "boolean": 0} + + for col in df.columns: + series = df[col] + feat_type = _classify_feature_type(series) + type_counts[feat_type] = type_counts.get(feat_type, 0) + 1 + + n_missing = int(series.isnull().sum()) + pct_missing = round(n_missing / n_rows, 4) if n_rows else 0.0 + n_unique = int(series.nunique(dropna=True)) + + pct_dominant = None + non_null = series.dropna() + if len(non_null) > 0: + pct_dominant = round( + float(non_null.value_counts(normalize=True).iloc[0]), 4 + ) + + profiles.append({ + "feature": str(col), + "type": feat_type, + "dtype": str(series.dtype), + "pct_missing": pct_missing, + "n_unique": n_unique, + "pct_dominant": pct_dominant, + "status": _feature_readiness_status( + pct_missing, n_unique, n_rows, feat_type, pct_dominant + ), + "summary": _feature_summary(series, feat_type, n_unique), + }) + + return profiles, type_counts + + +def _prepare_feature_profiles_for_display( + profiles, max_profiles=_OVERVIEW_MAX_FEATURE_PROFILES +): + """Cap profile rows for the report table, prioritizing poor then warning then good.""" + status_counts = {"poor": 0, "warning": 0, "good": 0} + for profile in profiles: + status = profile.get("status", "good") + if status in status_counts: + status_counts[status] += 1 + + total = len(profiles) + meta = { + "total": total, + "shown": total, + "max": max_profiles, + "truncated": False, + "status_counts": status_counts, + } + if total <= max_profiles: + return profiles, meta + + ranked = sorted( + enumerate(profiles), + key=lambda item: ( + _PROFILE_STATUS_RANK.get(item[1].get("status"), 99), + item[0], + ), + ) + meta["shown"] = max_profiles + meta["truncated"] = True + return [profile for _, profile in ranked[:max_profiles]], meta + + +def _dataframe_for_overview_detail_charts(df, display_profiles): + """Subset *df* to capped profile-table features for overview detail charts.""" + cols = [p["feature"] for p in display_profiles if p["feature"] in df.columns] + if not cols: + return df.iloc[:, :0] + return df[cols] + + +def _build_categorical_distributions(df, top_n=_OVERVIEW_CAT_TOP_N, max_columns=None): + """Top-*n* value counts (with percentages) for each categorical column.""" + distributions = {} + cat_cols = [ + col for col in df.columns if _classify_feature_type(df[col]) == "categorical" + ] + total_cat = len(cat_cols) + truncated = False + if max_columns is not None and total_cat > max_columns: + cat_cols = cat_cols[:max_columns] + truncated = True + for col in cat_cols: + vc = df[col].value_counts(dropna=True) + total = len(df[col].dropna()) + if total == 0: + distributions[str(col)] = [] + continue + entries = [] + for val, count in vc.head(top_n).items(): + entries.append({ + "value": str(val), + "count": int(count), + "pct": round(float(count) / total, 4), + }) + distributions[str(col)] = entries + meta = { + "total": total_cat, + "shown": len(cat_cols), + "truncated": truncated, + } + return distributions, meta + + +def _build_dataset_overview_section(file_info, include_visualizations=False): + """Build the dataset-overview portion of the readiness report. + + Returns file metadata, per-feature readiness profiles, numerical describe() + summary, categorical top-value distributions, and numerical histograms. + """ + file_path, file_name, file_type = file_info + df = read_file(file_info) + if hasattr(df, "columns"): + df.columns = [str(c) for c in df.columns] + + n_rows = len(df) + profiles, type_counts = _build_feature_profiles(df) + display_profiles, profile_meta = _prepare_feature_profiles_for_display(profiles) + + file_size_bytes = None + if file_path and os.path.exists(file_path): + try: + file_size_bytes = os.path.getsize(file_path) + except OSError: + pass + + memory_bytes = int(df.memory_usage(deep=True).sum()) + + numerical_summary, numerical_summary_meta = _build_numerical_summary_for_overview( + df.select_dtypes(include="number") + ) + categorical_distributions, categorical_distributions_meta = ( + _build_categorical_distributions( + df, max_columns=_OVERVIEW_MAX_CATEGORICAL_DIST_COLS + ) + ) + + overview = { + "file_metadata": { + "file_name": file_name, + "file_type": file_type, + "file_size_bytes": file_size_bytes, + "memory_bytes": memory_bytes, + "rows": n_rows, + "columns": len(df.columns), + "numerical_count": type_counts.get("numerical", 0), + "categorical_count": type_counts.get("categorical", 0), + "datetime_count": type_counts.get("datetime", 0), + "boolean_count": type_counts.get("boolean", 0), + }, + "feature_profiles": display_profiles, + "feature_profiles_meta": profile_meta, + "numerical_summary": numerical_summary, + "numerical_summary_meta": numerical_summary_meta, + "categorical_distributions": categorical_distributions, + "categorical_distributions_meta": categorical_distributions_meta, + "profile_thresholds": { + "missing_warning": _OVERVIEW_MISSING_WARNING, + "missing_poor": _OVERVIEW_MISSING_POOR, + "dominant_warning": _OVERVIEW_DOMINANT_WARNING, + "high_cardinality": _OVERVIEW_HIGH_CARDINALITY, + "id_unique_ratio": _OVERVIEW_ID_UNIQUE_RATIO, + }, + "visualizations_deferred": not include_visualizations, + } + if include_visualizations: + chart_df = _dataframe_for_overview_detail_charts(df, display_profiles) + overview["categorical_charts"] = categorical_distribution_charts(chart_df) + overview["histograms"] = summary_histograms(chart_df, figsize=(7, 4.5)) + return overview + + +def _build_data_quality_section(file_info, include_visualizations=False): + """Compute the data-quality portion of the readiness report. + + Runs completeness, outliers, and duplicity (the same functions backing the + Data Quality tab), then derives readiness-oriented KPIs (normalized so that + higher is always better), an overall grade, and a "needs attention" list. + + Returns a JSON-serializable dict, or ``{"error": str}`` on failure. + """ + section = {} + + # --- Completeness ----------------------------------------------------- + compl = completeness(file_info, include_visualization=include_visualizations) + compl_scores = compl.get("Completeness scores", {}) or {} + overall_completeness = compl.get("Overall Completeness") + + # --- Outliers --------------------------------------------------------- + out = outliers(file_info, include_visualization=include_visualizations) + out_scores_raw = out.get("Outlier scores", {}) if isinstance(out, dict) else {} + overall_outlier = None + out_scores = {} + if isinstance(out_scores_raw, dict): + overall_outlier = out_scores_raw.get("Overall outlier score") + out_scores = { + k: v for k, v in out_scores_raw.items() if k != "Overall outlier score" + } + outliers_error = out.get("Error") if isinstance(out, dict) else None + + # --- Duplicity -------------------------------------------------------- + dup = duplicity(file_info) + overall_duplicity = ( + dup.get("Duplicity scores", {}).get("Overall duplicity of the dataset") + if isinstance(dup, dict) + else None + ) + + # --- Normalized KPIs (higher = better) -------------------------------- + completeness_kpi = overall_completeness + uniqueness_kpi = (1 - overall_duplicity) if overall_duplicity is not None else None + outlier_clean_kpi = (1 - overall_outlier) if overall_outlier is not None else None + + kpis = [ + { + "id": "completeness", + "label": "Completeness", + "value": completeness_kpi, + "status": _grade_label(completeness_kpi), + "hint": "Share of non-missing values across the dataset.", + }, + { + "id": "uniqueness", + "label": "Uniqueness", + "value": uniqueness_kpi, + "status": _grade_label(uniqueness_kpi), + "hint": "1 − proportion of duplicate rows.", + }, + { + "id": "outlier_cleanliness", + "label": "Outlier-cleanliness", + "value": outlier_clean_kpi, + "status": _grade_label(outlier_clean_kpi), + "hint": "1 − mean outlier proportion (IQR method) across numerical features.", + }, + ] + + present = [k["value"] for k in kpis if k["value"] is not None] + grade = sum(present) / len(present) if present else None + + # --- Needs attention -------------------------------------------------- + incomplete = sorted( + ( + {"feature": col, "completeness": score} + for col, score in compl_scores.items() + if isinstance(score, (int, float)) and score < 1.0 + ), + key=lambda x: x["completeness"], + ) + high_outliers = sorted( + ( + {"feature": col, "outlier_proportion": score} + for col, score in out_scores.items() + if isinstance(score, (int, float)) and score > 0 + ), + key=lambda x: x["outlier_proportion"], + reverse=True, + ) + + section = { + "grade": grade, + "grade_status": _grade_label(grade), + "auto_selection": { + "selection_criteria": { + "analysis_scope": { + "rule": ( + "Completeness, outliers, and duplicity run on the full dataset " + "automatically — no column selection required." + ), + "selected": "all columns", + }, + }, + }, + "kpis": kpis, + "needs_attention": { + "incomplete_features": incomplete, + "outlier_features": high_outliers, + "duplicate_rows": ( + overall_duplicity if overall_duplicity not in (None, 0) else 0 + ), + }, + "details": { + "completeness": { + "overall": overall_completeness, + "scores": compl_scores, + "visualization": compl.get("Completeness Visualization") if include_visualizations else None, + "visualization_deferred": not include_visualizations, + }, + "outliers": { + "overall": overall_outlier, + "scores": out_scores, + "visualization": out.get("Outliers Visualization") if include_visualizations and isinstance(out, dict) else None, + "visualization_deferred": not include_visualizations, + "error": outliers_error, + }, + "duplicity": {"overall": overall_duplicity}, + }, + "visualizations_deferred": not include_visualizations, + } + return section + + +# Impact-on-AI tuning constants +_CORR_MAX_COLUMNS = 25 # cap analysed columns to keep heatmaps readable +_CORR_HIGH_CARD_MAX = 50 # drop categorical columns with more unique values +_CORR_ID_UNIQUE_RATIO = 0.9 # drop categorical columns that look like IDs +_CORR_REDUNDANT_THRESHOLD = 0.8 # |score| at/above which a pair is "redundant" +_CORR_LEAKAGE_THRESHOLD = 0.95 # |score| at/above which a pair is "leakage risk" +_CORR_ISOLATED_THRESHOLD = 0.1 # max |score| below which a feature is "isolated" + + +def _prune_columns_for_corr(df): + """Select columns worth feeding into the all-pairs correlation analysis. + + Drops columns that are useless or pathological for correlation: + constants, ID-like / high-cardinality categoricals. Numerical columns are + always kept (they are cheap to correlate). The result is capped at + ``_CORR_MAX_COLUMNS`` (numerical prioritized) to keep the computation and + heatmaps tractable. + + Returns ``(kept_columns, dropped)`` where *dropped* is a list of + ``{"feature": str, "reason": str}``. + """ + n_rows = max(len(df), 1) + numeric_cols = list(df.select_dtypes(exclude=["object", "string", "category"]).columns) + categorical_cols = list(df.select_dtypes(include=["object", "string", "category"]).columns) + + kept_numeric = [] + kept_categorical = [] + dropped = [] + + for col in numeric_cols: + if df[col].nunique(dropna=True) <= 1: + dropped.append({"feature": col, "reason": "constant column"}) + else: + kept_numeric.append(col) + + for col in categorical_cols: + nunique = df[col].nunique(dropna=True) + if nunique <= 1: + dropped.append({"feature": col, "reason": "constant column"}) + elif nunique / n_rows >= _CORR_ID_UNIQUE_RATIO: + dropped.append({"feature": col, "reason": "ID-like (near-unique values)"}) + elif nunique > _CORR_HIGH_CARD_MAX: + dropped.append({"feature": col, "reason": f"high cardinality ({nunique} categories)"}) + else: + kept_categorical.append(col) + + # Cap total columns, prioritizing numerical features + kept = kept_numeric + kept_categorical + if len(kept) > _CORR_MAX_COLUMNS: + for col in kept[_CORR_MAX_COLUMNS:]: + dropped.append({"feature": col, "reason": "exceeded column cap"}) + kept = kept[:_CORR_MAX_COLUMNS] + + return kept, dropped + + +def _pairwise_signals(scores): + """Derive readiness signals from a flat ``{"a vs b": score}`` mapping. + + Collapses the symmetric/asymmetric directional entries into one record per + unordered pair (keeping the largest-magnitude score), then classifies pairs + as redundant or leakage-risk and flags features that are not meaningfully + related to anything else ("isolated"). + """ + pair_max = {} + for key, val in scores.items(): + if " vs " not in key or not isinstance(val, (int, float)): + continue + a, b = key.split(" vs ", 1) + if a == b: + continue + ukey = tuple(sorted([a, b])) + abs_score = abs(val) + if ukey not in pair_max or abs_score > pair_max[ukey]["abs_score"]: + pair_max[ukey] = { + "a": ukey[0], + "b": ukey[1], + "score": round(float(val), 3), + "abs_score": abs_score, + } + + pairs = list(pair_max.values()) + pairs.sort(key=lambda p: p["abs_score"], reverse=True) + + leakage = [ + {"a": p["a"], "b": p["b"], "score": p["score"]} + for p in pairs + if p["abs_score"] >= _CORR_LEAKAGE_THRESHOLD + ] + redundant = [ + {"a": p["a"], "b": p["b"], "score": p["score"]} + for p in pairs + if _CORR_REDUNDANT_THRESHOLD <= p["abs_score"] < _CORR_LEAKAGE_THRESHOLD + ] + top = [{"a": p["a"], "b": p["b"], "score": p["score"]} for p in pairs[:8]] + + # Per-feature connectivity: the strongest relationship each feature has + connectivity = {} + for p in pairs: + connectivity[p["a"]] = max(connectivity.get(p["a"], 0.0), p["abs_score"]) + connectivity[p["b"]] = max(connectivity.get(p["b"], 0.0), p["abs_score"]) + isolated = sorted( + f for f, c in connectivity.items() if c < _CORR_ISOLATED_THRESHOLD + ) + + return { + "redundant": redundant, + "leakage": leakage, + "top": top, + "isolated": isolated, + } + + +def _build_impact_on_ai_section(file_info, include_visualizations=False): + """Compute the Impact-on-AI portion of the readiness report. + + Runs an automated, non-interactive all-pairs correlation analysis (numerical + via vectorized pandas correlation, categorical via Theil's U) over a pruned, + capped set of columns, then derives redundancy / leakage / isolation signals. + + Returns a JSON-serializable dict, or ``{"error": str}`` on failure. + """ + df = read_file(file_info) + if hasattr(df, "columns"): + df.columns = [str(c) for c in df.columns] + + kept, dropped = _prune_columns_for_corr(df) + dropped_capped, dropped_meta = _cap_detail_list( + dropped, _READINESS_MAX_DETAIL_LIST_ITEMS + ) + if len(kept) < 2: + return { + "error": "Not enough usable columns for correlation analysis after pruning.", + "columns_analyzed": len(kept), + "columns_dropped": dropped_capped, + } + + corr = calc_correlations(kept, file_info, include_visualization=include_visualizations) + if isinstance(corr, dict) and "Message" in corr: + return {"error": corr["Message"], "columns_dropped": dropped_capped} + + scores = corr.get("Correlation Scores", {}) if isinstance(corr, dict) else {} + signals = _pairwise_signals(scores) + cat = corr.get("Correlations Analysis Categorical", {}) or {} + num = corr.get("Correlations Analysis Numerical", {}) or {} + + leakage_pairs = signals["leakage"] + redundant_pairs = signals["redundant"] + isolated_features = signals["isolated"] + top_pairs = signals["top"] + n_kept = len(kept) + + leakage_kpi = 1.0 if not leakage_pairs else max(0.0, 1.0 - len(leakage_pairs) / 5.0) + redundancy_kpi = max(0.0, 1.0 - min(len(redundant_pairs) / 10.0, 1.0)) + informativeness_kpi = ( + max(0.0, 1.0 - len(isolated_features) / n_kept) if n_kept else None + ) + + kpis = [ + { + "id": "leakage_safety", + "label": "Leakage safety", + "value": leakage_kpi, + "status": "good" if not leakage_pairs else ("warning" if len(leakage_pairs) < 3 else "poor"), + "hint": ( + f"No pairs with |score| ≥ {_CORR_LEAKAGE_THRESHOLD}; " + f"{len(leakage_pairs)} leakage-risk pair(s) found." + ), + "raw_count": len(leakage_pairs), + }, + { + "id": "redundancy", + "label": "Redundancy", + "value": redundancy_kpi, + "status": _grade_label(redundancy_kpi), + "hint": ( + f"1 − min(redundant pairs / 10, 1); " + f"{len(redundant_pairs)} pair(s) with |score| ≥ {_CORR_REDUNDANT_THRESHOLD}." + ), + "raw_count": len(redundant_pairs), + }, + { + "id": "informativeness", + "label": "Informativeness", + "value": informativeness_kpi, + "status": _grade_label(informativeness_kpi), + "hint": ( + f"Share of analyzed features with a strong relationship " + f"(max |score| ≥ {_CORR_ISOLATED_THRESHOLD})." + ), + "raw_count": len(isolated_features), + }, + ] + + present = [k["value"] for k in kpis if k["value"] is not None] + grade = sum(present) / len(present) if present else None + + return { + "grade": grade, + "grade_status": _grade_label(grade), + "columns_analyzed": n_kept, + "auto_selection": { + "columns_selected": kept, + "selection_criteria": { + "columns_analyzed": { + "rule": ( + f"Prune constants, ID-like categoricals (unique ratio ≥ " + f"{_CORR_ID_UNIQUE_RATIO}), and high-cardinality categoricals " + f"(>{_CORR_HIGH_CARD_MAX} categories); cap at {_CORR_MAX_COLUMNS} " + "columns (numerical prioritized)." + ), + "selected": kept, + "excluded": dropped_capped, + "excluded_meta": dropped_meta, + }, + "thresholds": { + "redundant_threshold": _CORR_REDUNDANT_THRESHOLD, + "leakage_threshold": _CORR_LEAKAGE_THRESHOLD, + "isolated_threshold": _CORR_ISOLATED_THRESHOLD, + }, + }, + }, + "kpis": kpis, + "needs_attention": { + "leakage_pairs": leakage_pairs, + "redundant_pairs": redundant_pairs, + "isolated_features": isolated_features, + }, + "top_pairs": top_pairs, + "columns_dropped": dropped_capped, + "redundant_pairs": redundant_pairs, + "leakage_pairs": leakage_pairs, + "isolated_features": isolated_features, + "details": { + "categorical_visualization": cat.get( + "Correlations Analysis Categorical Visualization" + ) if include_visualizations else None, + "numerical_visualization": num.get( + "Correlations Analysis Numerical Visualization" + ) if include_visualizations else None, + "numerical_method": num.get("Method"), + "visualizations_deferred": not include_visualizations, + }, + "visualizations_deferred": not include_visualizations, + } + + +# Fairness & Bias tuning constants +_FAIRNESS_SENSITIVE_MIN_UNIQUE = 2 +_FAIRNESS_SENSITIVE_MAX_UNIQUE = 30 +_FAIRNESS_MAX_SENSITIVE_COLS = 5 +_FAIRNESS_ID_UNIQUE_RATIO = 0.9 +_FAIRNESS_TARGET_MAX_UNIQUE = 30 +_FAIRNESS_REP_RATIO_FLAG = 3.0 # probability ratio at/above which representation is flagged +_FAIRNESS_MINORITY_SHARE = 0.05 # class share below which a label is "minority" +_FAIRNESS_IMBALANCE_GOOD = 0.5 # imbalance degree below this → good +_FAIRNESS_IMBALANCE_WARNING = 1.0 # below this → warning, else poor +_FAIRNESS_TSD_FLAG = 0.15 # TSD above this → outcome-rate disparity flagged +_FAIRNESS_SENSITIVE_NAME_HINTS = ( + "gender", "sex", "race", "ethnic", "age", "religion", "marital", + "disability", "national", "origin", "minority", +) +_FAIRNESS_TARGET_NAME_HINTS = ( + "label", "target", "class", "outcome", "decision", "y", "income", +) + + +def _fairness_name_hint_score(col_name, hints): + """Return a higher score when *col_name* matches an earlier hint substring.""" + lower = col_name.lower() + best = 0 + for i, hint in enumerate(hints): + if hint in lower: + best = max(best, len(hints) - i) + return best + + +def _auto_select_fairness_columns(df): + """Pick sensitive attributes and a target column for automated fairness checks. + + Returns a dict with selected columns, exclusion reasons, and the + auto-selected positive class (mode of the target) for CDD. + """ + n_rows = max(len(df), 1) + cat_cols = list(df.select_dtypes(include=["object", "string", "category"]).columns) + + sensitive_candidates = [] + excluded_sensitive = [] + + for col in cat_cols: + nunique = df[col].nunique(dropna=True) + if nunique < _FAIRNESS_SENSITIVE_MIN_UNIQUE: + excluded_sensitive.append({"feature": col, "reason": "constant column"}) + elif nunique > _FAIRNESS_SENSITIVE_MAX_UNIQUE: + excluded_sensitive.append({ + "feature": col, + "reason": f"high cardinality ({nunique} categories, max {_FAIRNESS_SENSITIVE_MAX_UNIQUE})", + }) + elif nunique / n_rows >= _FAIRNESS_ID_UNIQUE_RATIO: + excluded_sensitive.append({"feature": col, "reason": "ID-like (near-unique values)"}) + else: + sensitive_candidates.append({ + "feature": col, + "nunique": int(nunique), + "name_hint_score": _fairness_name_hint_score(col, _FAIRNESS_SENSITIVE_NAME_HINTS), + }) + + sensitive_candidates.sort( + key=lambda c: (-c["name_hint_score"], c["nunique"], c["feature"]) + ) + selected_sensitive = [c["feature"] for c in sensitive_candidates[:_FAIRNESS_MAX_SENSITIVE_COLS]] + for c in sensitive_candidates[_FAIRNESS_MAX_SENSITIVE_COLS:]: + excluded_sensitive.append({"feature": c["feature"], "reason": "exceeded sensitive-column cap"}) + + # Target: low-cardinality columns (2–30 unique), prefer name hints; exclude sensitives + target_candidates = [] + sensitive_set = set(selected_sensitive) + for col in df.columns: + if col in sensitive_set: + continue + nunique = df[col].nunique(dropna=True) + if _FAIRNESS_SENSITIVE_MIN_UNIQUE <= nunique <= _FAIRNESS_TARGET_MAX_UNIQUE: + target_candidates.append({ + "feature": col, + "nunique": int(nunique), + "name_hint_score": _fairness_name_hint_score(col, _FAIRNESS_TARGET_NAME_HINTS), + }) + + target_candidates.sort( + key=lambda c: (-c["name_hint_score"], c["nunique"], c["feature"]) + ) + target_col = target_candidates[0]["feature"] if target_candidates else None + target_reason = None + if target_col: + tc = target_candidates[0] + if tc["name_hint_score"] > 0: + target_reason = "matched target name hint" + elif tc == target_candidates[-1] and len(target_candidates) == 1: + target_reason = "only eligible low-cardinality column" + else: + target_reason = "lowest-cardinality eligible column (after name-hint priority)" + + positive_class = None + positive_reason = None + if target_col is not None: + target_series = df[target_col].dropna() + if len(target_series) > 0: + positive_class = target_series.mode().iloc[0] + positive_reason = "most frequent class in auto-selected target" + + primary_sensitive = selected_sensitive[0] if selected_sensitive else None + + excluded_capped, excluded_meta = _cap_detail_list( + excluded_sensitive, _READINESS_MAX_DETAIL_LIST_ITEMS + ) + + return { + "sensitive_columns": selected_sensitive, + "primary_sensitive": primary_sensitive, + "target_column": target_col, + "positive_class": positive_class, + "selection_criteria": { + "sensitive_attributes": { + "rule": ( + f"Categorical columns with {_FAIRNESS_SENSITIVE_MIN_UNIQUE}–" + f"{_FAIRNESS_SENSITIVE_MAX_UNIQUE} unique values, excluding " + "ID-like columns; prefer name hints " + f"{list(_FAIRNESS_SENSITIVE_NAME_HINTS)}; " + f"capped at {_FAIRNESS_MAX_SENSITIVE_COLS}." + ), + "name_hints": list(_FAIRNESS_SENSITIVE_NAME_HINTS), + "selected": selected_sensitive, + "excluded": excluded_capped, + "excluded_meta": excluded_meta, + }, + "target_column": { + "rule": ( + f"Columns with {_FAIRNESS_SENSITIVE_MIN_UNIQUE}–{_FAIRNESS_TARGET_MAX_UNIQUE} " + "unique values, excluding auto-selected sensitive columns; " + "prefer name hints " + f"{list(_FAIRNESS_TARGET_NAME_HINTS)}." + ), + "name_hints": list(_FAIRNESS_TARGET_NAME_HINTS), + "selected": target_col, + "reason": target_reason, + }, + "positive_class": { + "rule": "Most frequent value in the auto-selected target (used for CDD only).", + "selected": str(positive_class) if positive_class is not None else None, + "reason": positive_reason, + }, + "thresholds": { + "representation_ratio_flag": _FAIRNESS_REP_RATIO_FLAG, + "minority_class_share": _FAIRNESS_MINORITY_SHARE, + "imbalance_degree_good": _FAIRNESS_IMBALANCE_GOOD, + "imbalance_degree_warning": _FAIRNESS_IMBALANCE_WARNING, + "tsd_disparity_flag": _FAIRNESS_TSD_FLAG, + }, + }, + } + + +def _parse_representation_flags(ratios_dict): + """Extract per-column max probability ratio and flag extreme imbalance.""" + by_column = {} + if not isinstance(ratios_dict, dict) or "Error" in ratios_dict: + return [], None, ratios_dict.get("Error") if isinstance(ratios_dict, dict) else None + + for key, ratio in ratios_dict.items(): + if not isinstance(ratio, (int, float)) or "Column: '" not in key: + continue + # Key format: Column: 'col', Probability ratio for 'A' to 'B' + try: + col_part = key.split("Column: '", 1)[1] + col = col_part.split("',", 1)[0] + except IndexError: + continue + entry = by_column.setdefault(col, {"column": col, "max_ratio": 0.0, "flagged_pairs": []}) + r = float(ratio) + if r > entry["max_ratio"]: + entry["max_ratio"] = round(r, 3) + if r >= _FAIRNESS_REP_RATIO_FLAG: + pair_part = key.split("Probability ratio for ", 1)[-1] + entry["flagged_pairs"].append({"pair": pair_part, "ratio": round(r, 3)}) + + summaries = sorted(by_column.values(), key=lambda x: x["max_ratio"], reverse=True) + worst = summaries[0]["max_ratio"] if summaries else None + rep_balance_kpi = min(1.0, 1.0 / worst) if worst and worst > 0 else None + return summaries, rep_balance_kpi, None + + +def _imbalance_status(id_score): + if id_score is None: + return "unknown" + if id_score < _FAIRNESS_IMBALANCE_GOOD: + return "good" + if id_score < _FAIRNESS_IMBALANCE_WARNING: + return "warning" + return "poor" + + +def _build_fairness_bias_section(file_info, include_visualizations=False): + """Compute the Fairness & Bias portion of the readiness report. + + Auto-selects sensitive attributes and a target, then runs representation + rate, class imbalance, statistical rate, and conditional demographic + disparity using the same functions as the Fairness & Bias tab. + """ + df = read_file(file_info) + if hasattr(df, "columns"): + df.columns = [str(c) for c in df.columns] + + selection = _auto_select_fairness_columns(df) + sensitive_cols = selection["sensitive_columns"] + target_col = selection["target_column"] + primary_sensitive = selection["primary_sensitive"] + positive_class = selection["positive_class"] + + kpis = [] + needs_attention = { + "representation_imbalance": [], + "minority_classes": [], + "outcome_disparities": [], + "cdd_disparities": [], + } + details = {} + + # --- Representation Rate (all selected sensitive columns) --------------- + rep_error = None + rep_balance_kpi = None + if sensitive_cols: + ratios = calculate_representation_rate(sensitive_cols, file_info) + rep_summaries, rep_balance_kpi, rep_error = _parse_representation_flags(ratios) + needs_attention["representation_imbalance"] = [ + s for s in rep_summaries if s["max_ratio"] >= _FAIRNESS_REP_RATIO_FLAG + ] + rep_visualizations = {} + if include_visualizations: + for col in sensitive_cols: + try: + vis = create_representation_rate_vis([col], file_info) + if isinstance(vis, str): + rep_visualizations[col] = vis + except Exception: + pass + details["representation_rate"] = { + "ratios": ratios if not rep_error else None, + "summaries": rep_summaries, + "visualizations": rep_visualizations, + "visualizations_deferred": not include_visualizations, + "error": rep_error, + } + else: + details["representation_rate"] = { + "error": "No eligible sensitive-attribute columns found.", + } + + kpis.append({ + "id": "representation_balance", + "label": "Representation balance", + "value": rep_balance_kpi, + "status": _grade_label(rep_balance_kpi), + "hint": f"1 / worst group probability ratio (flagged when ratio ≥ {_FAIRNESS_REP_RATIO_FLAG}).", + }) + + # --- Class Imbalance (auto target) -------------------------------------- + imbalance_degree = None + ci_error = None + if target_col: + ci_dict = _compute_class_imbalance( + df, target_col, "EU", include_visualization=include_visualizations + ) + if "Error" in ci_dict: + ci_error = ci_dict["Error"] + else: + imb = ci_dict.get("Imbalance degree") or {} + imbalance_degree = imb.get("Imbalance Degree score") + details["class_imbalance"] = { + "visualization": ci_dict.get("Class Imbalance Visualization") if include_visualizations else None, + "visualization_deferred": not include_visualizations, + "imbalance_degree": imbalance_degree, + } + # Minority classes + vc = df[target_col].value_counts(normalize=True, dropna=True) + for cls, share in vc.items(): + if share < _FAIRNESS_MINORITY_SHARE: + needs_attention["minority_classes"].append({ + "target_column": target_col, + "class": str(cls), + "share": round(float(share), 4), + }) + else: + details["class_imbalance"] = {"error": "No eligible target column found."} + + label_balance_kpi = ( + max(0.0, 1.0 - min(float(imbalance_degree) / 2.0, 1.0)) + if imbalance_degree is not None + else None + ) + kpis.append({ + "id": "label_balance", + "label": "Label balance", + "value": label_balance_kpi, + "status": _imbalance_status(imbalance_degree), + "hint": ( + f"Derived from Imbalance Degree (EU); 0 = balanced. " + f"Good < {_FAIRNESS_IMBALANCE_GOOD}, warning < {_FAIRNESS_IMBALANCE_WARNING}." + ), + "raw_imbalance_degree": imbalance_degree, + }) + + # --- Statistical Rate (primary sensitive + target) ---------------------- + disparity_kpi = None + if primary_sensitive and target_col: + sr = calculate_statistical_rates( + target_col, primary_sensitive, file_info, + include_visualization=include_visualizations, + ) + if isinstance(sr, dict) and "Error" in sr: + details["statistical_rate"] = {"error": sr["Error"]} + else: + tsd_scores = sr.get("TSD scores") or {} + flagged = [ + { + "target_column": target_col, + "sensitive_column": primary_sensitive, + "class": str(cls), + "tsd": round(float(score), 4), + } + for cls, score in tsd_scores.items() + if isinstance(score, (int, float)) and float(score) >= _FAIRNESS_TSD_FLAG + ] + flagged.sort(key=lambda x: x["tsd"], reverse=True) + needs_attention["outcome_disparities"] = flagged + max_tsd = max( + (float(v) for v in tsd_scores.values() if isinstance(v, (int, float))), + default=None, + ) + disparity_kpi = max(0.0, 1.0 - min(max_tsd, 1.0)) if max_tsd is not None else None + details["statistical_rate"] = { + "sensitive": primary_sensitive, + "target": target_col, + "tsd_scores": tsd_scores, + "visualization": sr.get("Statistical Rate Visualization") if include_visualizations else None, + "visualization_deferred": not include_visualizations, + } + else: + details["statistical_rate"] = { + "error": "Requires both a sensitive attribute and a target column.", + } + + kpis.append({ + "id": "outcome_parity", + "label": "Outcome parity", + "value": disparity_kpi, + "status": _grade_label(disparity_kpi), + "hint": ( + f"1 − max TSD across classes (flagged when TSD ≥ {_FAIRNESS_TSD_FLAG}). " + "Uses primary sensitive attribute." + ), + }) + + # --- Conditional Demographic Disparity ---------------------------------- + if primary_sensitive and target_col and positive_class is not None: + try: + cdd = conditional_demographic_disparity( + df[target_col].tolist(), + df[primary_sensitive].tolist(), + positive_class, + ) + if isinstance(cdd, dict) and "Error" in cdd: + details["cdd"] = {"error": cdd["Error"]} + else: + disparities = (cdd or {}).get("Disparities") or {} + cdd_flagged = [ + { + "sensitive_column": primary_sensitive, + "target_column": target_col, + "positive_class": str(positive_class), + "group": str(grp), + "disparity": info.get("disparity"), + } + for grp, info in disparities.items() + if str(info.get("disparity", "")).lower() == "true" + ] + needs_attention["cdd_disparities"] = cdd_flagged + details["cdd"] = { + "sensitive": primary_sensitive, + "target": target_col, + "positive_class": str(positive_class), + "disparities": disparities, + } + except Exception as e: + details["cdd"] = {"error": str(e)} + else: + details["cdd"] = { + "error": "Requires sensitive attribute, target, and auto-positive class.", + } + + present = [k["value"] for k in kpis if k["value"] is not None] + grade = sum(present) / len(present) if present else None + + return { + "grade": grade, + "grade_status": _grade_label(grade), + "auto_selection": selection, + "kpis": kpis, + "needs_attention": needs_attention, + "details": details, + "visualizations_deferred": not include_visualizations, + } + + +# --------------------------------------------------------------------------- +# Data Governance (readiness report) +# --------------------------------------------------------------------------- + +_GOV_QI_MIN_UNIQUE = 2 +_GOV_QI_MAX_UNIQUE = 50 +_GOV_QI_MAX_COUNT = 4 +_GOV_ID_UNIQUE_RATIO = 0.9 +_GOV_NUMERIC_QI_MAX_UNIQUE = 20 +_GOV_MM_QI_MAX_UNIQUE = 100 +_GOV_MISSING_MAX = 0.20 +_GOV_SENSITIVE_MIN_UNIQUE = 2 +_GOV_SENSITIVE_MAX_UNIQUE = 30 +_GOV_HIPAA_MAX_COLUMNS = 40 +_GOV_HIPAA_MISSING_MAX = 0.50 +_GOV_DP_MAX_FEATURES = 2 +_GOV_DP_EPSILON = 0.1 +_GOV_SMALL_SAMPLE = 30 +_GOV_WORST_GROUPS_MAX = 5 + +_GOV_K_GOOD = 5 +_GOV_K_WARNING = 2 +_GOV_L_GOOD = 3 +_GOV_L_WARNING = 2 +_GOV_T_GOOD = 0.10 +_GOV_T_WARNING = 0.20 +_GOV_MM_SINGLE_GOOD = 0.3 +_GOV_MM_SINGLE_WARNING = 0.6 +_GOV_MM_MULTI_GOOD = 0.5 +_GOV_MM_MULTI_WARNING = 0.8 + +_GOV_QI_NAME_HINTS = ( + "zip", "postal", "gender", "sex", "age", "race", "ethnic", "city", + "state", "country", "birth", "dob", "county", "region", "marital", +) +_GOV_SENSITIVE_NAME_HINTS = ( + "diagnosis", "disease", "condition", "salary", "income", "religion", + "health", "treatment", "medication", "outcome", "disability", +) +_GOV_ID_NAME_HINTS = ( + "id", "uuid", "guid", "index", "record", "patient", "user", "member", + "row", "case", +) +_GOV_HIPAA_NAME_HINTS = ( + "name", "address", "email", "phone", "ssn", "medical", "account", + "notes", "text", "comment", +) +_GOV_DP_NAME_HINTS = ( + "age", "income", "salary", "score", "amount", "value", "rate", "count", + "weight", "height", +) +_GOV_HIPAA_SERIOUS_TYPES = frozenset({ + "US_SSN", "MEDICAL_IDS", "VALID_POSTAL_CODE", +}) +_SYNTHETIC_ID_COL = "__aidrin_row_index__" + + +def _gov_name_hint_score(col_name, hints): + return _fairness_name_hint_score(col_name, hints) + + +def _column_pct_missing(series, n_rows): + if n_rows <= 0: + return 1.0 + return float(series.isna().sum()) / n_rows + + +def _column_entropy_norm(series): + """Normalized Shannon entropy of value counts in [0, 1].""" + vc = series.dropna().value_counts(normalize=True) + if len(vc) <= 1: + return 0.0 + ent = -sum(p * math.log2(p) for p in vc if p > 0) + max_ent = math.log2(len(vc)) + return ent / max_ent if max_ent > 0 else 0.0 + + +def _k_anonymity_status(k_val): + if k_val is None: + return "unknown" + if k_val >= _GOV_K_GOOD: + return "good" + if k_val >= _GOV_K_WARNING: + return "warning" + return "poor" + + +def _l_diversity_status(l_val): + if l_val is None: + return "unknown" + if l_val >= _GOV_L_GOOD: + return "good" + if l_val >= _GOV_L_WARNING: + return "warning" + return "poor" + + +def _t_closeness_status(t_val): + if t_val is None: + return "unknown" + if t_val <= _GOV_T_GOOD: + return "good" + if t_val <= _GOV_T_WARNING: + return "warning" + return "poor" + + +def _mm_risk_status(mean_risk, good=_GOV_MM_SINGLE_GOOD, warning=_GOV_MM_SINGLE_WARNING): + if mean_risk is None: + return "unknown" + if mean_risk < good: + return "good" + if mean_risk < warning: + return "warning" + return "poor" + + +def _hipaa_status(detected_phi): + if not detected_phi: + return "good", 1.0 + serious = any( + t in _GOV_HIPAA_SERIOUS_TYPES + for info in detected_phi.values() + for t in (info.get("potential_types_detected") or []) + ) + if serious: + return "poor", 0.0 + return "warning", 0.5 + + +def _worst_equivalence_classes(df, quasi_identifiers, max_groups=_GOV_WORST_GROUPS_MAX): + """Return the smallest equivalence classes on *quasi_identifiers*.""" + if not quasi_identifiers: + return [], 0 + + data = df.replace("?", pd.NA) + clean = data.dropna(subset=quasi_identifiers) + if clean.empty: + return [], 0 + + counts = clean.groupby(quasi_identifiers, dropna=False).size() + if counts.empty: + return [], 0 + + singleton_count = int((counts == 1).sum()) + worst_groups = [] + for keys, size in counts.nsmallest(max_groups).items(): + if len(quasi_identifiers) == 1: + key_tuple = (keys,) if not isinstance(keys, tuple) else keys + else: + key_tuple = keys if isinstance(keys, tuple) else (keys,) + qi_values = { + qi: str(val) if pd.notna(val) else "?" + for qi, val in zip(quasi_identifiers, key_tuple) + } + worst_groups.append({"size": int(size), "qi_values": qi_values}) + + return worst_groups, singleton_count + + +def _auto_select_governance_columns(df, fairness_target=None): + """Pick columns for automated privacy and HIPAA checks in the readiness report.""" + n_rows = max(len(df), 1) + excluded = [] + + # --- Sensitive attribute (before QIs so it can be excluded) ------------ + sensitive_candidates = [] + for col in df.columns: + feat_type = _classify_feature_type(df[col]) + if feat_type not in ("categorical", "boolean"): + continue + nunique = df[col].nunique(dropna=True) + pct_miss = _column_pct_missing(df[col], n_rows) + if nunique < _GOV_SENSITIVE_MIN_UNIQUE: + continue + if nunique > _GOV_SENSITIVE_MAX_UNIQUE: + excluded.append({ + "feature": col, "role": "sensitive", + "reason": f"high cardinality ({nunique} categories)", + }) + continue + if pct_miss > _GOV_MISSING_MAX: + excluded.append({ + "feature": col, "role": "sensitive", + "reason": f"high missingness ({pct_miss:.0%})", + }) + continue + hint = _gov_name_hint_score(col, _GOV_SENSITIVE_NAME_HINTS) + fairness_boost = 1 if fairness_target and col == fairness_target else 0 + sensitive_candidates.append({ + "feature": col, + "nunique": int(nunique), + "name_hint_score": hint + fairness_boost, + "entropy": _column_entropy_norm(df[col]), + }) + + sensitive_candidates.sort( + key=lambda c: (-c["name_hint_score"], -c["entropy"], c["feature"]) + ) + sensitive_col = ( + sensitive_candidates[0]["feature"] if sensitive_candidates else None + ) + + # --- ID column ----------------------------------------------------------- + id_candidates = [] + for col in df.columns: + if df[col].nunique(dropna=True) == n_rows and n_rows > 0: + id_candidates.append({ + "feature": col, + "name_hint_score": _gov_name_hint_score(col, _GOV_ID_NAME_HINTS), + }) + id_candidates.sort(key=lambda c: (-c["name_hint_score"], c["feature"])) + id_synthetic = not id_candidates + id_col = id_candidates[0]["feature"] if id_candidates else _SYNTHETIC_ID_COL + + # --- Quasi-identifiers --------------------------------------------------- + qi_candidates = [] + for col in df.columns: + if col == sensitive_col: + continue + if not id_synthetic and col == id_col: + continue + feat_type = _classify_feature_type(df[col]) + if feat_type == "datetime": + excluded.append({"feature": col, "role": "quasi-identifier", "reason": "datetime column"}) + continue + nunique = df[col].nunique(dropna=True) + pct_miss = _column_pct_missing(df[col], n_rows) + if nunique < _GOV_QI_MIN_UNIQUE: + excluded.append({"feature": col, "role": "quasi-identifier", "reason": "constant column"}) + continue + if pct_miss > _GOV_MISSING_MAX: + excluded.append({ + "feature": col, "role": "quasi-identifier", + "reason": f"high missingness ({pct_miss:.0%})", + }) + continue + if nunique / n_rows >= _GOV_ID_UNIQUE_RATIO: + excluded.append({"feature": col, "role": "quasi-identifier", "reason": "ID-like (near-unique values)"}) + continue + if feat_type in ("categorical", "boolean"): + if nunique > _GOV_QI_MAX_UNIQUE: + excluded.append({ + "feature": col, "role": "quasi-identifier", + "reason": f"high cardinality ({nunique} categories)", + }) + continue + elif feat_type == "numerical": + if nunique > _GOV_NUMERIC_QI_MAX_UNIQUE: + excluded.append({ + "feature": col, "role": "quasi-identifier", + "reason": f"continuous numeric ({nunique} unique values)", + }) + continue + else: + excluded.append({"feature": col, "role": "quasi-identifier", "reason": f"unsupported type ({feat_type})"}) + continue + + qi_candidates.append({ + "feature": col, + "nunique": int(nunique), + "feat_type": feat_type, + "name_hint_score": _gov_name_hint_score(col, _GOV_QI_NAME_HINTS), + }) + + qi_candidates.sort( + key=lambda c: (-c["name_hint_score"], c["nunique"], c["feature"]) + ) + quasi_identifiers = [c["feature"] for c in qi_candidates[:_GOV_QI_MAX_COUNT]] + for c in qi_candidates[_GOV_QI_MAX_COUNT:]: + excluded.append({"feature": c["feature"], "role": "quasi-identifier", "reason": "exceeded QI cap"}) + + mm_quasi_identifiers = [ + c["feature"] for c in qi_candidates[:_GOV_QI_MAX_COUNT] + if c["feat_type"] in ("categorical", "boolean") + and c["nunique"] <= _GOV_MM_QI_MAX_UNIQUE + ] + + # --- HIPAA scan columns -------------------------------------------------- + hipaa_candidates = [] + for col in df.columns: + nunique = df[col].nunique(dropna=True) + if nunique <= 1: + continue + pct_miss = _column_pct_missing(df[col], n_rows) + feat_type = _classify_feature_type(df[col]) + is_text = feat_type in ("categorical",) or str(df[col].dtype) in ("object", "string") + if is_text and pct_miss <= _GOV_HIPAA_MISSING_MAX: + avg_len = df[col].dropna().astype(str).str.len().mean() + hipaa_candidates.append({ + "feature": col, + "name_hint_score": _gov_name_hint_score(col, _GOV_HIPAA_NAME_HINTS), + "avg_len": avg_len if pd.notna(avg_len) else 0, + }) + + hipaa_candidates.sort( + key=lambda c: (-c["name_hint_score"], -c["avg_len"], c["feature"]) + ) + hipaa_scan_columns = [c["feature"] for c in hipaa_candidates[:_GOV_HIPAA_MAX_COLUMNS]] + if not hipaa_scan_columns: + hipaa_scan_columns = [ + col for col in df.columns if df[col].nunique(dropna=True) > 1 + ][: _GOV_HIPAA_MAX_COLUMNS] + + # --- DP numerical features ----------------------------------------------- + dp_candidates = [] + for col in df.columns: + if not pd.api.types.is_numeric_dtype(df[col]): + continue + nunique = df[col].nunique(dropna=True) + if nunique <= 1: + continue + if nunique / n_rows >= _GOV_ID_UNIQUE_RATIO: + continue + if _column_pct_missing(df[col], n_rows) > _GOV_MISSING_MAX: + continue + dp_candidates.append({ + "feature": col, + "name_hint_score": _gov_name_hint_score(col, _GOV_DP_NAME_HINTS), + "pct_missing": _column_pct_missing(df[col], n_rows), + "nunique": int(nunique), + }) + dp_candidates.sort( + key=lambda c: (-c["name_hint_score"], c["pct_missing"], c["nunique"], c["feature"]) + ) + dp_features = [c["feature"] for c in dp_candidates[:_GOV_DP_MAX_FEATURES]] + + qi_excluded_raw = [e for e in excluded if e["role"] == "quasi-identifier"] + sens_excluded_raw = [e for e in excluded if e["role"] == "sensitive"] + qi_excluded, qi_excluded_meta = _cap_detail_list( + qi_excluded_raw, _READINESS_MAX_DETAIL_LIST_ITEMS + ) + sens_excluded, sens_excluded_meta = _cap_detail_list( + sens_excluded_raw, _READINESS_MAX_DETAIL_LIST_ITEMS + ) + + return { + "quasi_identifiers": quasi_identifiers, + "mm_quasi_identifiers": mm_quasi_identifiers, + "sensitive_attribute": sensitive_col, + "id_column": id_col, + "id_synthetic": id_synthetic, + "hipaa_scan_columns": hipaa_scan_columns, + "dp_features": dp_features, + "dp_epsilon": _GOV_DP_EPSILON, + "selection_criteria": { + "quasi_identifiers": { + "rule": ( + f"Categorical/boolean with {_GOV_QI_MIN_UNIQUE}–{_GOV_QI_MAX_UNIQUE} " + f"unique values; discrete numeric with {_GOV_QI_MIN_UNIQUE}–" + f"{_GOV_NUMERIC_QI_MAX_UNIQUE}; exclude ID-like, datetime, " + f"and >{_GOV_MISSING_MAX:.0%} missing; prefer name hints; " + f"capped at {_GOV_QI_MAX_COUNT}." + ), + "name_hints": list(_GOV_QI_NAME_HINTS), + "selected": quasi_identifiers, + "excluded": qi_excluded, + "excluded_meta": qi_excluded_meta, + }, + "sensitive_attribute": { + "rule": ( + f"Categorical/boolean with {_GOV_SENSITIVE_MIN_UNIQUE}–" + f"{_GOV_SENSITIVE_MAX_UNIQUE} unique values, excluding QIs; " + "prefer sensitive name hints and fairness target when eligible." + ), + "name_hints": list(_GOV_SENSITIVE_NAME_HINTS), + "selected": sensitive_col, + "excluded": sens_excluded, + "excluded_meta": sens_excluded_meta, + }, + "id_column": { + "rule": ( + "Exact-unique column preferred (name hints: id, uuid, patient, …); " + "otherwise synthetic row index for linkage-risk scoring only." + ), + "selected": id_col, + "synthetic": id_synthetic, + }, + "hipaa_scan_columns": { + "rule": ( + f"Text-like columns up to {_GOV_HIPAA_MAX_COLUMNS}, prefer HIPAA " + "name hints; fallback to all non-constant columns." + ), + "name_hints": list(_GOV_HIPAA_NAME_HINTS), + "selected": hipaa_scan_columns, + }, + "dp_features": { + "rule": ( + f"Up to {_GOV_DP_MAX_FEATURES} numerical, non-ID-like columns " + f"(illustrative only, ε={_GOV_DP_EPSILON})." + ), + "selected": dp_features, + "epsilon": _GOV_DP_EPSILON, + }, + "thresholds": { + "k_good": _GOV_K_GOOD, + "k_warning": _GOV_K_WARNING, + "l_good": _GOV_L_GOOD, + "l_warning": _GOV_L_WARNING, + "t_good": _GOV_T_GOOD, + "t_warning": _GOV_T_WARNING, + "mm_single_good": _GOV_MM_SINGLE_GOOD, + "mm_single_warning": _GOV_MM_SINGLE_WARNING, + "mm_multi_good": _GOV_MM_MULTI_GOOD, + "mm_multi_warning": _GOV_MM_MULTI_WARNING, + "small_sample_rows": _GOV_SMALL_SAMPLE, + }, + }, + } + + +def _build_data_governance_section(file_info, include_visualizations=False): + """Compute the Data Governance portion of the readiness report.""" + df = read_file(file_info) + if hasattr(df, "columns"): + df.columns = [str(c) for c in df.columns] + + n_rows = len(df) + fairness_sel = _auto_select_fairness_columns(df) + selection = _auto_select_governance_columns( + df, fairness_target=fairness_sel.get("target_column") + ) + qi = selection["quasi_identifiers"] + mm_qis = selection["mm_quasi_identifiers"] + sensitive = selection["sensitive_attribute"] + id_col = selection["id_column"] + id_synthetic = selection["id_synthetic"] + + work_df = df.copy() + if id_synthetic: + work_df[_SYNTHETIC_ID_COL] = range(len(work_df)) + id_col = _SYNTHETIC_ID_COL + + kpis = [] + needs_attention = { + "low_anonymity": [], + "hipaa_phi": [], + "high_linkage_risk": [], + "attribute_disclosure": [], + } + details = {} + small_sample = n_rows < _GOV_SMALL_SAMPLE + + # --- k-Anonymity --------------------------------------------------------- + k_val = None + if qi: + k_res = compute_k_anonymity(qi, work_df, include_visualization=include_visualizations) + if "Error" not in k_res: + k_val = k_res.get("k-Value") + if k_val is not None and k_val < _GOV_K_WARNING: + worst_groups, singleton_count = _worst_equivalence_classes(work_df, qi) + needs_attention["low_anonymity"].append({ + "metric": "k-Anonymity", + "value": k_val, + "quasi_identifiers": list(qi), + "singleton_count": singleton_count, + "worst_groups": worst_groups, + "detail": ( + f"Minimum group size {k_val} on quasi-identifiers: " + f"{', '.join(qi)}" + ), + }) + details["k_anonymity"] = { + "quasi_identifiers": qi, + "k_value": k_val, + "descriptive_statistics": k_res.get("descriptive_statistics"), + "visualization": k_res.get("k-Anonymity Visualization") if include_visualizations else None, + "visualization_deferred": not include_visualizations, + } + else: + details["k_anonymity"] = {"error": k_res.get("Error")} + else: + details["k_anonymity"] = {"error": "No eligible quasi-identifier columns found."} + + k_kpi = min(float(k_val) / _GOV_K_GOOD, 1.0) if k_val is not None else None + kpis.append({ + "id": "anonymity_k", + "label": "Anonymity (k)", + "value": k_kpi, + "status": _k_anonymity_status(k_val), + "hint": f"k-Value ≥ {_GOV_K_GOOD} good, ≥ {_GOV_K_WARNING} warning (min group size on auto QIs).", + "raw_k": k_val, + }) + + # --- l-Diversity --------------------------------------------------------- + l_val = None + if qi and sensitive: + l_res = compute_l_diversity(qi, sensitive, work_df, include_visualization=include_visualizations) + if "Error" not in l_res: + l_val = l_res.get("l-Value") + if l_val is not None and l_val < _GOV_L_WARNING: + needs_attention["attribute_disclosure"].append({ + "metric": "l-Diversity", + "value": l_val, + "sensitive_attribute": sensitive, + "quasi_identifiers": list(qi), + "detail": ( + f"Sensitive '{sensitive}' has only {l_val} distinct value(s) " + f"in some groups defined by ({', '.join(qi)})" + ), + }) + details["l_diversity"] = { + "quasi_identifiers": qi, + "sensitive_attribute": sensitive, + "l_value": l_val, + "descriptive_statistics": l_res.get("descriptive_statistics"), + "visualization": l_res.get("l-Diversity Visualization") if include_visualizations else None, + "visualization_deferred": not include_visualizations, + } + else: + details["l_diversity"] = {"error": l_res.get("Error")} + else: + details["l_diversity"] = { + "error": "Requires quasi-identifiers and a sensitive attribute.", + } + + l_kpi = min(float(l_val) / _GOV_L_GOOD, 1.0) if l_val is not None else None + kpis.append({ + "id": "diversity_l", + "label": "Attribute diversity (l)", + "value": l_kpi, + "status": _l_diversity_status(l_val), + "hint": f"l-Value ≥ {_GOV_L_GOOD} good, ≥ {_GOV_L_WARNING} warning (min distinct sensitive values per QI group).", + "raw_l": l_val, + }) + + # --- t-Closeness --------------------------------------------------------- + t_val = None + if qi and sensitive: + t_res = compute_t_closeness(qi, sensitive, work_df, include_visualization=include_visualizations) + if "Error" not in t_res: + t_val = t_res.get("t-Value") + if t_val is not None and t_val > _GOV_T_WARNING: + needs_attention["attribute_disclosure"].append({ + "metric": "t-Closeness", + "value": t_val, + "sensitive_attribute": sensitive, + "quasi_identifiers": list(qi), + "detail": ( + f"Distribution of '{sensitive}' diverges from global " + f"(max TVD {t_val}) within groups of ({', '.join(qi)})" + ), + }) + details["t_closeness"] = { + "quasi_identifiers": qi, + "sensitive_attribute": sensitive, + "t_value": t_val, + "descriptive_statistics": t_res.get("descriptive_statistics"), + "visualization": t_res.get("t-Closeness Visualization") if include_visualizations else None, + "visualization_deferred": not include_visualizations, + } + else: + details["t_closeness"] = {"error": t_res.get("Error")} + else: + details["t_closeness"] = { + "error": "Requires quasi-identifiers and a sensitive attribute.", + } + + t_kpi = max(0.0, 1.0 - float(t_val) / _GOV_T_WARNING) if t_val is not None else None + kpis.append({ + "id": "distribution_t", + "label": "Distribution leakage (t)", + "value": t_kpi, + "status": _t_closeness_status(t_val), + "hint": f"Max TVD ≤ {_GOV_T_GOOD} good, ≤ {_GOV_T_WARNING} warning (lower is better).", + "raw_t": t_val, + }) + + # --- Entropy risk -------------------------------------------------------- + if qi: + e_res = compute_entropy_risk(qi, work_df, include_visualization=include_visualizations) + if "Error" not in e_res: + details["entropy_risk"] = { + "quasi_identifiers": qi, + "entropy_value": e_res.get("Entropy-Value"), + "descriptive_statistics": e_res.get("descriptive_statistics"), + "visualization": e_res.get("Entropy Risk Visualization") if include_visualizations else None, + "visualization_deferred": not include_visualizations, + } + else: + details["entropy_risk"] = {"error": e_res.get("Error")} + else: + details["entropy_risk"] = {"error": "No eligible quasi-identifier columns found."} + + # --- Single-attribute MM risk ---------------------------------------------- + worst_single_mean = None + single_by_qi = {} + if mm_qis: + for q in mm_qis: + try: + s_res = generate_single_attribute_MM_risk_scores_groupby( + work_df, id_col, [q], include_visualization=include_visualizations + ) + if "Error" in s_res: + single_by_qi[q] = {"error": s_res["Error"]} + continue + stats = (s_res.get("Descriptive statistics of the risk scores") or {}).get(q, {}) + mean_risk = stats.get("mean") + if mean_risk is not None: + mean_risk = float(mean_risk) + single_by_qi[q] = {"mean_risk": round(mean_risk, 4), "stats": stats} + if worst_single_mean is None or mean_risk > worst_single_mean: + worst_single_mean = mean_risk + if mean_risk >= _GOV_MM_SINGLE_WARNING: + needs_attention["high_linkage_risk"].append({ + "metric": "Single-attribute risk", + "feature": q, + "quasi_identifiers": [q], + "mean_risk": round(mean_risk, 4), + "detail": f"Quasi-identifier '{q}' — mean MM risk {mean_risk:.2f}", + }) + except Exception as exc: + single_by_qi[q] = {"error": str(exc)} + details["single_attribute_risk"] = { + "id_column": id_col, + "by_quasi_identifier": single_by_qi, + } + if needs_attention["low_anonymity"] and single_by_qi: + worst_qi = None + for q, info in single_by_qi.items(): + mr = info.get("mean_risk") + if mr is not None and (worst_qi is None or mr > worst_qi[1]): + worst_qi = (q, mr) + if worst_qi: + needs_attention["low_anonymity"][0]["worst_single_qi"] = { + "feature": worst_qi[0], + "mean_risk": worst_qi[1], + } + else: + details["single_attribute_risk"] = { + "error": "No categorical quasi-identifiers eligible for MM risk scoring.", + } + + single_kpi = ( + max(0.0, 1.0 - worst_single_mean) if worst_single_mean is not None else None + ) + kpis.append({ + "id": "single_linkage_risk", + "label": "Worst single-field risk", + "value": single_kpi, + "status": _mm_risk_status(worst_single_mean), + "hint": ( + f"1 − worst mean MM risk across QIs; good < {_GOV_MM_SINGLE_GOOD}, " + f"warning < {_GOV_MM_SINGLE_WARNING}." + ), + "raw_worst_mean": round(worst_single_mean, 4) if worst_single_mean is not None else None, + }) + + # --- Multiple-attribute MM risk ------------------------------------------ + multi_mean = None + if mm_qis: + try: + m_res = generate_multiple_attribute_MM_risk_scores_groupby( + work_df, id_col, mm_qis, include_visualization=include_visualizations + ) + if "Error" not in m_res: + m_stats = m_res.get("Descriptive statistics of the risk scores") or {} + multi_mean = m_stats.get("mean") + if multi_mean is not None: + multi_mean = float(multi_mean) + if multi_mean >= _GOV_MM_MULTI_WARNING: + needs_attention["high_linkage_risk"].append({ + "metric": "Multiple-attribute risk", + "features": list(mm_qis), + "quasi_identifiers": list(mm_qis), + "mean_risk": round(multi_mean, 4), + "detail": ( + f"Combined QIs ({', '.join(mm_qis)}) — " + f"mean MM risk {multi_mean:.2f}" + ), + }) + details["multiple_attribute_risk"] = { + "id_column": id_col, + "quasi_identifiers": mm_qis, + "mean_risk": round(multi_mean, 4) if multi_mean is not None else None, + "dataset_risk_score": m_res.get("Dataset Risk Score"), + "stats": m_stats, + "visualization": m_res.get("Multiple attribute risk scoring Visualization") if include_visualizations else None, + "visualization_deferred": not include_visualizations, + } + else: + details["multiple_attribute_risk"] = {"error": m_res.get("Error")} + except Exception as exc: + details["multiple_attribute_risk"] = {"error": str(exc)} + else: + details["multiple_attribute_risk"] = { + "error": "No categorical quasi-identifiers eligible for combined MM risk.", + } + + multi_kpi = max(0.0, 1.0 - multi_mean) if multi_mean is not None else None + kpis.append({ + "id": "linkage_risk", + "label": "Combined linkage risk", + "value": multi_kpi, + "status": _mm_risk_status( + multi_mean, good=_GOV_MM_MULTI_GOOD, warning=_GOV_MM_MULTI_WARNING + ), + "hint": ( + f"1 − mean MM risk on combined QIs; good < {_GOV_MM_MULTI_GOOD}, " + f"warning < {_GOV_MM_MULTI_WARNING}." + ), + "raw_mean": round(multi_mean, 4) if multi_mean is not None else None, + }) + + # --- HIPAA --------------------------------------------------------------- + hipaa_cols = selection["hipaa_scan_columns"] + detected_phi = {} + if hipaa_cols: + detected_phi = detect_hipaa_identifiers(work_df, hipaa_cols) + for col, info in detected_phi.items(): + types = info.get("potential_types_detected") or [] + serious = [t for t in types if t in _GOV_HIPAA_SERIOUS_TYPES] + needs_attention["hipaa_phi"].append({ + "column": col, + "total_flags": info.get("total_flags", 0), + "types": types, + "serious": bool(serious), + "examples": info.get("examples") or [], + }) + details["hipaa"] = { + "columns_scanned": hipaa_cols, + "detected": detected_phi, + } + else: + details["hipaa"] = {"error": "No columns available to scan."} + + hipaa_status, hipaa_kpi = _hipaa_status(detected_phi) + kpis.append({ + "id": "phi_exposure", + "label": "PHI exposure", + "value": hipaa_kpi, + "status": hipaa_status, + "hint": "Pattern scan for HIPAA-like identifiers (SSN, medical IDs, postal codes, etc.). Not full Safe Harbor certification.", + "columns_flagged": len(detected_phi), + }) + + # --- Differential privacy (illustrative) ----------------------------------- + dp_features = selection["dp_features"] + if dp_features: + try: + dp_res = return_noisy_stats( + dp_features, _GOV_DP_EPSILON, work_df, + save_output=False, include_visualization=include_visualizations, + ) + if "Error" not in dp_res: + details["differential_privacy"] = { + "features": dp_features, + "epsilon": _GOV_DP_EPSILON, + "illustrative": True, + "visualization": dp_res.get("DP Statistics Visualization") if include_visualizations else None, + "visualization_deferred": not include_visualizations, + "summary": { + k: v for k, v in dp_res.items() + if k.endswith("(before noise)") or k.endswith("(after noise)") + }, + } + else: + details["differential_privacy"] = {"error": dp_res.get("Error")} + except Exception as exc: + details["differential_privacy"] = {"error": str(exc)} + else: + details["differential_privacy"] = { + "error": "No eligible numerical columns for illustrative DP demo.", + } + + grade_kpis = [k for k in kpis if k["value"] is not None] + grade = sum(k["value"] for k in grade_kpis) / len(grade_kpis) if grade_kpis else None + + return { + "grade": grade, + "grade_status": _grade_label(grade), + "small_sample_warning": small_sample, + "auto_selection": selection, + "kpis": kpis, + "needs_attention": needs_attention, + "details": details, + "visualizations_deferred": not include_visualizations, + } + + +def _build_dataset_overview_visualizations(file_info): + df = read_file(file_info) + if hasattr(df, "columns"): + df.columns = [str(c) for c in df.columns] + profiles, _ = _build_feature_profiles(df) + display_profiles, _ = _prepare_feature_profiles_for_display(profiles) + chart_df = _dataframe_for_overview_detail_charts(df, display_profiles) + return { + "categorical_charts": categorical_distribution_charts(chart_df), + "histograms": summary_histograms(chart_df, figsize=(7, 4.5)), + } + + +def _build_data_quality_visualizations(file_info): + compl = completeness(file_info, include_visualization=True) + out = outliers(file_info, include_visualization=True) + viz = {} + if compl.get("Completeness Visualization"): + viz["completeness"] = compl["Completeness Visualization"] + if isinstance(out, dict) and out.get("Outliers Visualization"): + viz["outliers"] = out["Outliers Visualization"] + return viz + + +def _build_impact_on_ai_visualizations(file_info): + df = read_file(file_info) + if hasattr(df, "columns"): + df.columns = [str(c) for c in df.columns] + kept, _ = _prune_columns_for_corr(df) + if len(kept) < 2: + return {} + corr = calc_correlations(kept, file_info, include_visualization=True) + if isinstance(corr, dict) and "Message" in corr: + return {} + cat = corr.get("Correlations Analysis Categorical", {}) or {} + num = corr.get("Correlations Analysis Numerical", {}) or {} + viz = {} + cat_img = cat.get("Correlations Analysis Categorical Visualization") + num_img = num.get("Correlations Analysis Numerical Visualization") + if cat_img: + viz["categorical_correlation"] = cat_img + if num_img: + viz["numerical_correlation"] = num_img + return viz + + +def _build_fairness_bias_visualizations(file_info): + df = read_file(file_info) + if hasattr(df, "columns"): + df.columns = [str(c) for c in df.columns] + selection = _auto_select_fairness_columns(df) + sensitive_cols = selection["sensitive_columns"] + target_col = selection["target_column"] + primary_sensitive = selection["primary_sensitive"] + viz = {} + for col in sensitive_cols: + try: + vis = create_representation_rate_vis([col], file_info) + if isinstance(vis, str): + viz[f"representation_rate.{col}"] = vis + except Exception: + pass + if target_col: + ci_dict = _compute_class_imbalance(df, target_col, "EU", include_visualization=True) + img = ci_dict.get("Class Imbalance Visualization") + if img: + viz["class_imbalance"] = img + if primary_sensitive and target_col: + sr = calculate_statistical_rates( + target_col, primary_sensitive, file_info, include_visualization=True, + ) + if isinstance(sr, dict) and sr.get("Statistical Rate Visualization"): + viz["statistical_rate"] = sr["Statistical Rate Visualization"] + return viz + + +def _build_data_governance_visualizations(file_info): + """Build only chart payloads for governance (metrics already computed on initial load).""" + df = read_file(file_info) + if hasattr(df, "columns"): + df.columns = [str(c) for c in df.columns] + + fairness_sel = _auto_select_fairness_columns(df) + selection = _auto_select_governance_columns( + df, fairness_target=fairness_sel.get("target_column") + ) + qi = selection["quasi_identifiers"] + mm_qis = selection["mm_quasi_identifiers"] + sensitive = selection["sensitive_attribute"] + id_col = selection["id_column"] + id_synthetic = selection["id_synthetic"] + dp_features = selection["dp_features"] + + work_df = df.copy() + if id_synthetic: + work_df[_SYNTHETIC_ID_COL] = range(len(work_df)) + id_col = _SYNTHETIC_ID_COL + + viz = {} + if qi: + k_res = compute_k_anonymity(qi, work_df, include_visualization=True) + img = k_res.get("k-Anonymity Visualization") + if img: + viz["k_anonymity"] = img + e_res = compute_entropy_risk(qi, work_df, include_visualization=True) + img = e_res.get("Entropy Risk Visualization") + if img: + viz["entropy_risk"] = img + if qi and sensitive: + l_res = compute_l_diversity(qi, sensitive, work_df, include_visualization=True) + img = l_res.get("l-Diversity Visualization") + if img: + viz["l_diversity"] = img + t_res = compute_t_closeness(qi, sensitive, work_df, include_visualization=True) + img = t_res.get("t-Closeness Visualization") + if img: + viz["t_closeness"] = img + if mm_qis: + m_res = generate_multiple_attribute_MM_risk_scores_groupby( + work_df, id_col, mm_qis, include_visualization=True + ) + img = m_res.get("Multiple attribute risk scoring Visualization") + if img: + viz["multiple_attribute_risk"] = img + if dp_features: + try: + dp_res = return_noisy_stats( + dp_features, _GOV_DP_EPSILON, work_df, + save_output=False, include_visualization=True, + ) + img = dp_res.get("DP Statistics Visualization") + if img: + viz["differential_privacy"] = img + except Exception: + pass + return viz + + +_READINESS_VIZ_BUILDERS = { + "dataset-overview": _build_dataset_overview_visualizations, + "data-quality": _build_data_quality_visualizations, + "impact-on-ai": _build_impact_on_ai_visualizations, + "fairness-bias": _build_fairness_bias_visualizations, + "data-governance": _build_data_governance_visualizations, +} + + +_READINESS_SECTION_BUILDERS = { + "dataset-overview": _build_dataset_overview_section, + "data-quality": _build_data_quality_section, + "impact-on-ai": _build_impact_on_ai_section, + "fairness-bias": _build_fairness_bias_section, + "data-governance": _build_data_governance_section, +} + +_READINESS_SECTION_RESPONSE_KEYS = { + "dataset-overview": "dataset_overview", + "data-quality": "data_quality", + "impact-on-ai": "impact_on_ai", + "fairness-bias": "fairness_bias", + "data-governance": "data_governance", +} + +_READINESS_CACHE_TTL_SECONDS = 30 * 60 + + +def _readiness_section_cache_key(file_name, section, *, viz=False): + """User/file-scoped cache key (same colon pattern as ``/cached-result``).""" + user_id = get_current_user_id() + if viz: + return f"user:{user_id}:file:{file_name}:readiness_report:{section}:visualizations" + return f"user:{user_id}:file:{file_name}:readiness_report:{section}" + + +def _get_cached_readiness_payload(file_name, section, *, viz=False): + """Return cached section or visualization payload, or *(None, None)*.""" + key = _readiness_section_cache_key(file_name, section, viz=viz) + entry = current_app.TEMP_RESULTS_CACHE.get(key) + if entry and is_metric_cache_valid(entry): + return entry.get("data"), entry.get("build_time_seconds") + if entry: + current_app.TEMP_RESULTS_CACHE.pop(key, None) + return None, None + + +def _cache_readiness_payload(file_name, section, data, build_time_seconds, *, viz=False): + """Store a successful readiness section or visualization payload.""" + if data is None: + return + if not viz and isinstance(data, dict) and data.get("error"): + return + key = _readiness_section_cache_key(file_name, section, viz=viz) + current_app.TEMP_RESULTS_CACHE[key] = { + "data": ensure_json_serializable(data), + "timestamp": time.time(), + "expires_at": time.time() + _READINESS_CACHE_TTL_SECONDS, + "build_time_seconds": build_time_seconds, + } + + +def _get_or_build_readiness_section(section, file_info, include_visualizations=False): + """Return section data from cache or build, store on miss.""" + file_name = file_info[1] + cached, build_time = _get_cached_readiness_payload(file_name, section) + if cached is not None: + metric_time_log.info("Readiness report section %s cache hit", section) + return cached, build_time, True + + start_time = time.time() + data = _build_readiness_section( + section, file_info, include_visualizations=include_visualizations + ) + build_time_seconds = round(time.time() - start_time, 2) + _cache_readiness_payload(file_name, section, data, build_time_seconds) + return data, build_time_seconds, False + + +def get_cached_readiness_report(file_name): + """Return all cached readiness sections for ``/cached-result/readiness_report``. + + Returns a JSON-serializable dict when every section is cached, else *None*. + Optionally includes ``fair_compliance`` when a readiness FAIR result is cached. + """ + sections = {} + sections_cached = {} + for slug in _READINESS_SECTION_BUILDERS: + cached, build_time = _get_cached_readiness_payload(file_name, slug) + if cached is None: + return None + sections[slug] = {**cached, "build_time_seconds": build_time} + sections_cached[slug] = True + payload = { + "cached": True, + "sections": sections, + "sections_cached": sections_cached, + } + fair_compliance = _get_cached_readiness_fair_compliance(file_name) + if fair_compliance is not None: + payload["fair_compliance"] = fair_compliance + return payload + + +def _readiness_fair_cache_key(file_name): + """Cache key for optional readiness-report FAIR compliance.""" + user_id = get_current_user_id() + return f"user:{user_id}:file:{file_name}:readiness_report:fair" + + +def _fair_metadata_fingerprint(json_bytes, metadata_type): + """Stable fingerprint for readiness FAIR cache invalidation.""" + digest = hashlib.sha256() + digest.update(json_bytes) + digest.update(b"|") + digest.update(metadata_type.encode("utf-8")) + return digest.hexdigest() + + +def _get_cached_readiness_fair_compliance(file_name): + """Return cached readiness FAIR payload metadata, or *None*.""" + key = _readiness_fair_cache_key(file_name) + entry = current_app.TEMP_RESULTS_CACHE.get(key) + if entry and is_metric_cache_valid(entry): + data = entry.get("data") + if data is None or (isinstance(data, dict) and data.get("error")): + return None + return { + "data": data, + "metadata_type": entry.get("metadata_type"), + "metadata_filename": entry.get("metadata_filename"), + "metadata_fingerprint": entry.get("metadata_fingerprint"), + "build_time_seconds": entry.get("build_time_seconds"), + "cached": True, + } + if entry: + current_app.TEMP_RESULTS_CACHE.pop(key, None) + return None + + +def get_cached_readiness_fair_report(file_name): + """Return readiness FAIR cache for ``/cached-result/readiness_report_fair``.""" + fair_compliance = _get_cached_readiness_fair_compliance(file_name) + if fair_compliance is None: + return None + return {"cached": True, "fair_compliance": fair_compliance} + + +def _cache_readiness_fair_compliance( + file_name, + data, + *, + metadata_type, + metadata_filename, + metadata_fingerprint, + build_time_seconds, +): + """Store a successful readiness-report FAIR assessment.""" + if data is None or (isinstance(data, dict) and data.get("error")): + return + key = _readiness_fair_cache_key(file_name) + current_app.TEMP_RESULTS_CACHE[key] = { + "data": ensure_json_serializable(data), + "timestamp": time.time(), + "expires_at": time.time() + _READINESS_CACHE_TTL_SECONDS, + "build_time_seconds": build_time_seconds, + "metadata_type": metadata_type, + "metadata_filename": metadata_filename, + "metadata_fingerprint": metadata_fingerprint, + } + + +def _run_fair_assessment(data_dict, metadata_type): + """Run FAIR assessment for DCAT or Datacite metadata.""" + if metadata_type == "DCAT": + extracted_json = extract_keys_and_values(data_dict) + fair_dict = categorize_metadata(extracted_json, data_dict) + return format_dict_values(fair_dict) + if metadata_type == "Datacite": + return categorize_keys_fair(data_dict) + raise ValueError("Unknown metadata type") + + +def _get_or_build_readiness_visualizations(section, file_info): + """Return visualization payload from cache or build, store on miss.""" + file_name = file_info[1] + cached, build_time = _get_cached_readiness_payload( + file_name, section, viz=True + ) + if cached is not None: + metric_time_log.info( + "Readiness report section %s visualizations cache hit", section + ) + return cached, build_time, True + + builder = _READINESS_VIZ_BUILDERS.get(section) + start_time = time.time() + visualizations = builder(file_info) if builder else {} + build_time_seconds = round(time.time() - start_time, 2) + _cache_readiness_payload( + file_name, section, visualizations, build_time_seconds, viz=True + ) + return visualizations, build_time_seconds, False + + +def _readiness_file_info(): + """Return ``(file_path, file_name, file_type)`` from session, or *None*.""" + file_path = session.get("uploaded_file_path") + if not file_path: + return None + return ( + file_path, + session.get("uploaded_file_name"), + session.get("uploaded_file_type"), + ) + + +def _build_readiness_section(section, file_info, include_visualizations=False): + """Build one readiness-report section; return error dict on failure.""" + builder = _READINESS_SECTION_BUILDERS.get(section) + if builder is None: + return None + try: + return builder(file_info, include_visualizations=include_visualizations) + except Exception as e: + metric_time_log.error( + "Readiness report — %s error: %s", section, e, exc_info=True + ) + return {"error": f"{type(e).__name__}: {e}"} + + +@metrics_bp.route("/readiness-report/
/visualizations", methods=["GET"]) +def readiness_report_visualizations(section): + """Return on-demand chart images for a readiness-report section.""" + if section not in _READINESS_VIZ_BUILDERS: + return jsonify({"success": False, "message": f"Unknown section: {section}"}), 404 + + file_info = _readiness_file_info() + if file_info is None: + return jsonify({"success": False, "message": "No file uploaded"}), 200 + + try: + visualizations, build_time_seconds, from_cache = ( + _get_or_build_readiness_visualizations(section, file_info) + ) + except Exception as e: + metric_time_log.error( + "Readiness report visualizations — %s error: %s", section, e, exc_info=True + ) + return jsonify({ + "success": False, + "message": f"{type(e).__name__}: {e}", + }), 200 + + if not from_cache: + metric_time_log.info( + "Readiness report section %s visualizations built in %.2f seconds", + section, + build_time_seconds, + ) + return jsonify(ensure_json_serializable({ + "success": True, + "section": section, + "visualizations": visualizations, + "build_time_seconds": build_time_seconds, + "cached": from_cache, + })) + + +@metrics_bp.route("/readiness-report/
", methods=["GET"]) +def readiness_report_section(section): + """Return a single readiness-report section as JSON (for progressive UI loading).""" + if section not in _READINESS_SECTION_BUILDERS: + return jsonify({"success": False, "message": f"Unknown section: {section}"}), 404 + + file_info = _readiness_file_info() + if file_info is None: + return jsonify({"success": False, "message": "No file uploaded"}), 200 + + data, build_time_seconds, from_cache = _get_or_build_readiness_section( + section, file_info + ) + if not from_cache: + metric_time_log.info( + "Readiness report section %s built in %.2f seconds", + section, + build_time_seconds, + ) + return jsonify(ensure_json_serializable({ + "success": True, + "section": section, + "data": data, + "build_time_seconds": build_time_seconds, + "cached": from_cache, + })) + + +@metrics_bp.route("/readiness-report", methods=["GET"]) +def readiness_report(): + """Return the full readiness report as JSON (all sections in one response).""" + file_info = _readiness_file_info() + if file_info is None: + return jsonify({"success": False, "message": "No file uploaded"}), 200 + + start_time = time.time() + try: + response = {"success": True, "sections_cached": {}} + for slug in _READINESS_SECTION_BUILDERS: + section_data, section_elapsed, from_cache = ( + _get_or_build_readiness_section(slug, file_info) + ) + if isinstance(section_data, dict): + section_data = {**section_data, "build_time_seconds": section_elapsed} + if not from_cache: + metric_time_log.info( + "Readiness report section %s built in %.2f seconds", + slug, + section_elapsed, + ) + response["sections_cached"][slug] = from_cache + response[_READINESS_SECTION_RESPONSE_KEYS[slug]] = section_data + + metric_time_log.info("Readiness report built in %.2f seconds", time.time() - start_time) + return jsonify(ensure_json_serializable(response)) + except Exception as e: + metric_time_log.error("Readiness report error: %s", e, exc_info=True) + return jsonify({"success": False, "message": f"{type(e).__name__}: {e}"}), 200 + + +@metrics_bp.route("/readiness-report/pdf", methods=["GET"]) +def readiness_report_pdf(): + """Build readiness sections (cache on miss) and return a scorecard or full PDF.""" + file_info = _readiness_file_info() + if file_info is None: + return jsonify({"success": False, "message": "No file uploaded"}), 400 + + from web.readiness.pdf import ( + build_pdf_context, + pdf_filename, + render_readiness_report_pdf, + ) + + mode = request.args.get("mode", "scorecard") + include_details = mode == "full" + file_name = file_info[1] + sections = {} + visualizations: dict[str, dict] = {} + start_time = time.time() + try: + for slug in _READINESS_SECTION_BUILDERS: + data, section_elapsed, from_cache = _get_or_build_readiness_section( + slug, file_info + ) + if isinstance(data, dict) and data.get("error"): + return jsonify({ + "success": False, + "message": f"Could not build {slug}: {data['error']}", + }), 500 + sections[slug] = data + if include_details: + viz_data, viz_elapsed, viz_cached = _get_or_build_readiness_visualizations( + slug, file_info + ) + visualizations[slug] = viz_data or {} + if not viz_cached: + metric_time_log.info( + "Readiness report PDF — %s visualizations built in %.2f seconds", + slug, + viz_elapsed, + ) + if not from_cache: + metric_time_log.info( + "Readiness report PDF — section %s built in %.2f seconds", + slug, + section_elapsed, + ) + + fair_entry = _get_cached_readiness_fair_compliance(file_name) + fair_data = fair_entry.get("data") if fair_entry else None + context = build_pdf_context( + file_name=file_name, + sections=sections, + fair_data=fair_data, + include_details=include_details, + visualizations=visualizations, + ) + pdf_bytes = render_readiness_report_pdf(current_app, context) + metric_time_log.info( + "Readiness report PDF (%s) generated in %.2f seconds", + mode, + time.time() - start_time, + ) + return send_file( + io.BytesIO(pdf_bytes), + mimetype="application/pdf", + as_attachment=True, + download_name=pdf_filename(file_name, full=include_details), + ) + except RuntimeError as e: + metric_time_log.error("Readiness report PDF error: %s", e, exc_info=True) + return jsonify({"success": False, "message": str(e)}), 500 + except Exception as e: + metric_time_log.error("Readiness report PDF error: %s", e, exc_info=True) + return jsonify({"success": False, "message": f"{type(e).__name__}: {e}"}), 500 + + # --------------------------------------------------------------------------- # Fairness # --------------------------------------------------------------------------- @@ -475,10 +2899,11 @@ def class_imbalance(): return get_result_or_default("metrics.class_imbalance", file_path, file_name) -def _compute_class_imbalance(file, classes, dist_metric): +def _compute_class_imbalance(file, classes, dist_metric, include_visualization=True): ci_dict = {} try: - ci_dict["Class Imbalance Visualization"] = class_distribution_plot(file, classes) + if include_visualization: + ci_dict["Class Imbalance Visualization"] = class_distribution_plot(file, classes) ci_dict["Description"] = ( "The chart displays the distribution of classes within the " "specified feature, providing a visual representation of the " @@ -771,24 +3196,48 @@ def fair_assessment(): return jsonify({"error": "Invalid file format. Please upload a JSON file."}), 400 json_data = file.read() - data_dict = json.loads(json_data.decode("utf-8")) - metadata_type = request.form.get("metadata type", "") + readiness_context = request.form.get("readiness_context") == "1" + dataset_file_name = ( + session.get("uploaded_file_name") + or session.get("globus_file_name") + or "" + ) + metadata_fingerprint = _fair_metadata_fingerprint(json_data, metadata_type) + + if readiness_context and dataset_file_name: + cached_fair = _get_cached_readiness_fair_compliance(dataset_file_name) + if ( + cached_fair is not None + and cached_fair.get("metadata_fingerprint") == metadata_fingerprint + ): + metric_time_log.info( + "Readiness report FAIR compliance cache hit for %s", + dataset_file_name, + ) + return jsonify( + ensure_json_serializable( + { + **cached_fair["data"], + "cached": True, + "build_time_seconds": cached_fair.get( + "build_time_seconds" + ), + } + ) + ) - if metadata_type == "DCAT": - try: - extracted_json = extract_keys_and_values(data_dict) - fair_dict = categorize_metadata(extracted_json, data_dict) - result = format_dict_values(fair_dict) - except json.JSONDecodeError as e: - return jsonify({"error": f"Error parsing JSON: {str(e)}"}), 400 - elif metadata_type == "Datacite": - try: - result = categorize_keys_fair(data_dict) - except json.JSONDecodeError as e: - return jsonify({"error": f"Error parsing JSON: {str(e)}"}), 400 - else: + try: + data_dict = json.loads(json_data.decode("utf-8")) + except json.JSONDecodeError as e: + return jsonify({"error": f"Error parsing JSON: {str(e)}"}), 400 + + try: + result = _run_fair_assessment(data_dict, metadata_type) + except ValueError: return jsonify({"error": "Unknown metadata type"}), 400 + except json.JSONDecodeError as e: + return jsonify({"error": f"Error parsing JSON: {str(e)}"}), 400 duration = time.time() - start_time metric_time_log.info("FAIR Assessment completed in %.2f seconds", duration) @@ -797,7 +3246,16 @@ def fair_assessment(): span.set_attribute("metadata.type", metadata_type) result = ensure_json_serializable(result) - return jsonify(result) + if readiness_context and dataset_file_name: + _cache_readiness_fair_compliance( + dataset_file_name, + result, + metadata_type=metadata_type, + metadata_filename=file.filename, + metadata_fingerprint=metadata_fingerprint, + build_time_seconds=round(duration, 2), + ) + return jsonify({**result, "cached": False, "build_time_seconds": round(duration, 2)}) else: results_id = request.args.get("results_id") diff --git a/web/routes/utils.py b/web/routes/utils.py index b6f0e0fb..7f7de7ba 100644 --- a/web/routes/utils.py +++ b/web/routes/utils.py @@ -247,14 +247,14 @@ def ensure_json_serializable(obj): return obj -def summary_histograms(df): +def summary_histograms(df, figsize=(4, 3), dpi=150): """Generate base64-encoded KDE distribution plots for all numeric columns.""" text_color = "#6b7280" curve_color = "#4485F4" line_graphs = {} for column in df.select_dtypes(include="number").columns: - fig, ax = plt.subplots(figsize=(4, 3)) + fig, ax = plt.subplots(figsize=figsize) fig.patch.set_alpha(0) ax.set_facecolor("none") @@ -268,7 +268,7 @@ def summary_histograms(df): fig.tight_layout(pad=0.5) img_buffer = io.BytesIO() - fig.savefig(img_buffer, format="png", dpi=150, transparent=True) + fig.savefig(img_buffer, format="png", dpi=dpi, transparent=True) img_buffer.seek(0) encoded_img = base64.b64encode(img_buffer.read()).decode("utf-8") @@ -278,3 +278,75 @@ def summary_histograms(df): img_buffer.close() return line_graphs + + +def categorical_distribution_charts(df, top_n=10): + """Generate base64-encoded pie charts for categorical columns. + + Shows the top *top_n* values per column; remaining values are grouped + into an "Other" slice when applicable. + """ + text_color = "#6b7280" + palette = [ + "#4485F4", "#34A853", "#FBBC05", "#EA4335", "#9C27B0", + "#00ACC1", "#FF7043", "#8D6E63", "#78909C", "#AB47BC", + ] + charts = {} + + cat_cols = df.select_dtypes(include=["object", "string", "category"]).columns + for column in cat_cols: + series = df[column].dropna() + if len(series) == 0: + continue + + vc = series.value_counts() + if len(vc) > top_n: + top = vc.head(top_n) + other_count = int(vc.iloc[top_n:].sum()) + labels = [str(v) for v in top.index] + sizes = [int(v) for v in top.values] + if other_count > 0: + labels.append("Other") + sizes.append(other_count) + else: + labels = [str(v) for v in vc.index] + sizes = [int(v) for v in vc.values] + + n_slices = len(labels) + colors = [palette[i % len(palette)] for i in range(n_slices)] + + fig, ax = plt.subplots(figsize=(5, 5)) + fig.patch.set_alpha(0) + + wedges, _, autotexts = ax.pie( + sizes, + labels=None, + autopct=lambda pct: f"{pct:.1f}%" if pct >= 4 else "", + colors=colors, + startangle=90, + pctdistance=0.75, + textprops={"color": text_color, "fontsize": 8}, + ) + for t in autotexts: + t.set_fontsize(7) + + ax.legend( + wedges, + labels, + loc="center left", + bbox_to_anchor=(1, 0.5), + fontsize=8, + frameon=False, + labelcolor=text_color, + ) + ax.set_title(str(column), fontsize=10, color=text_color, pad=8) + fig.tight_layout() + + img_buffer = io.BytesIO() + fig.savefig(img_buffer, format="png", dpi=150, transparent=True, bbox_inches="tight") + img_buffer.seek(0) + charts[str(column)] = base64.b64encode(img_buffer.read()).decode("utf-8") + plt.close(fig) + img_buffer.close() + + return charts diff --git a/web/static/css/inspector.css b/web/static/css/inspector.css index 0ee0d18e..9919efea 100644 --- a/web/static/css/inspector.css +++ b/web/static/css/inspector.css @@ -95,3 +95,23 @@ button.is-submitting::after { transform: rotate(360deg); } } + +/* Readiness PDF export: html2canvas must not inherit fade-in or dark-mode opacity */ +.readiness-pdf-export, +.readiness-pdf-export * { + animation: none !important; + transition: none !important; + opacity: 1 !important; + transform: none !important; +} + +.readiness-pdf-export { + background: #ffffff !important; + color: #111827 !important; +} + +html.dark .readiness-pdf-export, +html.dark .readiness-pdf-export * { + --tw-bg-opacity: 1 !important; + --tw-text-opacity: 1 !important; +} diff --git a/web/static/css/readiness_report_print.css b/web/static/css/readiness_report_print.css new file mode 100644 index 00000000..328a742f --- /dev/null +++ b/web/static/css/readiness_report_print.css @@ -0,0 +1,732 @@ +/* Readiness report PDF — WeasyPrint print stylesheet */ + +@page { + size: A4; + margin: 14mm 12mm 22mm 12mm; + @bottom-right { + content: element(pdf-footer); + vertical-align: middle; + } +} + +@page :first { + margin-bottom: 16mm; + @bottom-right { + content: none; + } +} + +.pdf-running-footer { + position: running(pdf-footer); + width: 100%; + text-align: right; +} + +.pdf-footer-logo-img { + height: 8mm; + width: auto; +} + +.pdf-first-page-header { + text-align: center; + margin: 0 0 10pt 0; + page-break-after: avoid; +} + +.pdf-header-logo-img { + height: 28mm; + width: auto; +} + +body { + font-family: Helvetica, Arial, sans-serif; + font-size: 9.5pt; + line-height: 1.4; + color: #111827; + background: #ffffff; +} + +h1 { + font-size: 18pt; + margin: 0 0 6pt 0; + color: #111827; +} + +h2 { + font-size: 12pt; + margin: 0 0 8pt 0; + padding-bottom: 4pt; + border-bottom: 1px solid #d1d5db; + page-break-after: avoid; +} + +h3 { + font-size: 10pt; + margin: 0 0 6pt 0; + page-break-after: avoid; +} + +p { + margin: 0 0 6pt 0; +} + +.mono { + font-family: "Courier New", Courier, monospace; + font-size: 8.5pt; +} + +.muted { + color: #6b7280; + font-size: 8.5pt; +} + +.intro { + background: #f9fafb; + border: 1px solid #e5e7eb; + border-radius: 4pt; + padding: 10pt; + margin-bottom: 14pt; +} + +.section-card { + border: 1px solid #e5e7eb; + border-radius: 4pt; + padding: 10pt; + margin-bottom: 12pt; + page-break-before: always; + break-before: page; +} + +.box-blue { + background: #eff6ff; + border: 1px solid #bfdbfe; + border-radius: 4pt; + padding: 8pt; + margin-bottom: 8pt; + font-size: 8.5pt; +} + +.box-criteria { + background: #eff6ff; + border: 1px solid #bfdbfe; + border-radius: 4pt; + padding: 8pt; + margin-bottom: 8pt; + font-size: 8.5pt; +} + +.criteria-title { + font-weight: 600; + color: #1e40af; + margin: 0 0 6pt 0; +} + +.criteria-list { + margin: 0; + padding-left: 14pt; + color: #374151; +} + +.criteria-list li { + margin-bottom: 6pt; +} + +.criteria-label { + font-weight: 500; +} + +.criteria-rule { + display: block; + font-size: 8pt; + color: #6b7280; + margin-top: 1pt; +} + +.criteria-note { + color: #b45309; +} + +.meta-title { + margin-bottom: 6pt; +} + +.meta-grid { + width: 100%; + border-collapse: collapse; +} + +.meta-grid td { + padding: 2pt 6pt 2pt 0; + vertical-align: top; + width: 25%; +} + +.meta-label { + color: #6b7280; +} + +.box-amber { + background: #fffbeb; + border: 1px solid #fde68a; + border-radius: 4pt; + padding: 8pt; + margin-bottom: 8pt; + break-inside: auto; + page-break-inside: auto; +} + +.na-panel-title { + font-size: 9.5pt; + font-weight: 600; + color: #92400e; + margin: 0 0 8pt 0; + break-after: avoid; + page-break-after: avoid; +} + +.na-grid { + display: flex; + flex-wrap: wrap; + gap: 10pt; + break-inside: auto; + page-break-inside: auto; +} + +.na-block { + margin-bottom: 0; + flex: 0 0 calc((100% - 10pt) / 2); + max-width: calc((100% - 10pt) / 2); + box-sizing: border-box; + break-inside: avoid; + page-break-inside: avoid; + -webkit-column-break-inside: avoid; +} + +.na-block-title { + font-size: 7.5pt; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.03em; + color: #4b5563; + margin: 0 0 4pt 0; +} + +.na-block-title.tone-red { + color: #b91c1c; +} + +.na-block-title.tone-amber { + color: #b45309; +} + +.na-context { + font-size: 8pt; + color: #6b7280; + margin: 0 0 4pt 0; +} + +.na-message { + font-size: 8.5pt; + color: #374151; + margin: 0; +} + +.na-list { + margin: 0; + padding: 0; + list-style: none; +} + +.na-list li { + margin-bottom: 4pt; +} + +.na-row { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 8pt; +} + +.na-row-primary { + font-size: 8.5pt; + color: #374151; + min-width: 0; +} + +.na-row-value { + font-family: "Courier New", Courier, monospace; + font-size: 8pt; + color: #6b7280; + flex-shrink: 0; +} + +.na-row-secondary { + font-size: 7.5pt; + color: #9ca3af; + margin: 1pt 0 0 0; +} + +.na-more { + font-size: 8pt; + color: #9ca3af; +} + +.na-empty { + font-size: 9pt; + color: #166534; + margin: 0; +} + +.box-green { + background: #f0fdf4; + border: 1px solid #bbf7d0; + border-radius: 4pt; + padding: 8pt; + margin-bottom: 8pt; + color: #166534; +} + +.grade-row { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8pt; +} + +.grade-label { + font-size: 9.5pt; + font-weight: 500; + color: #374151; +} + +.badge { + display: inline-block; + padding: 2pt 8pt; + border-radius: 10pt; + font-size: 9pt; + font-weight: bold; +} + +.badge-good { + background: #dcfce7; + color: #166534; +} + +.badge-warning { + background: #fef3c7; + color: #92400e; +} + +.badge-poor { + background: #fee2e2; + color: #991b1b; +} + +.badge-unknown { + background: #f3f4f6; + color: #374151; +} + +.kpi-grid { + width: 100%; + border-collapse: separate; + border-spacing: 6pt; + margin-bottom: 8pt; +} + +.kpi-grid td { + width: 33%; + vertical-align: top; + border: 1px solid #e5e7eb; + border-radius: 4pt; + padding: 8pt; + background: #f9fafb; +} + +.kpi-grid-4 td { + width: 25%; + text-align: center; +} + +.kpi-label { + font-size: 7.5pt; + text-transform: uppercase; + letter-spacing: 0.03em; + color: #6b7280; +} + +.kpi-value { + font-size: 14pt; + font-weight: bold; + margin: 4pt 0; +} + +.kpi-value-good { color: #166534; } +.kpi-value-warning { color: #92400e; } +.kpi-value-poor { color: #991b1b; } + +.profile-header { + display: flex; + justify-content: space-between; + align-items: baseline; + margin: 8pt 0 4pt 0; +} + +.profile-header h3 { + margin: 0; + border: none; + padding: 0; +} + +.profile-counts { + font-size: 8pt; + color: #6b7280; +} + +.text-poor { + color: #dc2626; + font-weight: 600; +} + +.text-warning { + color: #d97706; + font-weight: 600; +} + +.footnote-ref { + font-size: 6.5pt; + vertical-align: super; + color: #2563eb; + font-weight: bold; + line-height: 0; +} + +.footnotes-list { + font-size: 7.5pt; + padding-left: 18pt; + margin: 0; +} + +.footnotes-list li { + margin-bottom: 4pt; +} + +.section-footnotes { + margin-top: 8pt; + padding-top: 6pt; + border-top: 1px solid #e5e7eb; + page-break-inside: avoid; +} + +.bar-track { + height: 4pt; + background: #e5e7eb; + border-radius: 2pt; + margin-top: 4pt; +} + +.bar-fill { + height: 4pt; + border-radius: 2pt; +} + +.bar-good { background: #22c55e; } +.bar-warning { background: #f59e0b; } +.bar-poor { background: #ef4444; } +.bar-fair { background: #2563eb; } + +table.data { + width: 100%; + border-collapse: collapse; + font-size: 8pt; + margin-bottom: 8pt; +} + +table.data th, +table.data td { + border: 1px solid #e5e7eb; + padding: 4pt 5pt; + text-align: left; +} + +table.data th { + background: #f3f4f6; + font-size: 7pt; + text-transform: uppercase; + color: #374151; +} + +table.data td.num { + text-align: right; + font-family: "Courier New", Courier, monospace; +} + +.na-block h4 { + font-size: 8.5pt; + margin: 0 0 3pt 0; + text-transform: uppercase; + color: #374151; +} + +.na-list-old { + margin: 0; + padding-left: 12pt; + font-size: 8.5pt; +} + +.na-list-old li { + margin-bottom: 2pt; +} + +.fair-progress-track { + height: 6pt; + background: #e5e7eb; + border-radius: 3pt; + margin-bottom: 8pt; +} + +.fair-progress-fill { + height: 6pt; + background: #2563eb; + border-radius: 3pt; +} + +.fair-detail-grid { + width: 100%; + border-collapse: separate; + border-spacing: 8pt 8pt; + margin-top: 8pt; +} + +.fair-detail-grid > tr > td { + width: 50%; + vertical-align: top; + padding: 0; +} + +.fair-principle-card { + border: 1px solid #e5e7eb; + border-radius: 4pt; + overflow: hidden; +} + +.fair-principle-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 6pt 8pt; + background: #f9fafb; + border-bottom: 1px solid #e5e7eb; + font-size: 8.5pt; +} + +.fair-principle-name { + font-weight: 600; + color: #111827; +} + +.fair-principle-checks { + font-size: 8pt; + color: #9ca3af; +} + +.fair-check-table { + width: 100%; + border-collapse: collapse; + font-size: 8pt; +} + +.fair-check-table td { + padding: 4pt 8pt; + border-bottom: 1px solid #f3f4f6; + vertical-align: middle; +} + +.fair-check-group td { + font-size: 7.5pt; + font-weight: 600; + color: #374151; + background: #f9fafb; + padding-top: 6pt; +} + +.fair-check-key { + color: #4b5563; +} + +.fair-check-status { + text-align: right; + width: 28%; +} + +.fair-badge { + display: inline-block; + padding: 1pt 5pt; + border-radius: 8pt; + font-size: 7pt; + font-weight: 600; +} + +.fair-badge-found { + background: #dcfce7; + color: #166534; +} + +.fair-badge-missing { + background: #fee2e2; + color: #991b1b; +} + +.fair-principles { + width: 100%; + border-collapse: collapse; + margin-bottom: 8pt; +} + +.fair-principles td { + text-align: center; + padding: 6pt; + border: 1px solid #e5e7eb; +} + +.glossary-table { + width: 100%; + border-collapse: collapse; + font-size: 7.5pt; + line-height: 1.35; + table-layout: fixed; +} + +.glossary-table col.glossary-col-term { + width: 22%; +} + +.glossary-table col.glossary-col-definition { + width: 78%; +} + +.glossary-table th, +.glossary-table td { + border: 1px solid #e5e7eb; + vertical-align: top; + overflow-wrap: break-word; + word-wrap: break-word; +} + +.glossary-table th { + background: #f3f4f6; + padding: 4pt 5pt; + font-size: 7pt; + text-transform: uppercase; + color: #374151; +} + +.glossary-table th.glossary-term, +.glossary-table td.glossary-term { + padding: 4pt 8pt 4pt 5pt; + font-weight: 600; +} + +.glossary-table th.glossary-definition, +.glossary-table td.glossary-definition { + padding: 4pt 5pt 4pt 4pt; +} + +.details-section { + margin-top: 10pt; + padding-top: 8pt; + border-top: 1px solid #d1d5db; +} + +.details-heading { + font-size: 10pt; + margin: 0 0 8pt 0; + color: #1d4ed8; +} + +.details-subheading { + font-size: 7.5pt; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.03em; + color: #4b5563; + margin: 8pt 0 4pt 0; +} + +.details-note { + margin-bottom: 6pt; +} + +.details-table { + margin-bottom: 8pt; +} + +.details-list { + margin: 0 0 8pt 0; + padding-left: 14pt; + font-size: 8.5pt; +} + +.details-list li { + margin-bottom: 3pt; +} + +.chart-grid { + display: flex; + flex-wrap: wrap; + gap: 6pt; + margin-bottom: 8pt; +} + +.chart-figure { + margin: 0; + break-inside: avoid; + page-break-inside: avoid; +} + +.chart-figure-small { + flex: 0 0 calc((100% - 12pt) / 3); + max-width: calc((100% - 12pt) / 3); + box-sizing: border-box; + border: 1px solid #e5e7eb; + border-radius: 4pt; + padding: 4pt; + background: #fafafa; +} + +.chart-figure-small figcaption { + font-size: 7pt; + font-weight: 600; + color: #374151; + margin-bottom: 3pt; + text-align: center; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.chart-figure-small img { + width: 100%; + height: auto; + display: block; +} + +.chart-figure-wide { + margin-bottom: 8pt; + border: 1px solid #e5e7eb; + border-radius: 4pt; + padding: 6pt; + background: #fafafa; +} + +.chart-figure-wide img { + width: 100%; + max-width: 100%; + height: auto; + display: block; +} + +.page-break { + page-break-before: always; +} diff --git a/web/static/css/theme.css b/web/static/css/theme.css index 44af6f02..b45e9e96 100644 --- a/web/static/css/theme.css +++ b/web/static/css/theme.css @@ -313,10 +313,29 @@ html.dark .info-text { } .info-icon:hover .info-text, -.info-text:hover { +.info-text:hover, +.info-icon:focus-within .info-text { display: block !important; } +/* Readiness report: show tooltip below icon (avoids clipping in tables) */ +#panel-readiness-report .info-icon--below .info-text { + left: auto; + right: 0; + top: calc(100% + 6px); + margin-left: 0; +} + +#panel-readiness-report th .info-icon, +#panel-readiness-report .inline-flex .info-icon { + flex-shrink: 0; +} + +/* Let readiness tooltips extend outside scrollable table wrappers when possible */ +#panel-readiness-report table { + overflow: visible; +} + .info-text a { color: var(--brandBlue) !important; text-decoration: underline !important; diff --git a/web/static/js/inspector.js b/web/static/js/inspector.js index c3eaa856..0964df83 100644 --- a/web/static/js/inspector.js +++ b/web/static/js/inspector.js @@ -10,6 +10,32 @@ let activePanel = "data-overview"; let codeMirrorEditor = null; let lastMetricResult = null; // Store last result for JSON download +let _readinessReportLoaded = false; // Lazy-load guard for the Readiness Report panel + +const _READINESS_REPORT_SECTIONS = [ + "dataset-overview", + "data-quality", + "impact-on-ai", + "fairness-bias", + "data-governance", +]; +const _readinessSectionStatus = {}; +/** Optional FAIR Compliance state for the readiness report appendix. */ +let _readinessFairCompliance = { status: "idle", data: null }; +const _READINESS_MAX_DETAIL_LIST_ITEMS = 50; +const _READINESS_MAX_DETAIL_TABLE_ROWS = 500; + +/** Render up to *maxItems* list entries plus a “+N more” tail when truncated. */ +function _readinessTruncatedListItems(items, maxItems, renderItem) { + const total = items.length; + const shown = items.slice(0, maxItems); + let html = shown.map(renderItem).join(""); + const more = total - shown.length; + if (more > 0) { + html += `
  • +${more} more not shown
  • `; + } + return { html, total, shownCount: shown.length }; +} /** * Show a metric panel by ID, hiding all others. @@ -62,6 +88,16 @@ function showPanel(panelId, pushHistory) { initCodeMirror(); } + // Lazy load the readiness report on first open; restore from server cache when available + if (panelId === "readiness-report" && !_readinessReportLoaded) { + _readinessReportLoaded = true; + _restoreCachedReadinessReport().then((restored) => { + if (!restored && activePanel === "readiness-report") { + loadReadinessReport(); + } + }); + } + // Close mobile sidebar after selection const sidebar = document.getElementById("sidebar"); if (sidebar && window.innerWidth < 640) { @@ -1704,125 +1740,266 @@ function escapeHtml(str) { // ==================== FAIR Assessment ==================== -function submitFairAssessment() { - const form = document.getElementById("form-fair-assessment"); - if (!form) return; +function _wireFairFileInput(fileInput, labelEl, iconEl) { + if (!fileInput || !labelEl) return; + fileInput.addEventListener("change", () => { + if (fileInput.files.length) { + labelEl.textContent = fileInput.files[0].name; + if (iconEl) { + iconEl.innerHTML = + ''; + iconEl.classList.remove("text-gray-400"); + iconEl.classList.add("text-green-500"); + } + } else { + labelEl.textContent = "JSON metadata file"; + if (iconEl) { + iconEl.innerHTML = + ''; + iconEl.classList.remove("text-green-500"); + iconEl.classList.add("text-gray-400"); + } + } + }); +} + +/** Build FAIR assessment result HTML (shared by panel and readiness report). */ +function buildFairAssessmentResultHtml(data) { + const checks = data["FAIR Compliance Checks"] || {}; + const totalCheck = checks["Total Checks"] || ""; + const totalMatch = totalCheck.match(/(\d+)\/(\d+)/); + const totalPassed = totalMatch ? parseInt(totalMatch[1], 10) : 0; + const totalExpected = totalMatch ? parseInt(totalMatch[2], 10) : 1; + const totalPct = Math.round((totalPassed / totalExpected) * 100); + + let html = `
    +
    +

    FAIR Compliance

    + ${totalPassed}/${totalExpected} checks passed +
    +
    +
    +
    +
    `; + + const fairKeys = ["Findable", "Accessible", "Interoperable", "Reusable"]; + fairKeys.forEach((k) => { + const checkStr = checks[`${k} Checks`] || "0/0"; + const m = checkStr.match(/(\d+)\/(\d+)/); + const passed = m ? parseInt(m[1], 10) : 0; + const total = m ? parseInt(m[2], 10) : 1; + const pct = Math.round((passed / total) * 100); + html += `
    +
    ${k}
    +
    ${passed}/${total}
    +
    +
    +
    +
    `; + }); + html += "
    "; + + html += '
    '; + fairKeys.forEach((k) => { + let val = "—"; + const checkStr = checks[`${k} Checks`] || ""; + if (data[k] !== undefined && typeof data[k] === "object") { + val = renderFairValue(data[k]); + } else if (data[k] !== undefined) { + val = `
    ${data[k]}
    `; + } + html += `
    + + ${k} + ${checkStr} + +
    ${val}
    +
    `; + }); + html += "
    "; + + const extraKeys = Object.keys(data).filter( + (k) => !fairKeys.includes(k) && k !== "Pie chart", + ); + if (extraKeys.length > 0) { + html += + '
    '; + html += + '

    Detailed Results

    '; + extraKeys.forEach((k) => { + const val = data[k]; + if (typeof val === "object" && val !== null) { + html += `
    + + ${k} + + +
    +
    ${escapeHtml(JSON.stringify(val, null, 2))}
    +
    +
    `; + } else { + html += `
    + ${k} + ${val ?? "—"} +
    `; + } + }); + html += "
    "; + } + + return html; +} + +function renderFairAssessmentResult(data, resultContainer) { + if (!resultContainer) return false; + if (data.error) { + resultContainer.innerHTML = ``; + return false; + } + resultContainer.innerHTML = buildFairAssessmentResultHtml(data); + return true; +} + +function submitFairAssessmentForm(form, resultContainer, callbacks) { + if (!form) return Promise.resolve(); const formData = new FormData(form); - const resultContainer = document.getElementById("fair-result-container"); - if (resultContainer) - resultContainer.innerHTML = '

    Processing...

    '; + if (resultContainer) { + resultContainer.classList.remove("hidden"); + resultContainer.innerHTML = '

    Processing…

    '; + } - // Return the promise so withSubmitGuard re-enables the button when it settles. return fetch("/fair-assessment", { method: "POST", body: formData }) .then((response) => response.json()) .then((data) => { - if (!resultContainer) return; - - // Check for error response - if (data.error) { - resultContainer.innerHTML = ``; - return; + const resultData = { ...data }; + delete resultData.cached; + delete resultData.build_time_seconds; + const ok = renderFairAssessmentResult(resultData, resultContainer); + if (ok) { + callbacks?.onSuccess?.(resultData, data); + } else { + callbacks?.onError?.(data); + } + }) + .catch((error) => { + console.error("Error:", error); + if (resultContainer) { + resultContainer.innerHTML = ``; } + callbacks?.onError?.(error); + }); +} - let html = ""; +function submitFairAssessment() { + return submitFairAssessmentForm( + document.getElementById("form-fair-assessment"), + document.getElementById("fair-result-container"), + ); +} - // Compliance summary bar — extract from FAIR Compliance Checks - const checks = data["FAIR Compliance Checks"] || {}; - const totalCheck = checks["Total Checks"] || ""; - const totalMatch = totalCheck.match(/(\d+)\/(\d+)/); - const totalPassed = totalMatch ? parseInt(totalMatch[1]) : 0; - const totalExpected = totalMatch ? parseInt(totalMatch[2]) : 1; - const totalPct = Math.round((totalPassed / totalExpected) * 100); +function submitReadinessFairAssessment() { + _readinessFairCompliance = { status: "computing", data: null }; + const uploadEl = document.getElementById("readiness-fair-upload"); + const resultsEl = document.getElementById("readiness-fair-results"); + if (resultsEl) resultsEl.classList.remove("hidden"); + + return submitFairAssessmentForm( + document.getElementById("form-readiness-fair"), + resultsEl, + { + onSuccess(data) { + _readinessFairCompliance = { status: "ok", data }; + uploadEl?.classList.add("hidden"); + }, + onError() { + _readinessFairCompliance = { status: "error", data: null }; + }, + }, + ); +} - html += `
    -
    -

    FAIR Compliance

    - ${totalPassed}/${totalExpected} checks passed -
    -
    -
    -
    -
    `; - - // Per-principle mini bars - const fairKeys = ["Findable", "Accessible", "Interoperable", "Reusable"]; - fairKeys.forEach((k) => { - const checkStr = checks[`${k} Checks`] || "0/0"; - const m = checkStr.match(/(\d+)\/(\d+)/); - const passed = m ? parseInt(m[1]) : 0; - const total = m ? parseInt(m[2]) : 1; - const pct = Math.round((passed / total) * 100); - html += `
    -
    ${k}
    -
    ${passed}/${total}
    -
    -
    -
    -
    `; - }); - html += "
    "; - - // FAIR principle details as collapsible accordions - html += '
    '; - fairKeys.forEach((k) => { - let val = "—"; - let checkStr = checks[`${k} Checks`] || ""; - if (data[k] !== undefined && typeof data[k] === "object") { - val = renderFairValue(data[k]); - } else if (data[k] !== undefined) { - val = `
    ${data[k]}
    `; - } - html += `
    - - ${k} - ${checkStr} - -
    ${val}
    -
    `; - }); - html += "
    "; +function _restoreReadinessFairFromCache(fairPayload) { + const data = fairPayload?.data; + if (!data || data.error) return false; - // Other data (FAIR Compliance Checks, Other, Original Metadata) - const extraKeys = Object.keys(data).filter( - (k) => !fairKeys.includes(k) && k !== "Pie chart", - ); - if (extraKeys.length > 0) { - html += - '
    '; - html += - '

    Detailed Results

    '; - extraKeys.forEach((k) => { - const val = data[k]; - if (typeof val === "object" && val !== null) { - html += `
    - - ${k} - - -
    -
    ${escapeHtml(JSON.stringify(val, null, 2))}
    -
    -
    `; - } else { - html += `
    - ${k} - ${val ?? "—"} -
    `; - } - }); - html += "
    "; - } + const uploadEl = document.getElementById("readiness-fair-upload"); + const resultsEl = document.getElementById("readiness-fair-results"); + if (!resultsEl) return false; + + _readinessFairCompliance = { status: "ok", data }; + uploadEl?.classList.add("hidden"); + resultsEl.classList.remove("hidden"); + renderFairAssessmentResult(data, resultsEl); + + const metadataType = fairPayload.metadata_type; + if (metadataType) { + const select = document.getElementById("readiness-fair-metadata-type"); + if (select) select.value = metadataType; + } + const metadataFilename = fairPayload.metadata_filename; + if (metadataFilename) { + const label = document.getElementById("readinessFairFileLabel"); + const icon = document.getElementById("readinessFairUploadIcon"); + if (label) label.textContent = metadataFilename; + if (icon) { + icon.classList.remove("text-gray-400"); + icon.classList.add("text-green-500"); + } + } + return true; +} - resultContainer.innerHTML = html; +/** Restore optional FAIR compliance from server cache (separate lightweight fetch). */ +function _tryRestoreCachedReadinessFair() { + return fetch("/cached-result/readiness_report_fair") + .then((r) => r.json()) + .then((resp) => { + if (!resp.cached || !resp.fair_compliance || activePanel !== "readiness-report") { + return false; + } + return _restoreReadinessFairFromCache(resp.fair_compliance); }) - .catch((error) => { - console.error("Error:", error); - if (resultContainer) - resultContainer.innerHTML = ``; + .catch((err) => { + debugLog("Readiness FAIR cache restore error:", err); + return false; }); } +function _resetReadinessFairSection() { + _readinessFairCompliance = { status: "idle", data: null }; + const uploadEl = document.getElementById("readiness-fair-upload"); + const resultsEl = document.getElementById("readiness-fair-results"); + const form = document.getElementById("form-readiness-fair"); + uploadEl?.classList.remove("hidden"); + if (resultsEl) { + resultsEl.classList.add("hidden"); + resultsEl.innerHTML = ""; + } + form?.reset(); + const label = document.getElementById("readinessFairFileLabel"); + const icon = document.getElementById("readinessFairUploadIcon"); + if (label) label.textContent = "JSON metadata file"; + if (icon) { + icon.innerHTML = + ''; + icon.classList.remove("text-green-500"); + icon.classList.add("text-gray-400"); + } +} + +function initReadinessFairSection() { + const form = document.getElementById("form-readiness-fair"); + if (!form || form.dataset.wired === "1") return; + form.dataset.wired = "1"; + _wireFairFileInput( + document.getElementById("readiness-fair-file"), + document.getElementById("readinessFairFileLabel"), + document.getElementById("readinessFairUploadIcon"), + ); +} + /** Render a FAIR value object as readable HTML with pass/fail badges */ function renderFairValue(obj) { if (typeof obj !== "object" || obj === null) return String(obj ?? "—"); @@ -1953,11 +2130,24 @@ function submitCustomMetric() { /** * Render histogram images in the data overview panel. * @param {Object} histograms - Dict of {column_theme: base64_img} from /summary-statistics + * @param {string} [containerId] + * @param {boolean} [skipHeading] + * @param {string} [layout] - "compact" (default) or "large" for readiness report */ -function renderWorkspaceHistograms(histograms) { - const container = document.getElementById("workspace-histograms"); +function renderWorkspaceHistograms(histograms, containerId, skipHeading, layout) { + const container = document.getElementById( + containerId || "workspace-histograms", + ); if (!container) return; + const isLarge = layout === "large"; + const gridClass = isLarge + ? "grid grid-cols-1 sm:grid-cols-2 gap-6" + : "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4"; + const imgClass = isLarge + ? "w-full h-auto object-contain" + : "w-full"; + // Always use the light variant — CSS filter handles dark mode const columns = {}; for (const [key, base64] of Object.entries(histograms)) { @@ -1972,14 +2162,17 @@ function renderWorkspaceHistograms(histograms) { return; } - let html = - '

    Feature Distributions

    '; - html += '
    '; + let html = ""; + if (!skipHeading) { + html += + '

    Feature Distributions

    '; + } + html += `
    `; for (const [colName, base64] of Object.entries(columns)) { html += `
    - Distribution of ${colName} + Distribution of ${colName}
    ${colName}
    `; @@ -1989,26 +2182,47 @@ function renderWorkspaceHistograms(histograms) { container.innerHTML = html; } -// ==================== Workspace Init ==================== +/** + * Render categorical distribution pie charts (base64 PNG per column). + */ +function renderCategoricalPieCharts(charts, containerId) { + const container = document.getElementById(containerId); + if (!container || !charts) return; + + const entries = Object.entries(charts); + if (entries.length === 0) { + container.innerHTML = ""; + return; + } + + let html = '
    '; + for (const [colName, base64] of entries) { + html += ` +
    + Distribution of ${colName} +
    `; + } + html += "
    "; + container.innerHTML = html; +} /** - * Initialize the workspace after file upload. - * Fetches summary statistics and populates feature dropdowns. + * Fetch summary statistics from the backend and render the stat cards, + * summary table, and feature-distribution histograms into the given + * containers. Reused by both the Data Overview panel and the + * Readiness Report panel. + * + * @param {string} summaryContainerId - element ID for the stats/table. + * @param {string} histogramsContainerId - element ID for the histograms. */ -function initWorkspace() { - // Restore panel from URL hash, or default to data-overview - const hash = location.hash.replace("#", ""); - const initialPanel = - hash && document.getElementById("panel-" + hash) ? hash : "data-overview"; - showPanel(initialPanel, false); // false = don't push to history on init - // Replace current history entry so back button works from the first panel - history.replaceState({ panel: initialPanel }, "", "#" + initialPanel); +function loadDataOverview(summaryContainerId, histogramsContainerId) { + const summaryId = summaryContainerId || "workspace-summary"; + const histogramsId = histogramsContainerId || "workspace-histograms"; - // Fetch summary statistics fetch("/summary-statistics") .then((r) => r.json()) .then((data) => { - const container = document.getElementById("workspace-summary"); + const container = document.getElementById(summaryId); if (!container) return; if (data.success) { @@ -2084,9 +2298,9 @@ function initWorkspace() { container.innerHTML = html; - // Render histograms in the data overview panel + // Render histograms below the summary table if (data.histograms) { - renderWorkspaceHistograms(data.histograms); + renderWorkspaceHistograms(data.histograms, histogramsId); } } else { container.innerHTML = ` @@ -2097,73 +2311,2058 @@ function initWorkspace() { } }) .catch((err) => { - const container = document.getElementById("workspace-summary"); + const container = document.getElementById(summaryId); if (container) container.innerHTML = `

    Error loading summary: ${err.message}

    `; }); +} - // Populate feature dropdowns via /feature-set (same as metric.js does) - fetch("/feature-set", { method: "POST" }) +/** + * Map a readiness status string to Tailwind color classes. + */ +function _dqStatusClasses(status) { + switch (status) { + case "good": + return { + text: "text-green-700 dark:text-green-400", + bar: "bg-green-500", + badge: + "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400", + }; + case "warning": + return { + text: "text-amber-700 dark:text-amber-400", + bar: "bg-amber-500", + badge: + "bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400", + }; + case "poor": + return { + text: "text-red-700 dark:text-red-400", + bar: "bg-red-500", + badge: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400", + }; + default: + return { + text: "text-gray-500 dark:text-gray-400", + bar: "bg-gray-400", + badge: + "bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300", + }; + } +} + +/** Coerce readiness metric values to finite numbers. */ +function _readinessNums(values) { + return (values || []) + .map((v) => (typeof v === "number" ? v : parseFloat(v))) + .filter((v) => !Number.isNaN(v)); +} + +/** + * Decimal places for a group: default 2; if every value rounds to 0.00, use more + * (up to maxDecimals) so small non-zero values remain visible. Same precision + * is used for every value in the group. + */ +function _readinessDecimalPlaces(values, { minDecimals = 2, maxDecimals = 6 } = {}) { + const nums = _readinessNums(values); + if (!nums.length) return minDecimals; + if (nums.every((v) => v === 0)) return minDecimals; + + for (let d = minDecimals; d <= maxDecimals; d++) { + const allRoundedZero = nums.every((v) => Number(v.toFixed(d)) === 0); + if (!allRoundedZero) return d; + } + return maxDecimals; +} + +/** Decimal places for 0–1 ratios shown as percentages (value × 100). */ +function _readinessPctDecimals(values, options) { + return _readinessDecimalPlaces( + _readinessNums(values).map((v) => v * 100), + options, + ); +} + +/** Format a number with fixed decimals; integers omit the fractional part. */ +function _readinessNum(value, decimals = 2) { + if (value === null || value === undefined || Number.isNaN(value)) return "N/A"; + if (typeof value !== "number") return String(value); + if (Number.isInteger(value)) return value.toLocaleString(); + return value.toFixed(decimals); +} + +/** Build a formatter that uses one decimal precision for every value in *values*. */ +function _readinessNumFormatter(values, options) { + const nums = _readinessNums(values); + if (nums.length && nums.every((v) => Number.isInteger(v))) { + return (value) => { + if (value === null || value === undefined || Number.isNaN(value)) return "N/A"; + return Number(value).toLocaleString(); + }; + } + const decimals = _readinessDecimalPlaces(nums, options); + return (value) => _readinessNum(value, decimals); +} + +/** Build a percentage formatter (0–1 input) with group-consistent decimals. */ +function _readinessPctFormatter(values, options) { + const decimals = _readinessPctDecimals(values, options); + return (value) => _pct(value, decimals); +} + +/** Format a 0–1 ratio as a percentage, or "N/A" if missing. */ +function _pct(value, decimals = 2) { + if (value === null || value === undefined || Number.isNaN(value)) return "N/A"; + return `${(value * 100).toFixed(decimals)}%`; +} + +/** Show an error message inside one readiness section container. */ +function _readinessSectionError(container, message) { + if (!container) return; + container.classList.add("text-center", "py-8"); + container.innerHTML = `

    ${message}

    `; +} + +/** Human-readable section build duration (matches server log precision). */ +function _formatReadinessBuildTime(seconds) { + if (seconds === null || seconds === undefined || Number.isNaN(Number(seconds))) { + return ""; + } + return `Prepared in ${Number(seconds).toFixed(2)} seconds`; +} + +/** Append build-time footer at the bottom of a readiness section container. */ +function _appendReadinessBuildTimeFooter(container, seconds) { + if (!container) return; + const label = _formatReadinessBuildTime(seconds); + if (!label) return; + container.querySelector(".readiness-build-time")?.remove(); + const el = document.createElement("p"); + el.className = + "readiness-build-time text-xs text-gray-400 dark:text-gray-500 mt-4 pt-3 border-t border-gray-200 dark:border-gray-700 text-right"; + el.textContent = label; + container.appendChild(el); +} + +const _readinessVizCache = {}; + +/** Placeholder for a chart that loads when the details panel is opened. */ +function _readinessVizSlot(section, vizKey, title) { + return `
    +

    ${title}

    +
    Open this section to load chart…
    +
    `; +} + +function _readinessVizSpinnerHtml() { + return `
    + +
    `; +} + +function _applyReadinessVisualizations(section, root, vizMap) { + if (!root || !vizMap) return; + + if (section === "dataset-overview") { + if (vizMap.categorical_charts) { + const catHost = root.querySelector("#readiness-categorical-charts"); + if (catHost) { + renderCategoricalPieCharts(vizMap.categorical_charts, "readiness-categorical-charts"); + } + } + if (vizMap.histograms) { + const histHost = root.querySelector("#readiness-histograms-inner"); + if (histHost) { + renderWorkspaceHistograms( + vizMap.histograms, + "readiness-histograms-inner", + true, + "large", + ); + } + } + return; + } + + root.querySelectorAll(".readiness-viz-slot").forEach((slot) => { + const key = slot.dataset.readinessViz; + const target = slot.querySelector(".readiness-viz-content"); + if (!target || !key) return; + const b64 = vizMap[key]; + if (b64) { + target.innerHTML = `${_escapeHtml(key)}`; + } else { + target.innerHTML = `

    Chart unavailable.

    `; + } + }); +} + +function _loadReadinessSectionVisualizations(section, root) { + if (!root) return Promise.resolve(); + const slots = root.querySelectorAll(".readiness-viz-slot"); + if (!section || (!slots.length && section !== "dataset-overview")) { + return Promise.resolve(); + } + + if (_readinessVizCache[section]) { + _applyReadinessVisualizations(section, root, _readinessVizCache[section]); + return Promise.resolve(); + } + + slots.forEach((slot) => { + const target = slot.querySelector(".readiness-viz-content"); + if (target) target.innerHTML = _readinessVizSpinnerHtml(); + }); + const catHost = root.querySelector("#readiness-categorical-charts"); + const histHost = root.querySelector("#readiness-histograms-inner"); + if (catHost && !catHost.childElementCount) catHost.innerHTML = _readinessVizSpinnerHtml(); + if (histHost && !histHost.childElementCount) histHost.innerHTML = _readinessVizSpinnerHtml(); + + return fetch(`/readiness-report/${section}/visualizations`) .then((r) => r.json()) - .then((data) => { - if (data.success && typeof populateWorkspaceDropdowns === "function") { - populateWorkspaceDropdowns(data); + .then((resp) => { + if (!resp.success) { + const msg = resp.message || "Could not load charts"; + slots.forEach((slot) => { + const target = slot.querySelector(".readiness-viz-content"); + if (target) target.innerHTML = `

    ${_escapeHtml(msg)}

    `; + }); + return; } + _readinessVizCache[section] = resp.visualizations || {}; + _applyReadinessVisualizations(section, root, _readinessVizCache[section]); }) - .catch((err) => console.error("Error fetching features:", err)); + .catch((err) => { + slots.forEach((slot) => { + const target = slot.querySelector(".readiness-viz-content"); + if (target) { + target.innerHTML = `

    ${_escapeHtml(err.message)}

    `; + } + }); + }); +} - // Feature relevance: disable target feature in checkbox lists - const targetDropdown = document.getElementById( - "all-features-dropdown-feature-relevance", +/** Fetch charts the first time a readiness details panel is expanded. */ +function _wireReadinessDetailsViz(detailsEl, section) { + if (!detailsEl || detailsEl.dataset.vizWired === "1") return; + detailsEl.dataset.vizWired = "1"; + detailsEl.addEventListener("toggle", () => { + if (!detailsEl.open) return; + _loadReadinessSectionVisualizations(section, detailsEl); + }); +} + +/** + * Fetch one readiness-report section and render it when ready. + * @param {string} section - URL slug (e.g. "data-quality") + * @param {HTMLElement} container + * @param {Function} renderFn - (container, data) => void + * @returns {Promise} + */ +function _getReadinessSectionRenderers() { + return [ + { + section: "dataset-overview", + container: document.getElementById("readiness-summary"), + render: renderReadinessDatasetOverview, + }, + { + section: "data-quality", + container: document.getElementById("readiness-data-quality"), + render: renderReadinessDataQuality, + }, + { + section: "impact-on-ai", + container: document.getElementById("readiness-impact"), + render: renderReadinessImpact, + }, + { + section: "fairness-bias", + container: document.getElementById("readiness-fairness"), + render: renderReadinessFairness, + }, + { + section: "data-governance", + container: document.getElementById("readiness-governance"), + render: renderReadinessGovernance, + }, + ]; +} + +function _initReadinessReportShell() { + _wireReadinessExportButton(); + _resetReadinessFairSection(); + initReadinessFairSection(); + _READINESS_REPORT_SECTIONS.forEach((section) => { + _readinessSectionStatus[section] = "pending"; + }); + _updateReadinessExportButton(); +} + +function _applyReadinessSection(container, section, data, renderFn, buildTimeSeconds) { + if (!container) { + _readinessSectionStatus[section] = "error"; + return false; + } + if (data.error) { + _readinessSectionStatus[section] = "error"; + _readinessSectionError(container, data.error); + return false; + } + _readinessSectionStatus[section] = "ok"; + container.classList.remove("text-center", "py-8"); + renderFn(container, data); + _appendReadinessBuildTimeFooter( + container, + buildTimeSeconds ?? data.build_time_seconds, ); - if (targetDropdown) { - targetDropdown.addEventListener("change", function () { - const target = this.value; - // In both cat and num checkbox containers, disable the checkbox matching the target - ["catFeaturesCheckbox1", "numFeaturesCheckbox1"].forEach( - (containerId) => { - const container = document.getElementById(containerId); - if (!container) return; - container.querySelectorAll('input[type="checkbox"]').forEach((cb) => { - if (cb.value === target) { - cb.checked = false; - cb.disabled = true; - cb.closest("label").style.opacity = "0.4"; - } else { - cb.disabled = false; - cb.closest("label").style.opacity = "1"; - } - }); - }, + return true; +} + +function _fetchReadinessSection(section, container, renderFn) { + if (!container) { + _readinessSectionStatus[section] = "error"; + _updateReadinessExportButton(); + return Promise.resolve(); + } + return fetch(`/readiness-report/${section}`) + .then((r) => r.json()) + .then((resp) => { + if (!resp.success) { + _readinessSectionStatus[section] = "error"; + _readinessSectionError( + container, + `Could not load ${section.replace(/-/g, " ")}: ${resp.message || "unknown error"}`, + ); + return; + } + const data = resp.data || {}; + _applyReadinessSection( + container, + section, + data, + renderFn, + resp.build_time_seconds, ); + }) + .catch((err) => { + _readinessSectionStatus[section] = "error"; + _readinessSectionError(container, `Error loading section: ${err.message}`); + }) + .finally(() => { + _updateReadinessExportButton(); }); - } +} - // Handle FAIR assessment file input UI - const fairFile = document.getElementById("fair-file"); - const fairLabel = document.getElementById("fairFileLabel"); - const fairIcon = document.getElementById("fairUploadIcon"); - if (fairFile && fairLabel) { - fairFile.addEventListener("change", () => { - if (fairFile.files.length) { - fairLabel.textContent = fairFile.files[0].name; - if (fairIcon) { - fairIcon.innerHTML = - ''; - fairIcon.classList.remove("text-gray-400"); - fairIcon.classList.add("text-green-500"); +/** + * Restore the readiness report from the aggregated server cache (one request). + * @returns {Promise} true when all sections were restored from cache + */ +function _restoreCachedReadinessReport() { + return fetch("/cached-result/readiness_report") + .then((r) => r.json()) + .then((resp) => { + if (!resp.cached || !resp.sections || activePanel !== "readiness-report") { + return false; + } + _initReadinessReportShell(); + let allOk = true; + _getReadinessSectionRenderers().forEach(({ section, container, render }) => { + const data = resp.sections[section]; + if (!data) { + _readinessSectionStatus[section] = "error"; + allOk = false; + return; + } + if ( + !_applyReadinessSection( + container, + section, + data, + render, + data.build_time_seconds, + ) + ) { + allOk = false; + } + }); + _updateReadinessExportButton(); + if (resp.fair_compliance) { + _restoreReadinessFairFromCache(resp.fair_compliance); + } + return allOk; + }) + .catch((err) => { + debugLog("Readiness cache restore error:", err); + return false; + }); +} + +/** + * Load the readiness report with hybrid progressive rendering: + * dataset overview first, then remaining sections in parallel. + */ +function loadReadinessReport() { + _initReadinessReportShell(); + _tryRestoreCachedReadinessFair(); + + const overviewEntry = _getReadinessSectionRenderers().find( + ({ section }) => section === "dataset-overview", + ); + const parallelSections = _getReadinessSectionRenderers().filter( + ({ section }) => section !== "dataset-overview", + ); + + const loadParallelSections = () => { + Promise.all( + parallelSections.map(({ section, container, render }) => + _fetchReadinessSection(section, container, render), + ), + ); + }; + + if (!overviewEntry?.container) { + loadParallelSections(); + return; + } + + // Hybrid: overview first, then parallel for the rest (spinners stay until each resolves). + _fetchReadinessSection( + overviewEntry.section, + overviewEntry.container, + overviewEntry.render, + ).finally(loadParallelSections); +} + +function _readinessAllSectionsReady() { + return _READINESS_REPORT_SECTIONS.every( + (section) => _readinessSectionStatus[section] === "ok", + ); +} + +function _updateReadinessExportButton() { + const bar = document.getElementById("readiness-export-bar"); + const scorecardBtn = document.getElementById("readiness-export-scorecard-pdf-btn"); + const fullBtn = document.getElementById("readiness-export-full-pdf-btn"); + if (!bar) return; + bar.classList.remove("hidden"); + [scorecardBtn, fullBtn].forEach((btn) => { + if (btn && !btn.hasAttribute("aria-busy")) { + btn.disabled = false; + } + }); +} + +function _wireReadinessExportButton() { + const scorecardBtn = document.getElementById("readiness-export-scorecard-pdf-btn"); + const fullBtn = document.getElementById("readiness-export-full-pdf-btn"); + if (scorecardBtn && scorecardBtn.dataset.wired !== "1") { + scorecardBtn.dataset.wired = "1"; + scorecardBtn.addEventListener("click", () => { + exportReadinessReportPdf("scorecard"); + }); + } + if (fullBtn && fullBtn.dataset.wired !== "1") { + fullBtn.dataset.wired = "1"; + fullBtn.addEventListener("click", () => { + exportReadinessReportPdf("full"); + }); + } +} + +function _readinessPdfFilename() { + const panel = document.getElementById("panel-readiness-report"); + const raw = panel?.dataset?.datasetName || window.AIDRIN_DATASET_NAME || "dataset"; + const stem = String(raw).replace(/\.[^.]+$/, "").replace(/[^\w.-]+/g, "_"); + const date = new Date().toISOString().slice(0, 10); + return `readiness-report-${stem || "dataset"}-${date}.pdf`; +} + +function _filenameFromContentDisposition(header) { + if (!header) return null; + const match = /filename\*?=(?:UTF-8''|")?([^";]+)/i.exec(header); + if (!match) return null; + try { + return decodeURIComponent(match[1].replace(/"/g, "")); + } catch (_e) { + return match[1].replace(/"/g, ""); + } +} + +/** Download readiness report PDF from the server (sync build-on-miss). */ +function exportReadinessReportPdf(mode = "scorecard") { + const isFull = mode === "full"; + const scorecardBtn = document.getElementById("readiness-export-scorecard-pdf-btn"); + const fullBtn = document.getElementById("readiness-export-full-pdf-btn"); + const scorecardLabel = document.getElementById("readiness-export-scorecard-pdf-label"); + const fullLabel = document.getElementById("readiness-export-full-pdf-label"); + const buttons = [scorecardBtn, fullBtn].filter(Boolean); + + buttons.forEach((el) => { + el.disabled = true; + el.setAttribute("aria-busy", "true"); + }); + if (isFull && fullLabel) fullLabel.textContent = "Preparing full PDF…"; + if (!isFull && scorecardLabel) scorecardLabel.textContent = "Preparing PDF…"; + + const url = isFull ? "/readiness-report/pdf?mode=full" : "/readiness-report/pdf"; + + return fetch(url) + .then(async (response) => { + if (!response.ok) { + let message = `PDF export failed (${response.status})`; + try { + const data = await response.json(); + if (data?.message) message = data.message; + } catch (_e) { + /* ignore */ } + throw new Error(message); + } + const filename = + _filenameFromContentDisposition(response.headers.get("Content-Disposition")) || + _readinessPdfFilename(); + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + link.remove(); + URL.revokeObjectURL(url); + }) + .catch((err) => { + console.error("Readiness PDF export failed:", err); + alert(err.message || "Could not generate PDF."); + }) + .finally(() => { + buttons.forEach((el) => { + el.disabled = false; + el.removeAttribute("aria-busy"); + }); + if (scorecardLabel) scorecardLabel.textContent = "Scorecard PDF"; + if (fullLabel) fullLabel.textContent = "Full report PDF"; + }); +} + +function _exportReadinessReportPdf() { + return exportReadinessReportPdf("scorecard"); +} + +/** Escape text for safe inclusion in readiness info tooltips. */ +function _escapeHtml(s) { + return String(s) + .replace(/&/g, "&") + .replace(/ `${k}=${v}`) + .join(", "); +} + +/** + * One needs-attention list row with optional secondary line (context/detail). + * *primary* and *secondary* may contain safe HTML built by callers; user text + * must be escaped before passing in. + */ +function _readinessNaRow(primary, secondary, value) { + const valHtml = value + ? `${_escapeHtml(value)}` + : ""; + const sub = + secondary + ? `

    ${secondary}

    ` + : ""; + return `
  • +
    + ${primary} + ${valHtml} +
    ${sub} +
  • `; +} + +/** Needs-attention subsection with title and list body. */ +function _readinessNaBlock(title, infoKey, contextHtml, listHtml, tone) { + const titleCls = + tone === "red" + ? "text-red-700 dark:text-red-400" + : tone === "amber" + ? "text-amber-700 dark:text-amber-400" + : "text-gray-600 dark:text-gray-300"; + const ctx = contextHtml + ? `

    ${contextHtml}

    ` + : ""; + return `
    +

    ${title}${infoKey ? _readinessInfoIcon(infoKey) : ""}

    + ${ctx} +
      ${listHtml}
    +
    `; +} + +/** Wrap needs-attention blocks in the standard amber/green panel. */ +function _renderReadinessNeedsAttentionPanel(naItems, emptyMessage) { + if (naItems.length) { + return `
    +

    Needs attention

    +
    ${naItems.join("")}
    +
    `; + } + return `
    +

    ${emptyMessage}

    +
    `; +} + +/** Readiness metric definitions shown in info-icon tooltips. */ +const _READINESS_METRIC_INFO = { + feature_profile: + "A per-column snapshot of whether each feature is usable for modeling. Combines missingness, cardinality, and value balance into a readiness status (Good / Warning / Poor).", + pct_missing: + "Share of rows where this feature is missing (null/NaN). High missingness reduces reliability and may require imputation or dropping the column.", + n_unique: + "Number of distinct non-missing values. Very low values suggest constants; very high values relative to row count may indicate IDs or free text.", + pct_dominant: + "Share of rows taken by the most frequent value (the mode). Values near 100% mean the column is almost constant and usually carries little signal.", + profile_status: + "Readiness verdict for this feature. Poor: high missingness, constant, or ID-like. Warning: moderate issues. Good: no major issues detected.", + overall_dq_grade: + "Average of the data-quality KPIs (completeness, uniqueness, outlier-cleanliness). Higher is better — indicates how clean the dataset is overall.", + analysis_scope: + "This section evaluates every column automatically; no features or targets are chosen by the user.", + completeness: + "Overall share of non-missing values across all features. Same measure as the Completeness metric on the Data Quality tab.", + uniqueness: + "1 minus the proportion of duplicate rows. Low uniqueness means many exact duplicate records, which can bias models and inflate metrics.", + outlier_cleanliness: + "1 minus the mean outlier proportion across numerical features (IQR method). Lower values mean more extreme values that may need review.", + features_analyzed: + "Number of columns included in the automated correlation scan after pruning constants, ID-like fields, and high-cardinality categoricals.", + leakage_risk_pairs: + "Feature pairs with correlation |score| ≥ 0.95 — nearly duplicate or derived from each other. Can inflate model performance or indicate redundant inputs (not necessarily target leakage).", + redundant_pairs: + "Feature pairs with correlation |score| between 0.8 and 0.95 — strongly related and likely redundant. Consider keeping only one from each pair.", + isolated_features: + "Features whose strongest correlation to any other feature is below 0.1. May be uninformative noise, identifiers, or weakly related fields worth reviewing.", + most_related_pairs: + "The feature pairs with the highest absolute correlation scores from the automated scan — quick view of the strongest relationships in the data.", + overall_impact_grade: + "Average of impact KPIs (leakage safety, redundancy, informativeness). Higher suggests healthier feature structure for modeling.", + leakage_safety: + "Whether any feature pairs exceed the leakage-risk correlation threshold (|score| ≥ 0.95). Fewer or no pairs is better.", + redundancy: + "Derived from the count of highly correlated redundant pairs (|score| ≥ 0.8). Lower redundancy is generally preferable.", + informativeness: + "Share of analyzed features that have at least one meaningful correlation to another feature — flags isolated or uninformative columns.", + overall_fairness_grade: + "Average of fairness KPIs (representation balance, label balance, outcome parity). Higher suggests more balanced representation and outcomes under the automated checks.", + representation_balance: + "1 divided by the worst group probability ratio across auto-selected sensitive attributes. Low values mean some groups are much more represented than others.", + label_balance: + "Derived from the Imbalance Degree of the auto-selected target column. 0 means perfectly balanced classes; higher imbalance degree means a skewed label distribution.", + outcome_parity: + "1 minus the maximum TSD (standard deviation of class rates across sensitive groups). Flags when outcome rates differ substantially by group.", + representation_imbalance: + "Sensitive attributes where the largest group probability ratio exceeds the threshold — one category dominates representation.", + minority_classes: + "Target classes that make up less than 5% of rows. Rare classes are harder to learn and can hurt model fairness and recall.", + outcome_disparities: + "Target classes whose outcome rates vary most across sensitive groups (high TSD). Suggests uneven outcomes by group.", + cdd_disparities: + "Sensitive groups flagged by Conditional Demographic Disparity — rejected outcomes outweigh accepted ones disproportionately (positive class is auto-selected as the most frequent target value).", + overall_governance_grade: + "Average of governance KPIs (anonymity, diversity, distribution leakage, linkage risk, PHI exposure). Higher suggests lower privacy and compliance risk under automated checks.", + anonymity_k: + "Minimum equivalence-class size (k) on auto-selected quasi-identifiers. Higher k means each QI combination appears in at least k rows — harder to re-identify individuals.", + diversity_l: + "Minimum l-diversity on the auto-selected sensitive attribute within QI groups. Higher l means more distinct sensitive values per group — harder to infer a specific sensitive value.", + distribution_t: + "Maximum t-closeness (TVD) between group and global sensitive-attribute distributions. Lower t means groups do not reveal unusually skewed sensitive information.", + single_linkage_risk: + "Worst mean Marketer/Prosecutor re-identification risk across single quasi-identifiers. Higher risk means one field alone can identify many individuals.", + linkage_risk: + "Mean MM re-identification risk when all auto-selected quasi-identifiers are combined — the realistic linkage-attack scenario.", + phi_exposure: + "HIPAA-style pattern scan on auto-selected text columns. Flags potential SSNs, medical IDs, postal codes, emails, etc. Not a full regulatory certification.", + low_anonymity: + "Privacy metrics (e.g. k-Anonymity) below warning thresholds — small equivalence classes increase re-identification risk.", + hipaa_phi: + "Columns where HIPAA-like identifier patterns were detected during the automated scan.", + high_linkage_risk: + "Quasi-identifiers or QI combinations with high Marketer/Prosecutor re-identification risk scores.", + attribute_disclosure: + "l-Diversity or t-Closeness signals suggesting sensitive-attribute values may be inferable within QI groups.", +}; + +/** + * Info-icon tooltip matching existing metric panels (see theme.css .info-icon). + * @param {string} key - key in _READINESS_METRIC_INFO + */ +function _readinessInfoIcon(key) { + const text = _READINESS_METRIC_INFO[key]; + if (!text) return ""; + return `i${_escapeHtml(text)}`; +} + +/** Table header cell with label + info tooltip. */ +function _readinessTh(label, infoKey, alignRight) { + const align = alignRight ? " text-right" : ""; + return `${label}${_readinessInfoIcon(infoKey)}`; +} + +/** Format byte count as a human-readable size string. */ +function _formatBytes(bytes) { + if (bytes == null || isNaN(bytes)) return "—"; + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +/** Badge HTML for per-feature readiness status. */ +function _profileStatusBadge(status) { + const cls = _dqStatusClasses(status); + const label = status === "good" ? "Good" : status === "warning" ? "Warning" : status === "poor" ? "Poor" : "—"; + return `${label}`; +} + +/** + * Render the dataset overview: file metadata, KPI tiles, per-feature readiness + * profile, and collapsible detailed statistics / distributions / histograms. + */ +function renderReadinessDatasetOverview(container, overview) { + if (overview.error) { + container.innerHTML = `

    Dataset overview unavailable: ${overview.error}

    `; + return; + } + + const meta = overview.file_metadata || {}; + const profiles = overview.feature_profiles || []; + const profileMeta = overview.feature_profiles_meta || {}; + const statusCounts = profileMeta.status_counts || {}; + const poorCount = + statusCounts.poor ?? profiles.filter((p) => p.status === "poor").length; + const warnCount = + statusCounts.warning ?? profiles.filter((p) => p.status === "warning").length; + + // --- File metadata --- + let html = ` +
    +

    ${meta.file_name || "Dataset"}

    +
    +
    Type: ${meta.file_type || "—"}
    +
    Size: ${_formatBytes(meta.file_size_bytes)}
    +
    Memory: ${_formatBytes(meta.memory_bytes)}
    +
    Rows: ${(meta.rows || 0).toLocaleString()}
    +
    Columns: ${meta.columns || 0}
    +
    Numerical: ${meta.numerical_count || 0}
    +
    Categorical: ${meta.categorical_count || 0}
    +
    Other: ${(meta.datetime_count || 0) + (meta.boolean_count || 0)}
    +
    +
    `; + + // --- KPI tiles --- + html += ` +
    +
    +
    ${(meta.rows || 0).toLocaleString()}
    +
    Records
    +
    +
    +
    ${meta.columns || 0}
    +
    Features
    +
    +
    +
    ${meta.numerical_count || 0}
    +
    Numerical
    +
    +
    +
    ${meta.categorical_count || 0}
    +
    Categorical
    +
    +
    `; + + // --- Per-feature readiness profile --- + html += ` +
    +

    Per-feature readiness profile${_readinessInfoIcon("feature_profile")}

    + + ${poorCount ? `${poorCount} poor` : ""} + ${warnCount ? `${poorCount ? " · " : ""}${warnCount} warning` : ""} + ${!poorCount && !warnCount ? "all good" : ""} + +
    `; + + if (profileMeta.truncated) { + html += `

    Showing ${profileMeta.shown.toLocaleString()} of ${profileMeta.total.toLocaleString()} features (prioritized: poor → warning → good).

    `; + } + + html += `
    `; + html += ``; + html += ``; + html += ``; + html += _readinessTh("% missing", "pct_missing", true); + html += _readinessTh("# unique", "n_unique", true); + html += _readinessTh("% dominant", "pct_dominant", true); + html += _readinessTh("Status", "profile_status", false); + html += ``; + html += ``; + + const profilePctValues = profiles.flatMap((p) => + [p.pct_missing, p.pct_dominant].filter((v) => v != null), + ); + const fmtProfilePct = _readinessPctFormatter(profilePctValues); + + profiles.forEach((p, i) => { + const stripe = i % 2 === 0 ? "bg-white dark:bg-gray-800" : "bg-gray-50 dark:bg-gray-700/50"; + html += ``; + html += ``; + html += ``; + html += ``; + html += ``; + html += ``; + html += ``; + html += ``; + html += ``; + html += ``; + }); + html += `
    FeatureTypeDtypeSummary
    ${p.feature}${p.type}${p.dtype}${fmtProfilePct(p.pct_missing)}${p.n_unique}${p.pct_dominant != null ? fmtProfilePct(p.pct_dominant) : "—"}${_profileStatusBadge(p.status)}${p.summary || "—"}
    `; + + // --- Collapsible detailed statistics --- + let detailsInner = ""; + + const numSummary = overview.numerical_summary || {}; + const numMeta = overview.numerical_summary_meta || {}; + const allNumFeatures = Object.keys(numSummary); + const numFeatures = allNumFeatures.slice(0, _READINESS_MAX_DETAIL_TABLE_ROWS); + if (numFeatures.length > 0) { + const allStats = Object.keys(numSummary[numFeatures[0]] || {}); + const preferredOrder = [ + "count", "min", "25th percentile", "50th percentile", "mean", + "75th percentile", "max", "std", + ]; + const statKeys = preferredOrder + .filter((s) => allStats.includes(s)) + .concat(allStats.filter((s) => !preferredOrder.includes(s))); + + const statFormatters = {}; + statKeys.forEach((s) => { + const colVals = numFeatures.map((feat) => numSummary[feat][s]); + statFormatters[s] = _readinessNumFormatter(colVals); + }); + + detailsInner += `

    Numerical summary statistics

    `; + detailsInner += `
    `; + detailsInner += ``; + statKeys.forEach((s) => { + detailsInner += ``; + }); + detailsInner += ``; + numFeatures.forEach((feat, i) => { + const stripe = i % 2 === 0 ? "bg-white dark:bg-gray-800" : "bg-gray-50 dark:bg-gray-700/50"; + detailsInner += ``; + statKeys.forEach((s) => { + const raw = numSummary[feat][s]; + const display = + raw === null || raw === undefined + ? "—" + : typeof raw === "number" + ? statFormatters[s](raw) + : raw; + detailsInner += ``; + }); + detailsInner += ``; + }); + detailsInner += `
    Feature${s}
    ${feat}${display}
    `; + if (numMeta.truncated || allNumFeatures.length > numFeatures.length) { + const total = numMeta.total || allNumFeatures.length; + const shown = numMeta.shown || numFeatures.length; + detailsInner += `

    Showing first ${shown} of ${total} numerical features. Profile table lists prioritized features.

    `; + } + } + + const catCharts = overview.categorical_charts || {}; + const catChartCols = Object.keys(catCharts); + const hasHistograms = + overview.histograms && Object.keys(overview.histograms).length > 0; + const vizDeferred = overview.visualizations_deferred; + const profileNumericalCount = profiles.filter((p) => p.type === "numerical").length; + const profileCategoricalCount = profiles.filter((p) => p.type === "categorical").length; + const showCatCharts = + catChartCols.length > 0 || + (vizDeferred && profileCategoricalCount > 0); + const showHistograms = + hasHistograms || (vizDeferred && profileNumericalCount > 0); + + if ((showCatCharts || showHistograms) && profileMeta.truncated) { + detailsInner += `

    Distribution charts use the same ${profileMeta.shown.toLocaleString()} features shown in the profile table above (prioritized: poor → warning → good).

    `; + } + + if (showCatCharts) { + detailsInner += `

    Categorical value distributions

    `; + detailsInner += `
    `; + } + + if (showHistograms) { + detailsInner += `

    Feature distributions (numerical)

    `; + detailsInner += `
    `; + } + + if (detailsInner) { + html += ` +
    + + Show detailed statistics & distributions + +
    ${detailsInner}
    +
    `; + } + + container.classList.remove("text-center", "py-8"); + container.innerHTML = html; + + if (vizDeferred) { + const detailsEl = container.querySelector("details"); + if (detailsEl && (showCatCharts || showHistograms)) { + _wireReadinessDetailsViz(detailsEl, "dataset-overview"); + } + } else { + if (catChartCols.length > 0) { + renderCategoricalPieCharts(overview.categorical_charts, "readiness-categorical-charts"); + } + if (hasHistograms) { + renderWorkspaceHistograms( + overview.histograms, + "readiness-histograms-inner", + true, + "large", + ); + } + } +} + +/** + * Render the Data Quality scorecard into the given container. + */ +function renderReadinessDataQuality(container, dq) { + if (dq.error) { + container.innerHTML = `

    Data quality unavailable: ${dq.error}

    `; + return; + } + const kpis = dq.kpis || []; + const gradeCls = _dqStatusClasses(dq.grade_status); + const scopeCrit = + (dq.auto_selection || {}).selection_criteria?.analysis_scope || {}; + const dqPctValues = [dq.grade, ...kpis.map((k) => k.value)].filter((v) => v != null); + const fmtDqPct = _readinessPctFormatter(dqPctValues); + + // --- Analysis scope (no column auto-selection required) --- + let html = ` +
    +

    Auto-selection criteria

    +
      +
    • Analysis scope: ${scopeCrit.selected || "all columns"}${_readinessInfoIcon("analysis_scope")}
      + ${scopeCrit.rule || "All columns are evaluated automatically."}
    • +
    +
    `; + + // --- Overall grade + KPI tiles --- + html += ` +
    + Overall data quality grade${_readinessInfoIcon("overall_dq_grade")} + ${fmtDqPct(dq.grade)} +
    +
    `; + + kpis.forEach((k) => { + const cls = _dqStatusClasses(k.status); + const widthPct = + k.value === null || k.value === undefined + ? 0 + : Math.max(0, Math.min(100, Math.round(k.value * 100))); + html += ` +
    +
    + ${k.label}${_readinessInfoIcon(k.id)} + ${fmtDqPct(k.value)} +
    +
    +
    +
    +

    ${k.hint || ""}

    +
    `; + }); + html += "
    "; + + // --- Needs attention --- + const na = dq.needs_attention || {}; + const incomplete = na.incomplete_features || []; + const outlierFeats = na.outlier_features || []; + const dupRows = na.duplicate_rows || 0; + const naPctValues = [ + ...incomplete.map((f) => f.completeness), + ...outlierFeats.map((f) => f.outlier_proportion), + dupRows > 0 ? dupRows : null, + ].filter((v) => v != null); + const fmtNaPct = _readinessPctFormatter(naPctValues); + + const naItems = []; + if (incomplete.length) { + const top = incomplete + .slice(0, 6) + .map( + (f) => + `
  • ${f.feature}${fmtNaPct(f.completeness)} complete
  • `, + ) + .join(""); + const more = + incomplete.length > 6 + ? `
  • +${incomplete.length - 6} more
  • ` + : ""; + naItems.push(` +
    +

    Incomplete features (${incomplete.length})${_readinessInfoIcon("completeness")}

    +
      ${top}${more}
    +
    `); + } + if (outlierFeats.length) { + const top = outlierFeats + .slice(0, 6) + .map( + (f) => + `
  • ${f.feature}${fmtNaPct(f.outlier_proportion)} outliers
  • `, + ) + .join(""); + const more = + outlierFeats.length > 6 + ? `
  • +${outlierFeats.length - 6} more
  • ` + : ""; + naItems.push(` +
    +

    Features with outliers (${outlierFeats.length})${_readinessInfoIcon("outlier_cleanliness")}

    +
      ${top}${more}
    +
    `); + } + if (dupRows && dupRows > 0) { + naItems.push(` +
    +

    Duplicate rows${_readinessInfoIcon("uniqueness")}

    +

    ${fmtNaPct(dupRows)} of rows are exact duplicates.

    +
    `); + } + + if (naItems.length) { + html += ` +
    +

    Needs attention

    +
    ${naItems.join("")}
    +
    `; } else { - fairLabel.textContent = "JSON metadata file"; - if (fairIcon) { - fairIcon.innerHTML = - ''; - fairIcon.classList.remove("text-green-500"); - fairIcon.classList.add("text-gray-400"); + html += ` +
    +

    No data quality issues detected — all features complete, no duplicates, no outliers.

    +
    `; + } + + // --- Collapsible details (original charts) --- + const det = dq.details || {}; + const vizDeferred = dq.visualizations_deferred; + let detailsInner = ""; + if (det.completeness) { + if (det.completeness.visualization) { + detailsInner += ` +
    +

    Completeness by feature

    + Completeness chart +
    `; + } else if (vizDeferred || det.completeness.visualization_deferred) { + detailsInner += _readinessVizSlot("data-quality", "completeness", "Completeness by feature"); + } + } + if (det.outliers) { + if (det.outliers.visualization) { + detailsInner += ` +
    +

    Outliers by feature

    + Outliers chart +
    `; + } else if (det.outliers.error) { + detailsInner += `

    Outliers: ${det.outliers.error}

    `; + } else if (vizDeferred || det.outliers.visualization_deferred) { + detailsInner += _readinessVizSlot("data-quality", "outliers", "Outliers by feature"); + } + } + + if (detailsInner) { + html += ` +
    + + Show detailed charts + +
    ${detailsInner}
    +
    `; + } + + container.classList.remove("text-center", "py-8"); + container.innerHTML = html; + + if (vizDeferred) { + const detailsEl = container.querySelector("details"); + if (detailsEl) _wireReadinessDetailsViz(detailsEl, "data-quality"); + } +} + +/** + * Render the Impact on AI scorecard (automated all-pairs correlation signals: + * redundancy, leakage risk, isolated features) into the given container. + */ +function renderReadinessImpact(container, impact) { + if (impact.error) { + container.innerHTML = `

    Impact on AI unavailable: ${impact.error}

    `; + return; + } + + const autoSel = impact.auto_selection || {}; + const crit = autoSel.selection_criteria || {}; + const colCrit = crit.columns_analyzed || {}; + const thresholds = crit.thresholds || {}; + const kpis = impact.kpis || []; + const na = impact.needs_attention || {}; + const gradeCls = _dqStatusClasses(impact.grade_status); + + const leakage = na.leakage_pairs || impact.leakage_pairs || []; + const redundant = na.redundant_pairs || impact.redundant_pairs || []; + const isolated = na.isolated_features || impact.isolated_features || []; + const topPairs = impact.top_pairs || []; + const dropped = colCrit.excluded || impact.columns_dropped || []; + const analyzed = impact.columns_analyzed || (colCrit.selected || []).length; + + const impactScoreValues = [ + ...leakage.map((p) => p.score), + ...redundant.map((p) => p.score), + ...topPairs.map((p) => p.score), + ]; + const fmtScore = _readinessNumFormatter(impactScoreValues); + const impactPctValues = [impact.grade, ...kpis.map((k) => k.value)].filter((v) => v != null); + const fmtImpactPct = _readinessPctFormatter(impactPctValues); + const fmtPair = (p) => + _readinessNaRow( + `${_escapeHtml(p.a)}${_escapeHtml(p.b)}`, + "Correlated feature pair", + `|score| ${fmtScore(p.score)}`, + ); + + const selectedCols = colCrit.selected || []; + const selectedPreview = + selectedCols.length > 0 + ? `${selectedCols.slice(0, 8).join(", ")}${selectedCols.length > 8 ? "…" : ""}` + : "none"; + + // --- Auto-selection criteria --- + let html = ` +
    +

    Auto-selection criteria

    +
      +
    • Columns analyzed (${analyzed}): ${selectedPreview}
      + ${colCrit.rule || ""}
    • +
    • Excluded columns: ${dropped.length}
    • +
    • Thresholds: + redundant |score| ≥ ${_readinessNum(thresholds.redundant_threshold)}, + leakage |score| ≥ ${_readinessNum(thresholds.leakage_threshold)}, + isolated max |score| < ${_readinessNum(thresholds.isolated_threshold)} +
    • +
    +
    `; + + // --- Overall grade + KPI tiles --- + html += ` +
    + Overall impact grade${_readinessInfoIcon("overall_impact_grade")} + ${fmtImpactPct(impact.grade)} +
    +
    `; + + kpis.forEach((k) => { + const cls = _dqStatusClasses(k.status); + const displayVal = + k.raw_count != null ? `${k.raw_count} flagged` : fmtImpactPct(k.value); + const widthPct = + k.value === null || k.value === undefined + ? 0 + : Math.max(0, Math.min(100, Math.round(k.value * 100))); + html += ` +
    +
    + ${k.label}${_readinessInfoIcon(k.id)} + ${displayVal} +
    +
    +
    +
    +

    ${k.hint || ""}

    +
    `; + }); + html += "
    "; + + // --- Needs attention --- + const naItems = []; + if (leakage.length) { + const items = leakage.slice(0, 6).map(fmtPair).join(""); + const more = + leakage.length > 6 + ? `
  • +${leakage.length - 6} more
  • ` + : ""; + naItems.push( + _readinessNaBlock( + `Leakage risk (|score| ≥ 0.95) (${leakage.length})`, + "leakage_risk_pairs", + null, + items + more, + "red", + ), + ); + } + if (redundant.length) { + const items = redundant.slice(0, 6).map(fmtPair).join(""); + const more = + redundant.length > 6 + ? `
  • +${redundant.length - 6} more
  • ` + : ""; + naItems.push( + _readinessNaBlock( + `Redundant pairs (|score| ≥ 0.8) (${redundant.length})`, + "redundant_pairs", + null, + items + more, + "amber", + ), + ); + } + if (isolated.length) { + const items = isolated + .slice(0, 10) + .map((f) => { + const name = typeof f === "string" ? f : f.feature || f; + return _readinessNaRow( + `${_escapeHtml(name)}`, + "No strong correlation to other analyzed features", + null, + ); + }) + .join(""); + const more = + isolated.length > 10 + ? `
  • +${isolated.length - 10} more
  • ` + : ""; + naItems.push( + _readinessNaBlock( + `Isolated features (${isolated.length})`, + "isolated_features", + null, + items + more, + "amber", + ), + ); + } + + html += _renderReadinessNeedsAttentionPanel( + naItems, + "No redundancy, leakage risk, or isolated features detected.", + ); + + // --- Collapsible details --- + const det = impact.details || {}; + const vizDeferred = impact.visualizations_deferred || det.visualizations_deferred; + let detailsInner = ""; + + if (topPairs.length) { + detailsInner += + '

    Most-related feature pairs' + + _readinessInfoIcon("most_related_pairs") + + "

    "; + detailsInner += + '
    '; + detailsInner += + ''; + topPairs.forEach((p, i) => { + const stripe = + i % 2 === 0 + ? "bg-white dark:bg-gray-800" + : "bg-gray-50 dark:bg-gray-700/50"; + detailsInner += ``; + }); + detailsInner += "
    Feature AFeature BScore
    ${p.a}${p.b}${fmtScore(p.score)}
    "; + } + + if (det.numerical_visualization) { + const method = det.numerical_method ? ` (${det.numerical_method})` : ""; + detailsInner += ` +
    +

    Numerical correlation${method}

    + Numerical correlation heatmap +
    `; + } else if (vizDeferred && analyzed >= 2) { + const method = det.numerical_method ? ` (${det.numerical_method})` : ""; + detailsInner += _readinessVizSlot( + "impact-on-ai", + "numerical_correlation", + `Numerical correlation${method}`, + ); + } + if (det.categorical_visualization) { + detailsInner += ` +
    +

    Categorical correlation (Theil's U)

    + Categorical correlation heatmap +
    `; + } else if (vizDeferred && analyzed >= 2) { + detailsInner += _readinessVizSlot( + "impact-on-ai", + "categorical_correlation", + "Categorical correlation (Theil's U)", + ); + } + if (dropped.length) { + const excludedMeta = colCrit.excluded_meta || {}; + const excludedTotal = excludedMeta.total || dropped.length; + const { html: items } = _readinessTruncatedListItems( + dropped, + _READINESS_MAX_DETAIL_LIST_ITEMS, + (d) => + `
  • ${d.feature}${d.reason}
  • `, + ); + detailsInner += ` +
    +

    Excluded columns (${excludedTotal})

    +
      ${items}
    +
    `; + } + + if (detailsInner) { + html += ` +
    + + Show detailed charts & tables + +
    ${detailsInner}
    +
    `; + } + + container.classList.remove("text-center", "py-8"); + container.innerHTML = html; + + if (vizDeferred) { + const detailsEl = container.querySelector("details"); + if (detailsEl) _wireReadinessDetailsViz(detailsEl, "impact-on-ai"); + } +} + +/** + * Render the Fairness & Bias scorecard (auto-selected columns, four metrics, + * selection criteria, needs-attention lists, collapsible charts). + */ +function renderReadinessFairness(container, fb) { + if (fb.error) { + container.innerHTML = `

    Fairness & Bias unavailable: ${fb.error}

    `; + return; + } + + const sel = fb.auto_selection || {}; + const criteria = sel.selection_criteria || {}; + const sensCrit = criteria.sensitive_attributes || {}; + const targetCrit = criteria.target_column || {}; + const posCrit = criteria.positive_class || {}; + const thresholds = criteria.thresholds || {}; + const kpis = fb.kpis || []; + const na = fb.needs_attention || {}; + const gradeCls = _dqStatusClasses(fb.grade_status); + const fairnessPctValues = [fb.grade, ...kpis.map((k) => k.value)].filter((v) => v != null); + const fmtFairnessPct = _readinessPctFormatter(fairnessPctValues); + const fmtThresholdPct = _readinessPctFormatter( + thresholds.minority_class_share != null ? [thresholds.minority_class_share] : [], + ); + const imbalanceVals = kpis + .filter((k) => k.id === "label_balance" && k.raw_imbalance_degree != null) + .map((k) => k.raw_imbalance_degree); + const fmtImbalance = _readinessNumFormatter(imbalanceVals); + + // --- Auto-selection criteria (transparent) --- + let html = ` +
    +

    Auto-selection criteria

    +
      +
    • Sensitive attributes: ${sensCrit.selected?.length ? sensCrit.selected.join(", ") : "none"}
      + ${sensCrit.rule || ""}
    • +
    • Target column: ${targetCrit.selected || "none"}${targetCrit.reason ? ` (${targetCrit.reason})` : ""}
      + ${targetCrit.rule || ""}
    • +
    • CDD positive class: ${posCrit.selected ?? "none"}${posCrit.reason ? ` (${posCrit.reason})` : ""}
      + ${posCrit.rule || ""}
    • +
    • Primary sensitive (statistical rate & CDD): ${sel.primary_sensitive || "none"}
    • +
    • Flags: + representation ratio ≥ ${_readinessNum(thresholds.representation_ratio_flag)}, + minority class < ${fmtThresholdPct(thresholds.minority_class_share)}, + TSD ≥ ${_readinessNum(thresholds.tsd_disparity_flag)}, + imbalance degree good/warning < ${_readinessNum(thresholds.imbalance_degree_good)} / ${_readinessNum(thresholds.imbalance_degree_warning)} +
    • +
    +
    `; + + // --- Overall grade + KPI tiles --- + html += ` +
    + Overall fairness grade${_readinessInfoIcon("overall_fairness_grade")} + ${fmtFairnessPct(fb.grade)} +
    +
    `; + + kpis.forEach((k) => { + const cls = _dqStatusClasses(k.status); + const displayVal = + k.id === "label_balance" && k.raw_imbalance_degree != null + ? `ID ${fmtImbalance(k.raw_imbalance_degree)}` + : fmtFairnessPct(k.value); + const widthPct = + k.value === null || k.value === undefined + ? 0 + : Math.max(0, Math.min(100, Math.round(k.value * 100))); + html += ` +
    +
    + ${k.label}${_readinessInfoIcon(k.id)} + ${displayVal} +
    +
    +
    +
    +

    ${k.hint || ""}

    +
    `; + }); + html += "
    "; + + // --- Needs attention --- + const naItems = []; + const repImbalance = na.representation_imbalance || []; + if (repImbalance.length) { + const ratioValues = repImbalance.flatMap((s) => [ + s.max_ratio, + ...(s.flagged_pairs || []).map((p) => p.ratio), + ]); + const fmtRatio = _readinessNumFormatter(ratioValues); + const items = repImbalance + .slice(0, 5) + .map((s) => { + const pairHint = + s.flagged_pairs && s.flagged_pairs.length + ? `Worst pair: ${s.flagged_pairs[0].pair} (ratio ${fmtRatio(s.flagged_pairs[0].ratio)})` + : ""; + return _readinessNaRow( + `${_escapeHtml(s.column)}`, + pairHint, + `max ratio ${fmtRatio(s.max_ratio)}`, + ); + }) + .join(""); + naItems.push( + _readinessNaBlock( + `Representation imbalance (${repImbalance.length})`, + "representation_imbalance", + "Sensitive attributes with extreme category probability ratios", + items, + "amber", + ), + ); + } + + const minorities = na.minority_classes || []; + if (minorities.length) { + const targetCol = + minorities[0].target_column || targetCrit.selected || "target"; + const fmtMinorityShare = _readinessPctFormatter(minorities.map((m) => m.share)); + const items = minorities + .map((m) => + _readinessNaRow( + `${_escapeHtml(m.class)}`, + `Class in ${_escapeHtml(targetCol)}`, + `${fmtMinorityShare(m.share)} share`, + ), + ) + .join(""); + naItems.push( + _readinessNaBlock( + `Minority classes (${minorities.length})`, + "minority_classes", + `Target column: ${_escapeHtml(targetCol)}`, + items, + "amber", + ), + ); + } + + const outcomeDisp = na.outcome_disparities || []; + if (outcomeDisp.length) { + const sensCol = outcomeDisp[0].sensitive_column || sel.primary_sensitive || "—"; + const tgtCol = outcomeDisp[0].target_column || targetCrit.selected || "—"; + const fmtTsd = _readinessNumFormatter(outcomeDisp.map((d) => d.tsd)); + const items = outcomeDisp + .map((d) => + _readinessNaRow( + `${_escapeHtml(d.target_column || tgtCol)} = ${_escapeHtml(d.class)}`, + `Outcome rates vary by sensitive ${_escapeHtml(d.sensitive_column || sensCol)}`, + `TSD ${fmtTsd(d.tsd)}`, + ), + ) + .join(""); + naItems.push( + _readinessNaBlock( + `Outcome-rate disparities (${outcomeDisp.length})`, + "outcome_disparities", + `Sensitive ${_escapeHtml(sensCol)} × target ${_escapeHtml(tgtCol)}`, + items, + "amber", + ), + ); + } + + const cddDisp = na.cdd_disparities || []; + if (cddDisp.length) { + const sensCol = cddDisp[0].sensitive_column || sel.primary_sensitive || "—"; + const tgtCol = cddDisp[0].target_column || targetCrit.selected || "—"; + const posClass = cddDisp[0].positive_class || posCrit.selected || "—"; + const items = cddDisp + .map((d) => + _readinessNaRow( + `${_escapeHtml(d.sensitive_column || sensCol)} = ${_escapeHtml(d.group)}`, + `CDD vs target ${_escapeHtml(d.target_column || tgtCol)} (positive: ${_escapeHtml(String(d.positive_class ?? posClass))})`, + null, + ), + ) + .join(""); + naItems.push( + _readinessNaBlock( + `CDD flagged groups (${cddDisp.length})`, + "cdd_disparities", + `Sensitive ${_escapeHtml(sensCol)} × target ${_escapeHtml(tgtCol)}`, + items, + "red", + ), + ); + } + + html += _renderReadinessNeedsAttentionPanel( + naItems, + "No fairness issues detected under the automated thresholds.", + ); + + // --- Collapsible details (charts) --- + const det = fb.details || {}; + const vizDeferred = + fb.visualizations_deferred || + det.representation_rate?.visualizations_deferred; + let detailsInner = ""; + + const repVis = det.representation_rate?.visualizations || {}; + const sensCols = sensCrit.selected || []; + if (vizDeferred && sensCols.length && !det.representation_rate?.error) { + sensCols.forEach((col) => { + detailsInner += _readinessVizSlot( + "fairness-bias", + `representation_rate.${col}`, + `Representation rate — ${col}`, + ); + }); + } else { + for (const [col, b64] of Object.entries(repVis)) { + detailsInner += ` +
    +

    Representation rate — ${col}

    + Representation ${col} +
    `; + } + } + if (det.representation_rate?.error && !Object.keys(repVis).length) { + detailsInner += `

    Representation rate: ${det.representation_rate.error}

    `; + } + + if (det.class_imbalance?.visualization) { + detailsInner += ` +
    +

    Class imbalance — ${targetCrit.selected || "target"}

    + Class imbalance +
    `; + } else if (det.class_imbalance?.error) { + detailsInner += `

    Class imbalance: ${det.class_imbalance.error}

    `; + } else if ( + (vizDeferred || det.class_imbalance?.visualization_deferred) && + targetCrit.selected + ) { + detailsInner += _readinessVizSlot( + "fairness-bias", + "class_imbalance", + `Class imbalance — ${targetCrit.selected || "target"}`, + ); + } + + if (det.statistical_rate?.visualization) { + detailsInner += ` +
    +

    Statistical rate — ${det.statistical_rate.sensitive} × ${det.statistical_rate.target}

    + Statistical rate +
    `; + } else if (det.statistical_rate?.error) { + detailsInner += `

    Statistical rate: ${det.statistical_rate.error}

    `; + } else if ( + (vizDeferred || det.statistical_rate?.visualization_deferred) && + sel.primary_sensitive && + targetCrit.selected + ) { + detailsInner += _readinessVizSlot( + "fairness-bias", + "statistical_rate", + `Statistical rate — ${sel.primary_sensitive} × ${targetCrit.selected}`, + ); + } + + if (det.cdd?.disparities && !det.cdd.error) { + const rows = Object.entries(det.cdd.disparities) + .map( + ([grp, info]) => + `${grp}${info.disparity}`, + ) + .join(""); + detailsInner += ` +
    +

    Conditional demographic disparity (positive: ${det.cdd.positive_class})

    + ${rows}
    GroupDisparity
    +
    `; + } else if (det.cdd?.error) { + detailsInner += `

    CDD: ${det.cdd.error}

    `; + } + + const excluded = sensCrit.excluded || []; + if (excluded.length) { + const excludedMeta = sensCrit.excluded_meta || {}; + const excludedTotal = excludedMeta.total || excluded.length; + const { html: items } = _readinessTruncatedListItems( + excluded, + _READINESS_MAX_DETAIL_LIST_ITEMS, + (d) => + `
  • ${d.feature}${d.reason}
  • `, + ); + detailsInner += ` +
    +

    Excluded sensitive candidates (${excludedTotal})

    +
      ${items}
    +
    `; + } + + if (detailsInner) { + html += ` +
    + + Show detailed charts & CDD table + +
    ${detailsInner}
    +
    `; + } + + container.classList.remove("text-center", "py-8"); + container.innerHTML = html; + + if (vizDeferred) { + const detailsEl = container.querySelector("details"); + if (detailsEl) _wireReadinessDetailsViz(detailsEl, "fairness-bias"); + } +} + +/** + * Render the Data Governance scorecard (auto-selected columns, privacy KPIs, + * HIPAA flags, needs-attention lists, collapsible charts). + */ +function renderReadinessGovernance(container, gov) { + if (gov.error) { + container.innerHTML = `

    Data Governance unavailable: ${gov.error}

    `; + return; + } + + const sel = gov.auto_selection || {}; + const criteria = sel.selection_criteria || {}; + const qiCrit = criteria.quasi_identifiers || {}; + const sensCrit = criteria.sensitive_attribute || {}; + const idCrit = criteria.id_column || {}; + const hipaaCrit = criteria.hipaa_scan_columns || {}; + const dpCrit = criteria.dp_features || {}; + const thresholds = criteria.thresholds || {}; + const kpis = gov.kpis || []; + const na = gov.needs_attention || {}; + const det = gov.details || {}; + const singleRisk = det.single_attribute_risk?.by_quasi_identifier || {}; + const gradeCls = _dqStatusClasses(gov.grade_status); + const lowAnon = na.low_anonymity || []; + const linkage = na.high_linkage_risk || []; + const attrDisc = na.attribute_disclosure || []; + const govPctValues = [gov.grade, ...kpis.map((k) => k.value)].filter((v) => v != null); + const fmtGovPct = _readinessPctFormatter(govPctValues); + const govRiskValues = [ + ...kpis.map((k) => k.raw_worst_mean ?? k.raw_mean).filter((v) => v != null), + ...linkage.map((x) => x.mean_risk), + ...lowAnon.flatMap((x) => + x.worst_single_qi ? [x.worst_single_qi.mean_risk] : [], + ), + ...Object.values(singleRisk).map((v) => v.mean_risk).filter((v) => v != null), + ]; + const govMetricValues = [ + ...kpis.filter((k) => k.id === "distribution_t" && k.raw_t != null).map((k) => k.raw_t), + ...attrDisc.map((x) => x.value), + ]; + const fmtGovRisk = _readinessNumFormatter(govRiskValues); + const fmtGovMetric = _readinessNumFormatter(govMetricValues); + + let html = ` +
    +

    Auto-selection criteria

    +
      +
    • Quasi-identifiers: ${qiCrit.selected?.length ? qiCrit.selected.join(", ") : "none"}
      + ${qiCrit.rule || ""}
    • +
    • Sensitive attribute: ${sensCrit.selected || "none"}
      + ${sensCrit.rule || ""}
    • +
    • ID column: ${idCrit.selected || "none"}${idCrit.synthetic ? " (synthetic row index)" : ""}
      + ${idCrit.rule || ""}
    • +
    • HIPAA scan columns: ${(hipaaCrit.selected || []).length} column(s)${hipaaCrit.selected?.length ? ` — ${hipaaCrit.selected.slice(0, 5).join(", ")}${hipaaCrit.selected.length > 5 ? "…" : ""}` : ""}
    • +
    • Thresholds: + k ≥ ${thresholds.k_good ?? "—"}/${thresholds.k_warning ?? "—"}, + l ≥ ${thresholds.l_good ?? "—"}/${thresholds.l_warning ?? "—"}, + t ≤ ${thresholds.t_good ?? "—"}/${thresholds.t_warning ?? "—"}, + MM single < ${thresholds.mm_single_good ?? "—"}/${thresholds.mm_single_warning ?? "—"}, + MM combined < ${thresholds.mm_multi_good ?? "—"}/${thresholds.mm_multi_warning ?? "—"} +
    • + ${ + gov.small_sample_warning + ? `
    • Note: small dataset (< ${thresholds.small_sample_rows ?? 30} rows) — privacy metrics may be unstable.
    • ` + : "" } +
    +
    `; + + html += ` +
    + Overall governance grade${_readinessInfoIcon("overall_governance_grade")} + ${fmtGovPct(gov.grade)} +
    +
    `; + + kpis.forEach((k) => { + const cls = _dqStatusClasses(k.status); + let displayVal = fmtGovPct(k.value); + if (k.id === "anonymity_k" && k.raw_k != null) displayVal = `k=${k.raw_k}`; + else if (k.id === "diversity_l" && k.raw_l != null) displayVal = `l=${k.raw_l}`; + else if (k.id === "distribution_t" && k.raw_t != null) + displayVal = `t=${fmtGovMetric(k.raw_t)}`; + else if (k.id === "single_linkage_risk" && k.raw_worst_mean != null) + displayVal = `${fmtGovRisk(k.raw_worst_mean)} risk`; + else if (k.id === "linkage_risk" && k.raw_mean != null) + displayVal = `${fmtGovRisk(k.raw_mean)} risk`; + else if (k.id === "phi_exposure" && k.columns_flagged != null) + displayVal = k.columns_flagged === 0 ? "None" : `${k.columns_flagged} col(s)`; + + const widthPct = + k.value === null || k.value === undefined + ? 0 + : Math.max(0, Math.min(100, Math.round(k.value * 100))); + html += ` +
    +
    + ${k.label}${_readinessInfoIcon(k.id)} + ${displayVal} +
    +
    +
    +
    +

    ${k.hint || ""}

    +
    `; + }); + html += "
    "; + + const naItems = []; + + if (lowAnon.length) { + let items = ""; + lowAnon.forEach((x) => { + const qiList = (x.quasi_identifiers || []).join(", "); + items += _readinessNaRow( + `${_escapeHtml(x.metric)}: k = ${x.value}`, + x.detail || (qiList ? `Quasi-identifiers: ${qiList}` : null), + x.singleton_count != null ? `${x.singleton_count} singleton group(s)` : null, + ); + (x.worst_groups || []).slice(0, 3).forEach((g) => { + items += _readinessNaRow( + `Smallest group (size ${g.size})`, + _formatQiValues(g.qi_values), + null, + ); + }); + if (x.worst_single_qi) { + items += _readinessNaRow( + `Highest single-QI risk: ${_escapeHtml(x.worst_single_qi.feature)}`, + "May contribute to low k when combined with other quasi-identifiers", + `risk ${fmtGovRisk(x.worst_single_qi.mean_risk)}`, + ); + } + }); + naItems.push( + _readinessNaBlock( + `Low anonymity (${lowAnon.length})`, + "low_anonymity", + lowAnon[0].quasi_identifiers?.length + ? `Quasi-identifiers: ${_escapeHtml(lowAnon[0].quasi_identifiers.join(", "))}` + : null, + items, + "red", + ), + ); + } + + const hipaaPhi = na.hipaa_phi || []; + if (hipaaPhi.length) { + const items = hipaaPhi + .slice(0, 6) + .map((x) => + _readinessNaRow( + `${_escapeHtml(x.column)}`, + (x.types || []).join(", ") || "Pattern match", + `${x.total_flags} flag(s)`, + ), + ) + .join(""); + naItems.push( + _readinessNaBlock( + `HIPAA pattern matches (${hipaaPhi.length})`, + "hipaa_phi", + "Scanned text-like columns for HIPAA-style identifier patterns", + items, + "red", + ), + ); + } + + const linkageNa = na.high_linkage_risk || []; + if (linkageNa.length) { + const items = linkageNa + .map((x) => { + const qis = x.quasi_identifiers || x.features || []; + const featLabel = x.feature + ? `${_escapeHtml(x.feature)}` + : `${_escapeHtml(qis.join(", "))}`; + return _readinessNaRow( + `${_escapeHtml(x.metric)}: ${featLabel}`, + x.detail || (qis.length ? `Quasi-identifiers: ${qis.join(", ")}` : null), + `risk ${fmtGovRisk(x.mean_risk)}`, + ); + }) + .join(""); + naItems.push( + _readinessNaBlock( + `High linkage risk (${linkageNa.length})`, + "high_linkage_risk", + null, + items, + "amber", + ), + ); + } + + const attrDiscNa = na.attribute_disclosure || []; + if (attrDiscNa.length) { + const items = attrDiscNa + .map((x) => { + const qiList = (x.quasi_identifiers || []).join(", "); + const sens = x.sensitive_attribute || "—"; + return _readinessNaRow( + `${_escapeHtml(x.metric)} = ${fmtGovMetric(x.value)}`, + x.detail || + `Sensitive ${_escapeHtml(sens)} within groups of (${qiList})`, + null, + ); + }) + .join(""); + naItems.push( + _readinessNaBlock( + `Attribute disclosure risk (${attrDiscNa.length})`, + "attribute_disclosure", + attrDiscNa[0].sensitive_attribute + ? `Sensitive: ${_escapeHtml(attrDiscNa[0].sensitive_attribute)}` + : null, + items, + "amber", + ), + ); + } + + html += _renderReadinessNeedsAttentionPanel( + naItems, + "No governance issues detected under the automated thresholds.", + ); + + let detailsInner = ""; + const vizDeferred = gov.visualizations_deferred; + + const chartMetrics = [ + ["k_anonymity", "k-Anonymity", "visualization"], + ["l_diversity", "l-Diversity", "visualization"], + ["t_closeness", "t-Closeness", "visualization"], + ["entropy_risk", "Entropy risk", "visualization"], + ["multiple_attribute_risk", "Multiple-attribute linkage risk", "visualization"], + ["differential_privacy", "Differential privacy (illustrative)", "visualization"], + ]; + chartMetrics.forEach(([key, title, visKey]) => { + const block = det[key]; + if (block?.[visKey]) { + detailsInner += ` +
    +

    ${title}

    + ${title} +
    `; + } else if (block?.error) { + detailsInner += `

    ${title}: ${block.error}

    `; + } else if (block && (vizDeferred || block.visualization_deferred)) { + detailsInner += _readinessVizSlot("data-governance", key, title); + } + }); + + const singleRows = Object.entries(singleRisk) + .filter(([, v]) => v.mean_risk != null) + .map( + ([q, v]) => + `${q}${fmtGovRisk(v.mean_risk)}`, + ) + .join(""); + if (singleRows) { + detailsInner += ` +
    +

    Single-attribute MM risk by quasi-identifier

    + ${singleRows}
    Quasi-identifierMean risk
    +
    `; + } + + const hipaaDet = det.hipaa?.detected || {}; + const hipaaRows = Object.entries(hipaaDet) + .map( + ([col, info]) => + `${col}${(info.potential_types_detected || []).join(", ")}${info.total_flags}`, + ) + .join(""); + if (hipaaRows) { + detailsInner += ` +
    +

    HIPAA scan results

    + ${hipaaRows}
    ColumnTypesFlags
    +
    `; + } + + const qiExcluded = qiCrit.excluded || []; + if (qiExcluded.length) { + const excludedMeta = qiCrit.excluded_meta || {}; + const excludedTotal = excludedMeta.total || qiExcluded.length; + const { html: items } = _readinessTruncatedListItems( + qiExcluded, + _READINESS_MAX_DETAIL_LIST_ITEMS, + (d) => + `
  • ${d.feature}${d.reason}
  • `, + ); + detailsInner += ` +
    +

    Excluded quasi-identifier candidates (${excludedTotal})

    +
      ${items}
    +
    `; + } + + if (dpCrit.selected?.length) { + detailsInner += `

    DP demo features: ${dpCrit.selected.join(", ")} (ε=${dpCrit.epsilon ?? "—"}, illustrative only).

    `; + } + + if (detailsInner) { + html += ` +
    + + Show detailed charts & tables + +
    ${detailsInner}
    +
    `; + } + + container.classList.remove("text-center", "py-8"); + container.innerHTML = html; + + if (vizDeferred) { + const detailsEl = container.querySelector("details"); + if (detailsEl) _wireReadinessDetailsViz(detailsEl, "data-governance"); + } +} + +// ==================== Workspace Init ==================== + +/** + * Initialize the workspace after file upload. + * Fetches summary statistics and populates feature dropdowns. + */ +function initWorkspace() { + // Restore panel from URL hash, or default to data-overview + const hash = location.hash.replace("#", ""); + const initialPanel = + hash && document.getElementById("panel-" + hash) ? hash : "data-overview"; + showPanel(initialPanel, false); // false = don't push to history on init + // Replace current history entry so back button works from the first panel + history.replaceState({ panel: initialPanel }, "", "#" + initialPanel); + + // Fetch + render summary statistics into the Data Overview panel + loadDataOverview(); + + // Populate feature dropdowns via /feature-set (same as metric.js does) + fetch("/feature-set", { method: "POST" }) + .then((r) => r.json()) + .then((data) => { + if (data.success && typeof populateWorkspaceDropdowns === "function") { + populateWorkspaceDropdowns(data); } + }) + .catch((err) => console.error("Error fetching features:", err)); + + // Feature relevance: disable target feature in checkbox lists + const targetDropdown = document.getElementById( + "all-features-dropdown-feature-relevance", + ); + if (targetDropdown) { + targetDropdown.addEventListener("change", function () { + const target = this.value; + // In both cat and num checkbox containers, disable the checkbox matching the target + ["catFeaturesCheckbox1", "numFeaturesCheckbox1"].forEach( + (containerId) => { + const container = document.getElementById(containerId); + if (!container) return; + container.querySelectorAll('input[type="checkbox"]').forEach((cb) => { + if (cb.value === target) { + cb.checked = false; + cb.disabled = true; + cb.closest("label").style.opacity = "0.4"; + } else { + cb.disabled = false; + cb.closest("label").style.opacity = "1"; + } + }); + }, + ); }); } + + // Handle FAIR assessment file input UI + _wireFairFileInput( + document.getElementById("fair-file"), + document.getElementById("fairFileLabel"), + document.getElementById("fairUploadIcon"), + ); } /** diff --git a/web/templates/_components/sidebar.html b/web/templates/_components/sidebar.html index f7c6dd3f..35ab5af2 100644 --- a/web/templates/_components/sidebar.html +++ b/web/templates/_components/sidebar.html @@ -18,6 +18,14 @@ Data Overview + + +
    diff --git a/web/templates/_panels/_readiness_report.html b/web/templates/_panels/_readiness_report.html new file mode 100644 index 00000000..4646a888 --- /dev/null +++ b/web/templates/_panels/_readiness_report.html @@ -0,0 +1,133 @@ + + diff --git a/web/templates/inspector.html b/web/templates/inspector.html index 5587b48e..6f60c6c8 100644 --- a/web/templates/inspector.html +++ b/web/templates/inspector.html @@ -56,6 +56,7 @@
    {% include '_panels/_data_overview.html' %} + {% include '_panels/_readiness_report.html' %} {% include '_panels/_data_quality.html' %} {% include '_panels/_feature_relevance.html' %} {% include '_panels/_correlation_analysis.html' %} @@ -85,6 +86,7 @@ {% endif %} {% if uploaded_file_path and not globus_mode %} + window.AIDRIN_DATASET_NAME = {{ uploaded_file_name | tojson }}; initWorkspace(); {% elif globus_mode %} // Globus mode — flag for workspaceSubmit to route through Globus @@ -92,6 +94,7 @@ window.AIDRIN_GLOBUS_ENDPOINT = '{{ globus_endpoint_id }}'; window.AIDRIN_GLOBUS_FILE_PATH = '{{ uploaded_file_path }}'; window.AIDRIN_GLOBUS_FILE_NAME = '{{ uploaded_file_name }}'; + window.AIDRIN_DATASET_NAME = {{ uploaded_file_name | tojson }}; window.AIDRIN_GLOBUS_FILE_TYPE = '{{ file_type }}'; showPanel('data-overview'); // Show Globus info banner + loading spinner for summary stats diff --git a/web/templates/readiness_report/_pdf_fair_details.html b/web/templates/readiness_report/_pdf_fair_details.html new file mode 100644 index 00000000..c2903d0a --- /dev/null +++ b/web/templates/readiness_report/_pdf_fair_details.html @@ -0,0 +1,35 @@ + + + {% for p in fair_compliance.principles %} + + {% if loop.index % 2 == 0 and not loop.last %}{% endif %} + {% endfor %} + +
    +
    +
    + {{ p.name }} + {{ p.check_str }} +
    + {% if p.rows %} + + + {% for row in p.rows %} + {% if row.is_group %} + + {% else %} + + + + + {% endif %} + {% endfor %} + +
    {{ row.label }}
    {{ row.label }} + {{ row.status_label }} +
    + {% else %} +

    No detail available.

    + {% endif %} +
    +
    diff --git a/web/templates/readiness_report/_pdf_needs_attention.html b/web/templates/readiness_report/_pdf_needs_attention.html new file mode 100644 index 00000000..5b437e8a --- /dev/null +++ b/web/templates/readiness_report/_pdf_needs_attention.html @@ -0,0 +1,35 @@ +{% if needs_attention and needs_attention|length > 0 %} +
    +

    Needs attention

    +
    + {% for block in needs_attention %} +
    +

    + {{ block.title }}{% if block.glossary_key %}{{ fn(block.glossary_key) }}{% endif %} +

    + {% if block.context %}

    {{ block.context }}

    {% endif %} + {% if block.message %} +

    {{ block.message }}

    + {% else %} +
      + {% for row in block.rows %} +
    • +
      + {{ row.primary }} + {% if row.value %}{{ row.value }}{% endif %} +
      + {% if row.secondary %}

      {{ row.secondary }}

      {% endif %} +
    • + {% endfor %} + {% if block.more_count > 0 %} +
    • +{{ block.more_count }} more not shown
    • + {% endif %} +
    + {% endif %} +
    + {% endfor %} +
    +
    +{% else %} +

    {{ empty_message }}

    +{% endif %} diff --git a/web/templates/readiness_report/_pdf_section_details.html b/web/templates/readiness_report/_pdf_section_details.html new file mode 100644 index 00000000..d62966fb --- /dev/null +++ b/web/templates/readiness_report/_pdf_section_details.html @@ -0,0 +1,58 @@ +{% if detail_block and detail_block.blocks %} +
    +

    {{ detail_block.heading }}

    + {% for block in detail_block.blocks %} + {% if block.type == 'subheading' %} +

    {{ block.text }}

    + {% elif block.type == 'note' %} +

    {{ block.text }}

    + {% elif block.type == 'table' %} +

    {{ block.title }}

    + {% if block.note %}

    {{ block.note }}

    {% endif %} + + + + {% for h in block.headers %} + 1 %} class="num"{% endif %}>{{ h }} + {% endfor %} + + + + {% for row in block.rows %} + + {% for cell in row %} + 1 %} class="num"{% else %} class="mono"{% endif %}>{{ cell }} + {% endfor %} + + {% endfor %} + +
    + {% elif block.type == 'list' %} +

    {{ block.title }}

    +
      + {% for item in block.entries %} +
    • + {{ item.primary }} + {% if item.secondary %} — {{ item.secondary }}{% endif %} +
    • + {% endfor %} +
    + {% elif block.type == 'chart_group' %} +

    {{ block.title }}

    +
    + {% for chart in block.charts %} +
    +
    {{ chart.label }}
    + {{ chart.label }} +
    + {% endfor %} +
    + {% elif block.type == 'chart_wide' %} +
    +
    {{ block.title }}
    + {{ block.title }} +
    + {% endif %} + {% endfor %} +
    +{% endif %} diff --git a/web/templates/readiness_report/_pdf_section_footnotes.html b/web/templates/readiness_report/_pdf_section_footnotes.html new file mode 100644 index 00000000..3cfc6f9f --- /dev/null +++ b/web/templates/readiness_report/_pdf_section_footnotes.html @@ -0,0 +1,9 @@ +{% if footnotes.section(section_name).entries() %} +
    +
      + {% for item in footnotes.section(section_name).entries() %} +
    1. {{ item.term }}. {{ item.definition }}
    2. + {% endfor %} +
    +
    +{% endif %} diff --git a/web/templates/readiness_report/pdf.html b/web/templates/readiness_report/pdf.html new file mode 100644 index 00000000..39ef6b67 --- /dev/null +++ b/web/templates/readiness_report/pdf.html @@ -0,0 +1,367 @@ + + + + + Readiness Report — {{ file_name }} + + + + + +
    + AIDRIN +
    + +
    +

    {% if include_details %}AI Data Readiness Report (Full){% else %}AI Data Readiness Report{% endif %}

    +

    Dataset: {{ file_name }}

    +

    Generated {{ generated_at }} · AIDRIN {{ app_version }}

    +

    + {% if include_details %} + Full readiness report including scorecard summaries and detailed charts & tables + (the same content as each section’s “Show details” panel in the interactive report). + {% else %} + Automated scorecard for the uploaded dataset. Five pillars are computed without user column selection. + {% endif %} + Overall grades use the mean of available KPI values on a 0–1 scale. + Good ≥ 90%, Warning ≥ 70%, Poor < 70%. + Higher KPI values indicate better readiness unless noted otherwise. +

    +
    + + {# --- Dataset Overview --- #} +
    + {% set fn = footnotes.section('overview').ref %} +

    Dataset Overview

    + {% set meta = overview.meta %} +
    +

    {{ meta.file_name or file_name }}

    + + + + + + + + + + + + + +
    Type: {{ meta.file_type or '—' }}Size: {{ fmt_bytes(meta.file_size_bytes) }}Memory: {{ fmt_bytes(meta.memory_bytes) }}Rows: {{ '{:,}'.format(meta.rows or 0) }}
    Columns: {{ meta.columns or 0 }}Numerical: {{ meta.numerical_count or 0 }}Categorical: {{ meta.categorical_count or 0 }}Other: {{ overview.other_count }}
    +
    + + + + + + +
    {{ '{:,}'.format(meta.rows or 0) }}
    Records
    {{ meta.columns or 0 }}
    Features
    {{ meta.numerical_count or 0 }}
    Numerical
    {{ meta.categorical_count or 0 }}
    Categorical
    + +
    +

    Per-feature readiness profile{{ fn('feature_profile') }}

    + + {% if overview.poor_count %}{{ overview.poor_count }} poor{% endif %} + {% if overview.warn_count %}{% if overview.poor_count %} · {% endif %}{{ overview.warn_count }} warning{% endif %} + {% if not overview.poor_count and not overview.warn_count %}all good{% endif %} + +
    + {% if overview.profile_meta.truncated %} +

    Showing {{ '{:,}'.format(overview.profile_meta.shown) }} of {{ '{:,}'.format(overview.profile_meta.total) }} features (prioritized: poor → warning → good).

    + {% endif %} + + + + + + + + + + + + + + + + {% for p in overview.profiles %} + + + + + + + + + + + {% endfor %} + +
    FeatureType{{ fn('feature_type_codes') }}Dtype% missing{{ fn('pct_missing') }}# unique{{ fn('n_unique') }}% dominant{{ fn('pct_dominant') }}Status{{ fn('profile_status') }}Summary
    {{ p.feature }}{{ p.type_abbr }}{{ p.dtype }}{{ fmt_pct(p.pct_missing) }}{{ p.n_unique }}{{ fmt_pct(p.pct_dominant) }}{{ p.status_label }}{{ p.summary or '—' }}
    + {% set section_name = 'overview' %} + {% include "readiness_report/_pdf_section_footnotes.html" %} + {% if include_details %} + {% set detail_block = section_details.overview %} + {% include "readiness_report/_pdf_section_details.html" %} + {% endif %} +
    + + {# --- Data Quality --- #} +
    + {% set fn = footnotes.section('data_quality').ref %} +

    Data Quality

    +
    +

    Auto-selection criteria

    +
      +
    • + Analysis scope: {{ data_quality.auto_selection.selected or 'all columns' }}{{ fn('analysis_scope') }} + {{ data_quality.auto_selection.rule or 'All columns are evaluated automatically.' }} +
    • +
    +
    +
    + Overall data quality grade{{ fn('overall_dq_grade') }} + {{ fmt_pct(data_quality.grade) }} +
    + + {% for kpi in data_quality.kpis %} + + {% if loop.index % 3 == 0 and not loop.last %}{% endif %} + {% endfor %} +
    +
    {{ kpi.label }}{{ fn(kpi.id) }}
    +
    {{ kpi.display }}
    +
    +

    {{ kpi.hint }}

    +
    + {% set needs_attention = data_quality.needs_attention %} + {% set empty_message = data_quality.empty_message %} + {% include "readiness_report/_pdf_needs_attention.html" %} + {% set section_name = 'data_quality' %} + {% include "readiness_report/_pdf_section_footnotes.html" %} + {% if include_details %} + {% set detail_block = section_details.data_quality %} + {% include "readiness_report/_pdf_section_details.html" %} + {% endif %} +
    + + {# --- Impact on AI --- #} +
    + {% set fn = footnotes.section('impact').ref %} +

    Impact on AI

    +

    Automated all-pairs correlation across features to surface redundancy, leakage risk, and uninformative features.

    +
    +

    Auto-selection criteria

    +
      +
    • + Columns analyzed ({{ impact.columns_analyzed }}): {{ impact.columns_preview }}{{ fn('features_analyzed') }} + {{ impact.columns_rule }} +
    • +
    • Excluded columns: {{ impact.excluded_count }}
    • +
    • + Thresholds: + redundant |score| ≥ {{ fmt_num(impact.thresholds.redundant_threshold) }}, + leakage |score| ≥ {{ fmt_num(impact.thresholds.leakage_threshold) }}, + isolated max |score| < {{ fmt_num(impact.thresholds.isolated_threshold) }} +
    • +
    +
    +
    + Overall impact grade{{ fn('overall_impact_grade') }} + {{ fmt_pct(impact.grade) }} +
    + + {% for kpi in impact.kpis %} + + {% if loop.index % 3 == 0 and not loop.last %}{% endif %} + {% endfor %} +
    +
    {{ kpi.label }}{{ fn(kpi.id) }}
    +
    {{ kpi.display }}
    +
    +

    {{ kpi.hint }}

    +
    + {% set needs_attention = impact.needs_attention %} + {% set empty_message = impact.empty_message %} + {% include "readiness_report/_pdf_needs_attention.html" %} + {% set section_name = 'impact' %} + {% include "readiness_report/_pdf_section_footnotes.html" %} + {% if include_details %} + {% set detail_block = section_details.impact %} + {% include "readiness_report/_pdf_section_details.html" %} + {% endif %} +
    + + {# --- Fairness & Bias --- #} +
    + {% set fn = footnotes.section('fairness').ref %} +

    Fairness & Bias

    +
    +

    Auto-selection criteria

    +
      +
    • + Sensitive attributes: + {{ fairness.sensitive_attributes.selected | join(', ') if fairness.sensitive_attributes.selected else 'none' }} + {{ fairness.sensitive_attributes.rule or '' }} +
    • +
    • + Target column: + {{ fairness.target_column.selected or 'none' }}{% if fairness.target_column.reason %} ({{ fairness.target_column.reason }}){% endif %} + {{ fairness.target_column.rule or '' }} +
    • +
    • + CDD positive class: + {{ fairness.positive_class.selected if fairness.positive_class.selected is not none else 'none' }}{% if fairness.positive_class.reason %} ({{ fairness.positive_class.reason }}){% endif %} + {{ fairness.positive_class.rule or '' }} +
    • +
    • Primary sensitive (statistical rate & CDD): {{ fairness.primary_sensitive or 'none' }}
    • +
    • + Flags: + representation ratio ≥ {{ fmt_num(fairness.thresholds.representation_ratio_flag) }}, + minority class < {{ fmt_pct(fairness.thresholds.minority_class_share) }}, + TSD ≥ {{ fmt_num(fairness.thresholds.tsd_disparity_flag) }}, + imbalance degree good/warning < {{ fmt_num(fairness.thresholds.imbalance_degree_good) }} / {{ fmt_num(fairness.thresholds.imbalance_degree_warning) }} +
    • +
    +
    +
    + Overall fairness grade{{ fn('overall_fairness_grade') }} + {{ fmt_pct(fairness.grade) }} +
    + + {% for kpi in fairness.kpis %} + + {% if loop.index % 3 == 0 and not loop.last %}{% endif %} + {% endfor %} +
    +
    {{ kpi.label }}{{ fn(kpi.id) }}
    +
    {{ kpi.display }}
    +
    +

    {{ kpi.hint }}

    +
    + {% set needs_attention = fairness.needs_attention %} + {% set empty_message = fairness.empty_message %} + {% include "readiness_report/_pdf_needs_attention.html" %} + {% set section_name = 'fairness' %} + {% include "readiness_report/_pdf_section_footnotes.html" %} + {% if include_details %} + {% set detail_block = section_details.fairness %} + {% include "readiness_report/_pdf_section_details.html" %} + {% endif %} +
    + + {# --- Data Governance --- #} +
    + {% set fn = footnotes.section('governance').ref %} +

    Data Governance

    +
    +

    Auto-selection criteria

    +
      +
    • + Quasi-identifiers: + {{ governance.quasi_identifiers.selected | join(', ') if governance.quasi_identifiers.selected else 'none' }} + {{ governance.quasi_identifiers.rule or '' }} +
    • +
    • + Sensitive attribute: {{ governance.sensitive_attribute.selected or 'none' }} + {{ governance.sensitive_attribute.rule or '' }} +
    • +
    • + ID column: + {{ governance.id_column.selected or 'none' }}{% if governance.id_column.synthetic %} (synthetic row index){% endif %} + {{ governance.id_column.rule or '' }} +
    • +
    • + HIPAA scan columns: + {{ governance.hipaa_scan | length }} column(s){% if governance.hipaa_scan %} — {{ governance.hipaa_scan[:5] | join(', ') }}{% if governance.hipaa_scan | length > 5 %}…{% endif %}{% endif %} +
    • +
    • + Thresholds: + k ≥ {{ governance.thresholds.k_good | default('—') }}/{{ governance.thresholds.k_warning | default('—') }}, + l ≥ {{ governance.thresholds.l_good | default('—') }}/{{ governance.thresholds.l_warning | default('—') }}, + t ≤ {{ governance.thresholds.t_good | default('—') }}/{{ governance.thresholds.t_warning | default('—') }}, + MM single < {{ governance.thresholds.mm_single_good | default('—') }}/{{ governance.thresholds.mm_single_warning | default('—') }}, + MM combined < {{ governance.thresholds.mm_multi_good | default('—') }}/{{ governance.thresholds.mm_multi_warning | default('—') }} +
    • + {% if governance.small_sample_warning %} +
    • Note: small dataset (< {{ governance.thresholds.small_sample_rows | default(30) }} rows) — privacy metrics may be unstable.
    • + {% endif %} +
    +
    +
    + Overall governance grade{{ fn('overall_governance_grade') }} + {{ fmt_pct(governance.grade) }} +
    + + {% for kpi in governance.kpis %} + + {% if loop.index % 2 == 0 and not loop.last %}{% endif %} + {% endfor %} +
    +
    {{ kpi.label }}{{ fn(kpi.id) }}
    +
    {{ kpi.display }}
    +
    +

    {{ kpi.hint }}

    +
    + {% set needs_attention = governance.needs_attention %} + {% set empty_message = governance.empty_message %} + {% include "readiness_report/_pdf_needs_attention.html" %} + {% set section_name = 'governance' %} + {% include "readiness_report/_pdf_section_footnotes.html" %} + {% if include_details %} + {% set detail_block = section_details.governance %} + {% include "readiness_report/_pdf_section_details.html" %} + {% endif %} +
    + + {% if fair_compliance %} +
    + {% set fn = footnotes.section('fair').ref %} +

    FAIR Compliance{{ fn('fair_compliance') }} (optional metadata assessment)

    +
    + {{ fair_compliance.total_passed }}/{{ fair_compliance.total_expected }} checks passed + {{ fair_compliance.total_pct }}% +
    +
    +
    +
    + + {% for p in fair_compliance.principles %} + + {% endfor %} +
    +
    {{ p.name }}
    +
    {{ p.passed }}/{{ p.total }}
    +
    +
    + {% include "readiness_report/_pdf_fair_details.html" %} + {% set section_name = 'fair' %} + {% include "readiness_report/_pdf_section_footnotes.html" %} +
    + {% endif %} + +
    +

    Metric glossary

    +

    Definitions for metrics appearing in this report.

    + + + + + + + + {% for item in glossary %} + + {% endfor %} + +
    TermDefinition
    {{ item.term }}{{ item.definition }}
    +

    + HIPAA and PHI findings are automated pattern scans and do not constitute regulatory certification. + FAIR compliance evaluates uploaded metadata JSON, not the tabular dataset file. +

    + + +