"""Data profiling for LLM decision-making. Produces column-level statistics and a correlation matrix, with built-in context‑window truncation so the output stays under a configurable budget (default 16 KB). """ import json from typing import Any, Optional import polars as pl import numpy as np MAX_PROFILE_SIZE_KB = 16 """Maximum serialised profile size before truncation kicks in.""" MAX_SAMPLE_ROWS = 5000 """Maximum rows to collect for profiling.""" MAX_COLUMNS_FULL = 100 """Beyond this column count the profile is always truncated.""" MAX_CORRELATION_COLS = 20 """Maximum numeric columns to include in the correlation matrix.""" _ENTITY_KEYWORDS = frozenset({ 'ip', 'host', 'domain', 'user', 'client', 'src_ip', 'dst_ip', 'source', 'destination', 'address', 'entity', 'name', 'id', 'username', 'hostname', 'mac', 'email', 'phone', 'session', }) def _is_float_dtype(dtype: pl.DataType) -> bool: return dtype in (pl.Float32, pl.Float64) def _is_int_dtype(dtype: pl.DataType) -> bool: return dtype in (pl.Int8, pl.Int16, pl.Int32, pl.Int64, pl.UInt8, pl.UInt16, pl.UInt32, pl.UInt64) def _is_numeric_dtype(dtype: pl.DataType) -> bool: return _is_float_dtype(dtype) or _is_int_dtype(dtype) def _is_string_dtype(dtype: pl.DataType) -> bool: return dtype in (pl.Utf8, pl.String, pl.Categorical) def _is_temporal_dtype(dtype: pl.DataType) -> bool: return dtype in (pl.Date, pl.Datetime, pl.Time, pl.Duration) # ── column statistics ─────────────────────────────────────────────────── def _compute_column_stats(series: pl.Series) -> dict: """Return a dict of statistics for a single column, based on its dtype. Numeric columns get min/max/mean/std/median/percentiles. String columns get unique count, missing count, and top-5 frequencies. Boolean columns get true/false/null counts. Temporal columns get min/max/null_count. """ dtype = series.dtype stats: dict = { 'dtype': str(dtype), 'null_count': int(series.null_count()), 'non_null_count': int(len(series) - series.null_count()), } if _is_numeric_dtype(dtype): non_null = series.drop_nulls() if len(non_null) > 0: stats.update({ 'min': _safe_float(non_null.min()), 'max': _safe_float(non_null.max()), 'mean': _safe_float(non_null.mean()), 'std': _safe_float(non_null.std()), 'median': _safe_float(non_null.median()), 'q25': _safe_float(non_null.quantile(0.25)), 'q75': _safe_float(non_null.quantile(0.75)), 'unique_count': int(non_null.n_unique()), }) else: stats.update({ 'min': None, 'max': None, 'mean': None, 'std': None, 'median': None, 'q25': None, 'q75': None, 'unique_count': 0, }) stats['type_category'] = 'numeric' elif _is_string_dtype(dtype): non_null = series.drop_nulls() stats['unique_count'] = int(non_null.n_unique()) if len(non_null) > 0 else 0 stats['type_category'] = 'categorical' # Top-5 value counts if len(non_null) > 0: vc = non_null.value_counts().sort('count', descending=True).head(5) # Polars >=1.0 value_counts() uses original col name, not 'index' val_col = vc.columns[0] stats['top_values'] = [ {'value': str(row[val_col]), 'count': int(row['count'])} for row in vc.iter_rows(named=True) ] else: stats['top_values'] = [] # Detect entity column stats['is_entity_candidate'] = str(series.name).lower() in _ENTITY_KEYWORDS elif dtype == pl.Boolean: non_null = series.drop_nulls() if len(non_null) > 0: true_count = int(non_null.sum()) stats.update({ 'true_count': true_count, 'false_count': int(len(non_null)) - true_count, }) else: stats.update({'true_count': 0, 'false_count': 0}) stats['type_category'] = 'boolean' else: stats['type_category'] = 'other' stats['unique_count'] = int(series.drop_nulls().n_unique()) return stats def _safe_float(val: Any) -> Optional[float]: """Convert *val* to float or return None on failure.""" if val is None: return None try: f = float(val) return None if np.isnan(f) or np.isinf(f) else round(f, 6) except (ValueError, TypeError): return None # ── correlation matrix ────────────────────────────────────────────────── def _compute_correlation_matrix( df: pl.DataFrame, top_n: int = MAX_CORRELATION_COLS, ) -> Optional[list[dict]]: """Pearson correlation for numeric columns. Returns a list of ``{column_i, column_j, correlation}`` dicts for the top *top_n* columns by mean absolute correlation, sorted descending by absolute value. Returns *None* when fewer than 2 numeric columns exist. """ numeric_cols = [ col for col in df.columns if _is_numeric_dtype(df[col].dtype) ] if len(numeric_cols) < 2: return None # Limit to top_n numeric columns (pick by variance — highest first) if len(numeric_cols) > top_n: variances = [] for col in numeric_cols: v = df[col].drop_nulls().std() variances.append((col, 0.0 if v is None else float(v))) variances.sort(key=lambda x: x[1], reverse=True) numeric_cols = [c for c, _ in variances[:top_n]] # Build correlation matrix try: import pandas as pd pdf = df.select(numeric_cols).to_pandas() corr = pdf.corr(method='pearson') pairs: list[dict] = [] for i, col_i in enumerate(numeric_cols): for col_j in numeric_cols[i + 1:]: val = corr.loc[col_i, col_j] if np.isnan(val): continue pairs.append({ 'column_i': col_i, 'column_j': col_j, 'correlation': round(float(val), 4), }) except ImportError: # Pandas not available — compute simple correlation matrix via numpy data = df.select(numeric_cols).to_numpy() data = np.nan_to_num(data, nan=0.0) corr_np = np.corrcoef(data.T) pairs = [] for i in range(len(numeric_cols)): for j in range(i + 1, len(numeric_cols)): val = corr_np[i, j] if np.isnan(val): continue pairs.append({ 'column_i': numeric_cols[i], 'column_j': numeric_cols[j], 'correlation': round(float(val), 4), }) # Sort by absolute correlation descending pairs.sort(key=lambda x: abs(x['correlation']), reverse=True) return pairs[:top_n * 2] # cap output # ── truncation ────────────────────────────────────────────────────────── def _estimate_json_size(obj: Any) -> int: """Return the length of *obj* when serialised to JSON.""" return len(json.dumps(obj, default=str, ensure_ascii=False)) def truncate_profile( profile: dict, max_kb: int = MAX_PROFILE_SIZE_KB, ) -> dict: """Remove low-value columns from *profile* until it fits in *max_kb*. Sets ``truncated=True`` and adds ``omitted_columns`` if truncation occurred. Columns with the least variance (or highest null rate) are removed first. """ max_bytes = max_kb * 1024 if _estimate_json_size(profile) <= max_bytes: profile['truncated'] = False profile.setdefault('omitted_columns', []) return profile column_stats = profile.get('column_stats', {}) if not column_stats: profile['truncated'] = True return profile # Score columns: low variance / high null rate → remove first scored: list[tuple[str, float]] = [] for col_name, stats in column_stats.items(): null_rate = stats.get('null_count', 0) / max(stats.get('non_null_count', 1), 1) # Numeric columns get a variance estimate std = stats.get('std') variance_score = float(std) if std is not None else 0.0 # Remove low-variance OR high-null columns removal_score = -variance_score + null_rate * 100 scored.append((col_name, removal_score)) scored.sort(key=lambda x: x[1]) # most valuable first kept_columns = [] omitted_columns = [] for col_name, _ in scored: test_profile = dict(profile) test_profile['column_stats'] = { c: column_stats[c] for c in kept_columns + [col_name] } if _estimate_json_size(test_profile) <= max_bytes: kept_columns.append(col_name) else: omitted_columns.append(col_name) profile['column_stats'] = { c: column_stats[c] for c in kept_columns } profile['truncated'] = True profile['omitted_columns'] = omitted_columns profile['truncation_note'] = ( f"{len(omitted_columns)} of {len(omitted_columns) + len(kept_columns)} " f"columns omitted to stay under {max_kb} KB budget" ) return profile # ── main profiling entry point ────────────────────────────────────────── def profile_dataset( lf: pl.LazyFrame, sample_size: int = 1000, columns: Optional[list[str]] = None, max_kb: int = MAX_PROFILE_SIZE_KB, max_correlation_cols: int = MAX_CORRELATION_COLS, ) -> dict: """Compute column-level statistics and a correlation matrix. Args: lf: LazyFrame to profile. sample_size: Maximum rows to collect (sampled if larger). columns: Subset of columns to profile (``None`` = all). max_kb: Truncation budget in KB. max_correlation_cols: Max numeric columns for correlation. Returns: A dict with keys ``column_stats``, ``correlation_matrix``, ``truncated``, ``omitted_columns``, ``total_columns``, ``profiled_rows``, and ``sample_ratio``. """ # Collect (possibly sampled) dataframe df = lf.collect(streaming=True) total_rows = len(df) sample_ratio = 1.0 if total_rows > sample_size: df = df.sample(n=sample_size, seed=42) sample_ratio = sample_size / total_rows # Determine columns to profile if columns: profile_columns = [c for c in columns if c in df.columns] else: profile_columns = list(df.columns) if len(profile_columns) > MAX_COLUMNS_FULL: profile_columns = profile_columns[:MAX_COLUMNS_FULL] # Per-column stats column_stats: dict = {} for col_name in profile_columns: column_stats[col_name] = _compute_column_stats(df[col_name]) # Correlation matrix correlation_matrix = _compute_correlation_matrix(df, top_n=max_correlation_cols) result = { 'column_stats': column_stats, 'correlation_matrix': correlation_matrix, 'truncated': False, 'omitted_columns': [], 'total_columns': len(df.columns), 'profiled_columns': len(profile_columns), 'profiled_rows': len(df), 'total_rows': total_rows, 'sample_ratio': round(sample_ratio, 4), } return truncate_profile(result, max_kb=max_kb)