KaustavMP commited on
Commit
e5df031
·
0 Parent(s):

Initial FairSight project import

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .firebaserc +6 -0
  2. .gitignore +13 -0
  3. README.md +30 -0
  4. backend/.dockerignore +3 -0
  5. backend/Dockerfile +13 -0
  6. backend/config.py +35 -0
  7. backend/data/sample_dataset.csv +0 -0
  8. backend/main.py +154 -0
  9. backend/models/__init__.py +1 -0
  10. backend/models/enums.py +37 -0
  11. backend/models/schemas.py +205 -0
  12. backend/requirements.txt +8 -0
  13. backend/routers/__init__.py +1 -0
  14. backend/routers/alerts.py +36 -0
  15. backend/routers/fairness.py +89 -0
  16. backend/routers/overview.py +113 -0
  17. backend/routers/remediation.py +67 -0
  18. backend/routers/simulator.py +38 -0
  19. backend/services/__init__.py +1 -0
  20. backend/services/alert_service.py +153 -0
  21. backend/services/data_service.py +193 -0
  22. backend/services/fairness_engine.py +273 -0
  23. backend/services/proxy_detector.py +140 -0
  24. backend/services/remediation_service.py +302 -0
  25. backend/services/simulator_service.py +167 -0
  26. cloudbuild.yaml +34 -0
  27. firebase.json +27 -0
  28. firestore.indexes.json +32 -0
  29. firestore.rules +65 -0
  30. frontend/.env.example +8 -0
  31. frontend/index.html +13 -0
  32. frontend/package.json +24 -0
  33. frontend/src/App.jsx +48 -0
  34. frontend/src/components/AppShell.jsx +70 -0
  35. frontend/src/components/Charts.jsx +74 -0
  36. frontend/src/components/GeminiChat.jsx +61 -0
  37. frontend/src/contexts/AuthContext.jsx +45 -0
  38. frontend/src/hooks/useOrgCollection.js +39 -0
  39. frontend/src/lib/firebase.js +25 -0
  40. frontend/src/main.jsx +17 -0
  41. frontend/src/pages/AlertsPage.jsx +26 -0
  42. frontend/src/pages/DashboardPage.jsx +54 -0
  43. frontend/src/pages/LoginPage.jsx +51 -0
  44. frontend/src/pages/MetricsPage.jsx +54 -0
  45. frontend/src/pages/RemediationPage.jsx +21 -0
  46. frontend/src/pages/SimulatorPage.jsx +76 -0
  47. frontend/src/pages/UploadPage.jsx +63 -0
  48. frontend/src/styles/app.css +227 -0
  49. frontend/vite.config.js +10 -0
  50. functions/.env.example +5 -0
.firebaserc ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ {
2
+ "projects": {
3
+ "default": "fairlens-494713"
4
+ }
5
+ }
6
+
.gitignore ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ node_modules/
2
+ .firebase/
3
+ .env
4
+ .env.local
5
+ .env.*.local
6
+ frontend/dist/
7
+ functions/node_modules/
8
+ functions/.env
9
+ functions/.secret.local
10
+ *.log
11
+ __pycache__/
12
+ *.pyc
13
+
README.md ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # FairSight
2
+
3
+ Firebase + React + Cloud Run scaffolding for the FairSight bias detection dashboard.
4
+
5
+ ## Projects
6
+
7
+ - `frontend/`: React + Vite app
8
+ - `functions/`: Firebase Functions for upload pipeline, simulator wrapper, and Gemini integration
9
+ - `backend/`: Python fairness engine and Cloud Run service
10
+
11
+ ## Setup
12
+
13
+ 1. Install dependencies:
14
+ - `cd frontend && npm install`
15
+ - `cd ../functions && npm install`
16
+ 2. Create `frontend/.env.local` from `frontend/.env.example`.
17
+ 3. Create `functions/.env` from `functions/.env.example`.
18
+ 4. Authenticate Firebase CLI and select project `fairlens-494713`.
19
+ 5. Deploy backend to Cloud Run, then set `FAIRNESS_BACKEND_URL` in `functions/.env`.
20
+
21
+ ## Firebase / GCP commands
22
+
23
+ ```powershell
24
+ firebase use fairlens-494713
25
+ firebase deploy --only firestore,storage,functions,hosting
26
+ gcloud config set project fairlens-494713
27
+ gcloud builds submit backend --tag us-central1-docker.pkg.dev/fairlens-494713/fairlens/fairsight-backend
28
+ gcloud run deploy fairsight-backend --image us-central1-docker.pkg.dev/fairlens-494713/fairlens/fairsight-backend --platform managed --region us-central1 --allow-unauthenticated
29
+ ```
30
+
backend/.dockerignore ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ __pycache__/
2
+ *.pyc
3
+
backend/Dockerfile ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /app
4
+
5
+ COPY requirements.txt .
6
+ RUN pip install --no-cache-dir -r requirements.txt
7
+
8
+ COPY . .
9
+
10
+ ENV PORT=8080
11
+
12
+ CMD ["sh", "-c", "uvicorn main:app --host 0.0.0.0 --port ${PORT}"]
13
+
backend/config.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ FairSight — Application Configuration & Constants
3
+ """
4
+
5
+ # ── Disparate Impact thresholds (EEOC 4/5ths rule) ───────────────────────
6
+ DI_CRITICAL_THRESHOLD = 0.80
7
+ DI_WARNING_THRESHOLD = 0.90
8
+
9
+ # ── Equal Opportunity Gap thresholds ──────────────────────────────────────
10
+ EO_CRITICAL_THRESHOLD = 0.15
11
+ EO_WARNING_THRESHOLD = 0.08
12
+
13
+ # ── Calibration Gap thresholds ────────────────────────────────────────────
14
+ CAL_CRITICAL_THRESHOLD = 0.12
15
+ CAL_WARNING_THRESHOLD = 0.06
16
+
17
+ # ── Proxy detection correlation threshold ─────────────────────────────────
18
+ PROXY_CORRELATION_THRESHOLD = 0.15
19
+ PROXY_HIGH_THRESHOLD = 0.35
20
+
21
+ # ── Protected attributes recognized by the system ────────────────────────
22
+ PROTECTED_ATTRIBUTES = ["gender", "race", "age", "disability"]
23
+
24
+ # ── Feature columns (non-protected) ──────────────────────────────────────
25
+ FEATURE_COLUMNS = [
26
+ "credit_score", "zip_code", "income", "education",
27
+ "employment_years", "debt_ratio", "loan_amount",
28
+ "school_name", "neighborhood"
29
+ ]
30
+
31
+ # ── CORS Origins ──────────────────────────────────────────────────────────
32
+ ALLOWED_ORIGINS = ["*"]
33
+
34
+ # ── Dataset path ──────────────────────────────────────────────────────────
35
+ SAMPLE_DATASET_PATH = "data/sample_dataset.csv"
backend/data/sample_dataset.csv ADDED
The diff for this file is too large to render. See raw diff
 
backend/main.py ADDED
@@ -0,0 +1,154 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ FairSight — Bias Detection Suite
3
+ ═══════════════════════════════════════════════════════════════════════════
4
+ Inspect datasets and AI models for hidden discrimination before deployment.
5
+
6
+ Main FastAPI application entry point.
7
+ """
8
+ from fastapi import FastAPI, UploadFile, File, HTTPException
9
+ from fastapi.middleware.cors import CORSMiddleware
10
+ from contextlib import asynccontextmanager
11
+ import pandas as pd
12
+ import io
13
+
14
+ from config import ALLOWED_ORIGINS
15
+ from services.data_service import get_dataset, reload_dataset, get_dataset_info
16
+ from models.schemas import DatasetInfo
17
+
18
+ # Import routers
19
+ from routers.overview import router as overview_router
20
+ from routers.fairness import router as fairness_router
21
+ from routers.alerts import router as alerts_router
22
+ from routers.remediation import router as remediation_router
23
+ from routers.simulator import router as simulator_router
24
+
25
+
26
+ @asynccontextmanager
27
+ async def lifespan(app: FastAPI):
28
+ """Initialize the dataset on startup."""
29
+ print("⚡ FairSight — Initializing dataset...")
30
+ df = get_dataset()
31
+ print(f"✅ Dataset loaded: {len(df)} rows, {len(df.columns)} columns")
32
+ info = get_dataset_info()
33
+ print(f" Protected attributes: {info['protected_attributes_found']}")
34
+ print(f" Decision column: {info['decision_column']}")
35
+ print("🚀 FairSight backend is ready!")
36
+ yield
37
+ print("👋 FairSight shutting down.")
38
+
39
+
40
+ app = FastAPI(
41
+ title="FairSight — Bias Detection Suite",
42
+ description=(
43
+ "Inspect datasets and AI models for hidden discrimination before deployment. "
44
+ "Provides disparate impact analysis, proxy variable detection, "
45
+ "remediation recommendations, and what-if simulation."
46
+ ),
47
+ version="1.0.0",
48
+ lifespan=lifespan,
49
+ )
50
+
51
+ # ── CORS ──────────────────────────────────────────────────────────────────
52
+ app.add_middleware(
53
+ CORSMiddleware,
54
+ allow_origins=ALLOWED_ORIGINS,
55
+ allow_credentials=True,
56
+ allow_methods=["*"],
57
+ allow_headers=["*"],
58
+ )
59
+
60
+ # ── Mount routers ─────────────────────────────────────────────────────────
61
+ app.include_router(overview_router)
62
+ app.include_router(fairness_router)
63
+ app.include_router(alerts_router)
64
+ app.include_router(remediation_router)
65
+ app.include_router(simulator_router)
66
+
67
+
68
+ # ── Root endpoint ─────────────────────────────────────────────────────────
69
+ @app.get("/", tags=["Health"])
70
+ async def root():
71
+ return {
72
+ "name": "FairSight — Bias Detection Suite",
73
+ "version": "1.0.0",
74
+ "status": "running",
75
+ "endpoints": {
76
+ "overview": "/api/overview",
77
+ "fairness_metrics": "/api/fairness-metrics",
78
+ "alerts": "/api/alerts",
79
+ "remediation": "/api/remediation",
80
+ "simulator": "/api/simulator",
81
+ "dataset_info": "/api/dataset/info",
82
+ "upload_dataset": "/api/dataset/upload",
83
+ "docs": "/docs",
84
+ },
85
+ }
86
+
87
+
88
+ # ── Dataset management endpoints ─────────────────────────────────────────
89
+ @app.get("/api/dataset/info", response_model=DatasetInfo, tags=["Dataset"])
90
+ async def dataset_info():
91
+ """Return metadata about the currently loaded dataset."""
92
+ info = get_dataset_info()
93
+ return DatasetInfo(**info)
94
+
95
+
96
+ @app.post("/api/dataset/upload", response_model=DatasetInfo, tags=["Dataset"])
97
+ async def upload_dataset(file: UploadFile = File(...)):
98
+ """
99
+ Upload a custom CSV dataset.
100
+
101
+ Required columns:
102
+ - At least one protected attribute (gender, race, age, disability)
103
+ - A 'decision' column (0/1)
104
+ - A 'qualified' column (0/1) for EO and calibration metrics
105
+ - A 'predicted_prob' column (0–1) for calibration and scatter plots
106
+ """
107
+ if not file.filename.endswith(".csv"):
108
+ raise HTTPException(status_code=400, detail="Only CSV files are supported.")
109
+
110
+ try:
111
+ contents = await file.read()
112
+ df = pd.read_csv(io.BytesIO(contents))
113
+ except Exception as e:
114
+ raise HTTPException(status_code=400, detail=f"Failed to parse CSV: {str(e)}")
115
+
116
+ # Validate required columns
117
+ required = ["decision"]
118
+ missing = [col for col in required if col not in df.columns]
119
+ if missing:
120
+ raise HTTPException(
121
+ status_code=400,
122
+ detail=f"Missing required columns: {missing}. Must have at least 'decision'."
123
+ )
124
+
125
+ protected_found = [c for c in ["gender", "race", "age", "disability"] if c in df.columns]
126
+ if not protected_found:
127
+ raise HTTPException(
128
+ status_code=400,
129
+ detail="No protected attributes found. Include at least one of: gender, race, age, disability."
130
+ )
131
+
132
+ # Add missing optional columns with defaults
133
+ if "qualified" not in df.columns:
134
+ df["qualified"] = df["decision"] # assume decision = qualification if not provided
135
+ if "predicted_prob" not in df.columns:
136
+ df["predicted_prob"] = df["decision"].astype(float)
137
+
138
+ reload_dataset(df)
139
+ info = get_dataset_info()
140
+ return DatasetInfo(**info)
141
+
142
+
143
+ @app.post("/api/dataset/reset", tags=["Dataset"])
144
+ async def reset_dataset():
145
+ """Reset to the built-in sample dataset."""
146
+ reload_dataset(None)
147
+ info = get_dataset_info()
148
+ return {"message": "Dataset reset to sample data.", "info": info}
149
+
150
+
151
+ # ── Run with uvicorn ──────────────────────────────────────────────────────
152
+ if __name__ == "__main__":
153
+ import uvicorn
154
+ uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)
backend/models/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # FairSight Models Package
backend/models/enums.py ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ FairSight — Enumerations for protected attributes, severity levels, and metric types.
3
+ """
4
+ from enum import Enum
5
+
6
+
7
+ class ProtectedAttribute(str, Enum):
8
+ GENDER = "gender"
9
+ RACE = "race"
10
+ AGE = "age"
11
+ DISABILITY = "disability"
12
+
13
+
14
+ class Severity(str, Enum):
15
+ CRITICAL = "critical"
16
+ WARNING = "warning"
17
+ INFO = "info"
18
+
19
+
20
+ class AlertType(str, Enum):
21
+ DIRECT_DISCRIMINATION = "direct_discrimination"
22
+ PROXY_VARIABLE = "proxy_variable"
23
+ SAMPLE_SIZE = "sample_size"
24
+ CALIBRATION = "calibration"
25
+
26
+
27
+ class RemediationType(str, Enum):
28
+ REMOVE_PROXY = "remove_proxy"
29
+ REWEIGHT_DATA = "reweight_data"
30
+ ADJUST_THRESHOLD = "adjust_threshold"
31
+ POST_PROCESS = "post_process"
32
+
33
+
34
+ class MetricStatus(str, Enum):
35
+ PASSING = "passing"
36
+ NEEDS_REVIEW = "needs_review"
37
+ CRITICAL = "critical"
backend/models/schemas.py ADDED
@@ -0,0 +1,205 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ FairSight — Pydantic schemas for all API request and response models.
3
+ """
4
+ from __future__ import annotations
5
+ from typing import Optional
6
+ from pydantic import BaseModel, Field
7
+
8
+
9
+ # ═══════════════════════════════════════════════════════════════════════════
10
+ # Overview endpoint schemas
11
+ # ═══════════════════════════════════════════════════════════════════════════
12
+
13
+ class ApprovalRate(BaseModel):
14
+ group: str
15
+ rate: float = Field(..., ge=0, le=1, description="Approval rate 0–1")
16
+ count: int = Field(..., description="Total individuals in group")
17
+
18
+
19
+ class FairnessScore(BaseModel):
20
+ metric: str
21
+ score: float = Field(..., ge=0, le=100)
22
+ status: str # "passing", "needs_review", "critical"
23
+ description: str
24
+
25
+
26
+ class HeatmapCell(BaseModel):
27
+ feature: str
28
+ protected_attribute: str
29
+ correlation: float = Field(..., ge=0, le=1)
30
+
31
+
32
+ class OverviewResponse(BaseModel):
33
+ overall_fairness_score: float = Field(..., ge=0, le=100)
34
+ disparate_impact_ratio: float
35
+ groups_analyzed: int
36
+ group_names: list[str]
37
+ flagged_features: int
38
+ proxy_variables_found: int
39
+ approval_rates: list[ApprovalRate]
40
+ fairness_scores: list[FairnessScore]
41
+ heatmap: list[HeatmapCell]
42
+
43
+
44
+ # ═══════════════════════════════════════════════════════════════════════════
45
+ # Fairness Metrics endpoint schemas
46
+ # ═══════════════════════════════════════════════════════════════════════════
47
+
48
+ class GroupMetric(BaseModel):
49
+ group: str
50
+ value: float
51
+ sample_size: int
52
+
53
+
54
+ class ScatterPoint(BaseModel):
55
+ actual: float
56
+ predicted: float
57
+ group: str
58
+
59
+
60
+ class FairnessMetricsResponse(BaseModel):
61
+ protected_attribute: str
62
+ disparate_impact: float
63
+ disparate_impact_status: str
64
+ equal_opportunity_gap: float
65
+ equal_opportunity_status: str
66
+ calibration_gap: float
67
+ calibration_status: str
68
+ group_details: list[GroupMetric]
69
+ scatter_data: list[ScatterPoint]
70
+ reference_group: str
71
+ description: str
72
+
73
+
74
+ # ═══════════════════════════════════════════════════════════════════════════
75
+ # Alerts endpoint schemas
76
+ # ═══════════════════════════════════════════════════════════════════════════
77
+
78
+ class Alert(BaseModel):
79
+ id: str
80
+ type: str # "direct_discrimination" | "proxy_variable"
81
+ severity: str # "critical" | "warning" | "info"
82
+ title: str
83
+ description: str
84
+ affected_groups: list[str]
85
+ affected_features: list[str]
86
+ metric_value: Optional[float] = None
87
+ threshold: Optional[float] = None
88
+ recommendation: str
89
+
90
+
91
+ class AlertsResponse(BaseModel):
92
+ total_alerts: int
93
+ critical_count: int
94
+ warning_count: int
95
+ info_count: int
96
+ alerts: list[Alert]
97
+
98
+
99
+ # ═══════════════════════════════════════════════════════════════════════════
100
+ # Remediation endpoint schemas
101
+ # ═══════════════════════════════════════════════════════════════════════════
102
+
103
+ class Intervention(BaseModel):
104
+ id: str
105
+ type: str # "remove_proxy" | "reweight_data" | "adjust_threshold" | "post_process"
106
+ title: str
107
+ description: str
108
+ priority: int = Field(..., ge=1, le=5)
109
+ expected_di_improvement: float
110
+ expected_eo_improvement: float
111
+ expected_cal_improvement: float
112
+ risk_level: str
113
+ implementation_effort: str # "low" | "medium" | "high"
114
+ affected_features: list[str]
115
+
116
+
117
+ class FairnessConstraint(BaseModel):
118
+ name: str
119
+ current_value: float
120
+ min_value: float
121
+ max_value: float
122
+ step: float
123
+ description: str
124
+
125
+
126
+ class RemediationResponse(BaseModel):
127
+ current_disparate_impact: float
128
+ target_disparate_impact: float
129
+ interventions: list[Intervention]
130
+ fairness_constraints: list[FairnessConstraint]
131
+
132
+
133
+ class ApplyRemediationRequest(BaseModel):
134
+ intervention_ids: list[str]
135
+ fairness_constraints: dict[str, float] = Field(
136
+ default_factory=dict,
137
+ description="Map of constraint name → desired value"
138
+ )
139
+
140
+
141
+ class ApplyRemediationResponse(BaseModel):
142
+ original_disparate_impact: float
143
+ new_disparate_impact: float
144
+ original_eo_gap: float
145
+ new_eo_gap: float
146
+ original_cal_gap: float
147
+ new_cal_gap: float
148
+ approval_rates_before: list[ApprovalRate]
149
+ approval_rates_after: list[ApprovalRate]
150
+ interventions_applied: list[str]
151
+
152
+
153
+ # ═══════════════════════════════════════════════════════════════════════════
154
+ # Simulator endpoint schemas
155
+ # ═══════════════════════════════════════════════════════════════════════════
156
+
157
+ class SimulatorRequest(BaseModel):
158
+ threshold_adjustment: float = Field(
159
+ default=0.0, ge=-0.5, le=0.5,
160
+ description="Shift the decision threshold (negative = more approvals)"
161
+ )
162
+ reweighting_strength: float = Field(
163
+ default=0.0, ge=0.0, le=1.0,
164
+ description="How aggressively to reweight underrepresented groups (0=none, 1=full)"
165
+ )
166
+ calibration_strength: float = Field(
167
+ default=0.0, ge=0.0, le=1.0,
168
+ description="How much to apply Platt-style calibration (0=none, 1=full)"
169
+ )
170
+ remove_features: list[str] = Field(
171
+ default_factory=list,
172
+ description="Feature names to remove from the model"
173
+ )
174
+ protected_attribute: str = Field(
175
+ default="race",
176
+ description="Protected attribute to evaluate"
177
+ )
178
+
179
+
180
+ class SimulatorResult(BaseModel):
181
+ original_approval_rates: list[ApprovalRate]
182
+ simulated_approval_rates: list[ApprovalRate]
183
+ original_disparate_impact: float
184
+ simulated_disparate_impact: float
185
+ original_eo_gap: float
186
+ simulated_eo_gap: float
187
+ original_cal_gap: float
188
+ simulated_cal_gap: float
189
+ overall_approval_change: float
190
+ accuracy_impact: float = Field(
191
+ ..., description="Estimated accuracy change (negative = accuracy drop)"
192
+ )
193
+
194
+
195
+ # ═══════════════════════════════════════════════════════════════════════════
196
+ # Dataset upload schema
197
+ # ═══════════════════════════════════════════════════════════════════════════
198
+
199
+ class DatasetInfo(BaseModel):
200
+ rows: int
201
+ columns: int
202
+ column_names: list[str]
203
+ protected_attributes_found: list[str]
204
+ decision_column: str
205
+ qualification_column: str
backend/requirements.txt ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ fastapi==0.111.0
2
+ uvicorn[standard]==0.30.1
3
+ pandas==2.2.2
4
+ numpy==1.26.4
5
+ scikit-learn==1.5.0
6
+ scipy==1.13.1
7
+ pydantic==2.7.4
8
+ python-multipart==0.0.9
backend/routers/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # FairSight Routers Package
backend/routers/alerts.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ FairSight — Alerts Router
3
+ GET /api/alerts → priority-ordered fairness issues
4
+ """
5
+ from fastapi import APIRouter
6
+
7
+ from services.data_service import get_dataset
8
+ from services.alert_service import generate_alerts
9
+ from models.schemas import AlertsResponse
10
+
11
+ router = APIRouter(prefix="/api", tags=["Alerts"])
12
+
13
+
14
+ @router.get("/alerts", response_model=AlertsResponse)
15
+ async def get_alerts():
16
+ """
17
+ Surface critical fairness issues in priority order.
18
+
19
+ Flags both direct discrimination (disparate impact, equal opportunity
20
+ violations) and proxy variables (features that appear neutral but
21
+ encode protected characteristics).
22
+ """
23
+ df = get_dataset()
24
+ alerts = generate_alerts(df)
25
+
26
+ critical_count = sum(1 for a in alerts if a["severity"] == "critical")
27
+ warning_count = sum(1 for a in alerts if a["severity"] == "warning")
28
+ info_count = sum(1 for a in alerts if a["severity"] == "info")
29
+
30
+ return AlertsResponse(
31
+ total_alerts=len(alerts),
32
+ critical_count=critical_count,
33
+ warning_count=warning_count,
34
+ info_count=info_count,
35
+ alerts=alerts,
36
+ )
backend/routers/fairness.py ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ FairSight — Fairness Metrics Router
3
+ GET /api/fairness-metrics?protected_attr=race → detailed fairness breakdown
4
+ """
5
+ from fastapi import APIRouter, Query
6
+
7
+ from services.data_service import get_dataset
8
+ from services.fairness_engine import (
9
+ compute_disparate_impact,
10
+ compute_equal_opportunity_gap,
11
+ compute_calibration_gap,
12
+ compute_scatter_data,
13
+ )
14
+ from models.schemas import FairnessMetricsResponse
15
+
16
+ router = APIRouter(prefix="/api", tags=["Fairness Metrics"])
17
+
18
+
19
+ @router.get("/fairness-metrics", response_model=FairnessMetricsResponse)
20
+ async def get_fairness_metrics(
21
+ protected_attr: str = Query(
22
+ default="race",
23
+ description="Protected attribute to evaluate: gender, race, age, disability"
24
+ ),
25
+ decision_type: str = Query(
26
+ default="decision",
27
+ description="Column name for the decision outcome"
28
+ ),
29
+ ):
30
+ """
31
+ Compute and return detailed fairness metrics for a specific protected attribute.
32
+
33
+ Returns:
34
+ - Disparate impact ratio with 4/5ths rule evaluation
35
+ - Equal opportunity gap (TPR parity)
36
+ - Calibration gap (prediction-reality alignment)
37
+ - Scatter plot data (predicted vs actual qualifications)
38
+ - Per-group breakdown
39
+ """
40
+ df = get_dataset()
41
+
42
+ if protected_attr not in df.columns:
43
+ protected_attr = "race"
44
+
45
+ if decision_type not in df.columns:
46
+ decision_type = "decision"
47
+
48
+ # ── Compute all three metrics ─────────────────────────────────────────
49
+ di_result = compute_disparate_impact(df, protected_attr, decision_type)
50
+ eo_result = compute_equal_opportunity_gap(df, protected_attr, decision_type)
51
+ cal_result = compute_calibration_gap(df, protected_attr)
52
+ scatter = compute_scatter_data(df, protected_attr)
53
+
54
+ # ── Per-group details ─────────────────────────────────────────────────
55
+ group_details = []
56
+ for group in sorted(df[protected_attr].unique()):
57
+ mask = df[protected_attr] == group
58
+ group_details.append({
59
+ "group": str(group),
60
+ "value": round(float(df.loc[mask, decision_type].mean()), 3),
61
+ "sample_size": int(mask.sum()),
62
+ })
63
+
64
+ # ── Description ───────────────────────────────────────────────────────
65
+ description = (
66
+ f"Fairness analysis for '{protected_attr}' across {len(group_details)} groups. "
67
+ f"Reference group (highest approval): {di_result.get('reference_group', 'N/A')}. "
68
+ )
69
+
70
+ if di_result["ratio"] < 0.80:
71
+ description += "⚠️ The 4/5ths rule is violated — this may indicate illegal discrimination. "
72
+ if eo_result["gap"] > 0.10:
73
+ description += "Qualified individuals face significantly unequal treatment across groups. "
74
+ if cal_result["gap"] > 0.08:
75
+ description += "Model predictions are not equally calibrated across groups."
76
+
77
+ return FairnessMetricsResponse(
78
+ protected_attribute=protected_attr,
79
+ disparate_impact=di_result["ratio"],
80
+ disparate_impact_status=di_result["status"].value,
81
+ equal_opportunity_gap=eo_result["gap"],
82
+ equal_opportunity_status=eo_result["status"].value,
83
+ calibration_gap=cal_result["gap"],
84
+ calibration_status=cal_result["status"].value,
85
+ group_details=group_details,
86
+ scatter_data=scatter,
87
+ reference_group=di_result.get("reference_group", ""),
88
+ description=description,
89
+ )
backend/routers/overview.py ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ FairSight — Overview Router
3
+ GET /api/overview → at-a-glance fairness scorecard
4
+ """
5
+ from fastapi import APIRouter
6
+
7
+ from services.data_service import get_dataset
8
+ from services.fairness_engine import (
9
+ compute_disparate_impact,
10
+ compute_equal_opportunity_gap,
11
+ compute_calibration_gap,
12
+ compute_overall_fairness_score,
13
+ get_metric_status_label,
14
+ )
15
+ from services.proxy_detector import get_heatmap_data, detect_proxy_variables
16
+ from config import PROTECTED_ATTRIBUTES, FEATURE_COLUMNS
17
+ from models.schemas import OverviewResponse
18
+
19
+ router = APIRouter(prefix="/api", tags=["Overview"])
20
+
21
+
22
+ @router.get("/overview", response_model=OverviewResponse)
23
+ async def get_overview():
24
+ """
25
+ Return the at-a-glance fairness scorecard:
26
+ - Overall fairness score (0–100)
27
+ - Disparate impact ratio
28
+ - Groups analyzed
29
+ - Flagged features / proxy variables
30
+ - Approval rates by demographic group
31
+ - Model fairness scores (DI, EO, Calibration)
32
+ - Dataset quality heatmap
33
+ """
34
+ df = get_dataset()
35
+
36
+ available_attrs = [a for a in PROTECTED_ATTRIBUTES if a in df.columns]
37
+ available_features = [f for f in FEATURE_COLUMNS if f in df.columns]
38
+
39
+ # ── Collect all unique groups ─────────────────────────────────────────
40
+ all_groups = set()
41
+ for attr in available_attrs:
42
+ all_groups.update(str(v) for v in df[attr].unique().tolist())
43
+
44
+ # ── Primary metric: race (most common fairness evaluation) ────────────
45
+ primary_attr = "race" if "race" in df.columns else available_attrs[0]
46
+
47
+ di_result = compute_disparate_impact(df, primary_attr)
48
+ eo_result = compute_equal_opportunity_gap(df, primary_attr)
49
+ cal_result = compute_calibration_gap(df, primary_attr)
50
+
51
+ overall_score = compute_overall_fairness_score(
52
+ di_result["ratio"], eo_result["gap"], cal_result["gap"]
53
+ )
54
+
55
+ # ── Proxy detection ───────────────────────────────────────────────────
56
+ proxies = detect_proxy_variables(df, available_features, available_attrs)
57
+ flagged_features = len(set(p["feature"] for p in proxies))
58
+
59
+ # ── Approval rates by demographic group ───────────────────────────────
60
+ approval_rates = []
61
+ for attr in available_attrs:
62
+ for group in sorted(df[attr].unique()):
63
+ mask = df[attr] == group
64
+ rate = df.loc[mask, "decision"].mean()
65
+ count = int(mask.sum())
66
+ # Avoid duplicates if same group name across attributes
67
+ if not any(ar["group"] == str(group) for ar in approval_rates):
68
+ approval_rates.append({
69
+ "group": str(group),
70
+ "rate": round(float(rate), 3),
71
+ "count": count,
72
+ })
73
+
74
+ # ── Fairness scores ───────────────────────────────────────────────────
75
+ di_score_value = round(min(100, max(0, (di_result["ratio"] / 1.0) * 100)), 1)
76
+ eo_score_value = round(min(100, max(0, (1 - eo_result["gap"] / 0.30) * 100)), 1)
77
+ cal_score_value = round(min(100, max(0, (1 - cal_result["gap"] / 0.30) * 100)), 1)
78
+
79
+ fairness_scores = [
80
+ {
81
+ "metric": "Disparate impact",
82
+ "score": di_score_value,
83
+ "status": di_result["status"].value,
84
+ "description": f"DI ratio of {di_result['ratio']:.2f} — {'below' if di_result['ratio'] < 0.80 else 'at or above'} the 4/5ths threshold",
85
+ },
86
+ {
87
+ "metric": "Equal opportunity",
88
+ "score": eo_score_value,
89
+ "status": eo_result["status"].value,
90
+ "description": f"EO gap of {eo_result['gap']:.1%} between best and worst performing groups",
91
+ },
92
+ {
93
+ "metric": "Calibration",
94
+ "score": cal_score_value,
95
+ "status": cal_result["status"].value,
96
+ "description": f"Calibration gap of {cal_result['gap']:.1%} across groups",
97
+ },
98
+ ]
99
+
100
+ # ── Heatmap data ──────────────────────────────────────────────────────
101
+ heatmap = get_heatmap_data(df, available_features, available_attrs)
102
+
103
+ return OverviewResponse(
104
+ overall_fairness_score=overall_score,
105
+ disparate_impact_ratio=di_result["ratio"],
106
+ groups_analyzed=len(all_groups),
107
+ group_names=sorted(all_groups),
108
+ flagged_features=flagged_features,
109
+ proxy_variables_found=len(proxies),
110
+ approval_rates=approval_rates,
111
+ fairness_scores=fairness_scores,
112
+ heatmap=heatmap,
113
+ )
backend/routers/remediation.py ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ FairSight — Remediation Router
3
+ GET /api/remediation → get prioritized interventions
4
+ POST /api/remediation/apply → apply selected interventions and see impact
5
+ """
6
+ from fastapi import APIRouter, Query
7
+
8
+ from services.data_service import get_dataset
9
+ from services.fairness_engine import compute_disparate_impact
10
+ from services.remediation_service import (
11
+ generate_interventions,
12
+ get_fairness_constraints,
13
+ apply_remediation,
14
+ )
15
+ from models.schemas import (
16
+ RemediationResponse,
17
+ ApplyRemediationRequest,
18
+ ApplyRemediationResponse,
19
+ )
20
+
21
+ router = APIRouter(prefix="/api", tags=["Remediation"])
22
+
23
+
24
+ @router.get("/remediation", response_model=RemediationResponse)
25
+ async def get_remediation():
26
+ """
27
+ Return prioritized remediation interventions with expected impact estimates.
28
+
29
+ Intervention types:
30
+ - remove_proxy: Remove features acting as demographic proxies
31
+ - reweight_data: Reweight training samples to balance outcomes
32
+ - adjust_threshold: Group-specific decision threshold tuning
33
+ - post_process: Post-processing calibration
34
+ """
35
+ df = get_dataset()
36
+
37
+ interventions = generate_interventions(df)
38
+ constraints = get_fairness_constraints()
39
+
40
+ di = compute_disparate_impact(df, "race")
41
+
42
+ return RemediationResponse(
43
+ current_disparate_impact=di["ratio"],
44
+ target_disparate_impact=0.80,
45
+ interventions=interventions,
46
+ fairness_constraints=constraints,
47
+ )
48
+
49
+
50
+ @router.post("/remediation/apply", response_model=ApplyRemediationResponse)
51
+ async def apply_remediation_endpoint(
52
+ request: ApplyRemediationRequest,
53
+ protected_attr: str = Query(default="race"),
54
+ ):
55
+ """
56
+ Apply selected interventions and return before/after fairness comparison.
57
+ """
58
+ df = get_dataset()
59
+
60
+ result = apply_remediation(
61
+ df,
62
+ intervention_ids=request.intervention_ids,
63
+ constraints=request.fairness_constraints,
64
+ protected_attr=protected_attr,
65
+ )
66
+
67
+ return ApplyRemediationResponse(**result)
backend/routers/simulator.py ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ FairSight — Simulator Router
3
+ POST /api/simulator → what-if simulation with adjustable parameters
4
+ """
5
+ from fastapi import APIRouter
6
+
7
+ from services.data_service import get_dataset
8
+ from services.simulator_service import simulate
9
+ from models.schemas import SimulatorRequest, SimulatorResult
10
+
11
+ router = APIRouter(prefix="/api", tags=["Simulator"])
12
+
13
+
14
+ @router.post("/simulator", response_model=SimulatorResult)
15
+ async def run_simulation(request: SimulatorRequest):
16
+ """
17
+ Run a what-if simulation to see how fairness metrics change
18
+ with different interventions, before touching the actual model.
19
+
20
+ Parameters:
21
+ - threshold_adjustment: shift the decision threshold
22
+ - reweighting_strength: how aggressively to uplift underrepresented groups
23
+ - calibration_strength: Platt-style calibration intensity
24
+ - remove_features: proxy features to exclude
25
+ - protected_attribute: attribute to evaluate against
26
+ """
27
+ df = get_dataset()
28
+
29
+ result = simulate(
30
+ df,
31
+ threshold_adjustment=request.threshold_adjustment,
32
+ reweighting_strength=request.reweighting_strength,
33
+ calibration_strength=request.calibration_strength,
34
+ remove_features=request.remove_features,
35
+ protected_attribute=request.protected_attribute,
36
+ )
37
+
38
+ return SimulatorResult(**result)
backend/services/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # FairSight Services Package
backend/services/alert_service.py ADDED
@@ -0,0 +1,153 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ FairSight — Alert Service
3
+ Generates priority-ordered alerts for direct discrimination and proxy variable issues.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import uuid
8
+ import pandas as pd
9
+
10
+ from models.enums import Severity, AlertType
11
+ from services.fairness_engine import (
12
+ compute_disparate_impact,
13
+ compute_equal_opportunity_gap,
14
+ compute_calibration_gap,
15
+ )
16
+ from services.proxy_detector import detect_proxy_variables
17
+ from config import (
18
+ DI_CRITICAL_THRESHOLD,
19
+ DI_WARNING_THRESHOLD,
20
+ EO_CRITICAL_THRESHOLD,
21
+ EO_WARNING_THRESHOLD,
22
+ PROTECTED_ATTRIBUTES,
23
+ FEATURE_COLUMNS,
24
+ )
25
+
26
+
27
+ def generate_alerts(df: pd.DataFrame) -> list[dict]:
28
+ """
29
+ Analyze the dataset and generate a prioritized list of alerts.
30
+ Combines direct discrimination detection with proxy variable detection.
31
+ """
32
+ alerts: list[dict] = []
33
+
34
+ # ── 1. Direct discrimination alerts (per protected attribute) ─────────
35
+ for attr in PROTECTED_ATTRIBUTES:
36
+ if attr not in df.columns:
37
+ continue
38
+
39
+ # Disparate impact alerts
40
+ di_result = compute_disparate_impact(df, attr)
41
+ di_ratio = di_result["ratio"]
42
+ group_rates = di_result.get("group_rates", {})
43
+ ref_group = di_result.get("reference_group", "")
44
+
45
+ if di_ratio < DI_CRITICAL_THRESHOLD:
46
+ # Find the most disadvantaged group
47
+ worst_group = min(group_rates, key=group_rates.get)
48
+ alerts.append({
49
+ "id": f"di-{attr}-{uuid.uuid4().hex[:8]}",
50
+ "type": AlertType.DIRECT_DISCRIMINATION.value,
51
+ "severity": Severity.CRITICAL.value,
52
+ "title": f"Critical Disparate Impact on {attr.title()}",
53
+ "description": (
54
+ f"The disparate impact ratio for '{attr}' is {di_ratio:.2f}, "
55
+ f"well below the EEOC 4/5ths threshold of 0.80. "
56
+ f"Group '{worst_group}' has an approval rate of "
57
+ f"{group_rates.get(worst_group, 0):.1%} compared to "
58
+ f"'{ref_group}' at {group_rates.get(ref_group, 0):.1%}."
59
+ ),
60
+ "affected_groups": [worst_group, ref_group],
61
+ "affected_features": [],
62
+ "metric_value": di_ratio,
63
+ "threshold": DI_CRITICAL_THRESHOLD,
64
+ "recommendation": (
65
+ f"Review decision criteria for bias against '{worst_group}'. "
66
+ f"Consider threshold adjustments or reweighting training data."
67
+ ),
68
+ })
69
+ elif di_ratio < DI_WARNING_THRESHOLD:
70
+ worst_group = min(group_rates, key=group_rates.get)
71
+ alerts.append({
72
+ "id": f"di-{attr}-{uuid.uuid4().hex[:8]}",
73
+ "type": AlertType.DIRECT_DISCRIMINATION.value,
74
+ "severity": Severity.WARNING.value,
75
+ "title": f"Disparate Impact Warning on {attr.title()}",
76
+ "description": (
77
+ f"The disparate impact ratio for '{attr}' is {di_ratio:.2f}, "
78
+ f"approaching the 0.80 threshold."
79
+ ),
80
+ "affected_groups": list(group_rates.keys()),
81
+ "affected_features": [],
82
+ "metric_value": di_ratio,
83
+ "threshold": DI_WARNING_THRESHOLD,
84
+ "recommendation": f"Monitor {attr} disparate impact closely.",
85
+ })
86
+
87
+ # Equal opportunity alerts
88
+ eo_result = compute_equal_opportunity_gap(df, attr)
89
+ eo_gap = eo_result["gap"]
90
+
91
+ if eo_gap > EO_CRITICAL_THRESHOLD:
92
+ tpr_data = eo_result.get("tpr_by_group", {})
93
+ worst_eo_group = min(tpr_data, key=tpr_data.get) if tpr_data else ""
94
+ alerts.append({
95
+ "id": f"eo-{attr}-{uuid.uuid4().hex[:8]}",
96
+ "type": AlertType.DIRECT_DISCRIMINATION.value,
97
+ "severity": Severity.CRITICAL.value,
98
+ "title": f"Equal Opportunity Violation on {attr.title()}",
99
+ "description": (
100
+ f"Qualified individuals from '{worst_eo_group}' are {eo_gap:.1%} "
101
+ f"less likely to be approved than qualified individuals from other groups."
102
+ ),
103
+ "affected_groups": list(tpr_data.keys()),
104
+ "affected_features": [],
105
+ "metric_value": eo_gap,
106
+ "threshold": EO_CRITICAL_THRESHOLD,
107
+ "recommendation": (
108
+ "Ensure qualified applicants receive equal consideration "
109
+ "regardless of group membership."
110
+ ),
111
+ })
112
+ elif eo_gap > EO_WARNING_THRESHOLD:
113
+ alerts.append({
114
+ "id": f"eo-{attr}-{uuid.uuid4().hex[:8]}",
115
+ "type": AlertType.DIRECT_DISCRIMINATION.value,
116
+ "severity": Severity.WARNING.value,
117
+ "title": f"Equal Opportunity Gap on {attr.title()}",
118
+ "description": f"An equal opportunity gap of {eo_gap:.1%} was detected for '{attr}'.",
119
+ "affected_groups": list(eo_result.get("tpr_by_group", {}).keys()),
120
+ "affected_features": [],
121
+ "metric_value": eo_gap,
122
+ "threshold": EO_WARNING_THRESHOLD,
123
+ "recommendation": "Review model for potential equal opportunity issues.",
124
+ })
125
+
126
+ # ── 2. Proxy variable alerts ──────────────────────────────────────────
127
+ available_features = [f for f in FEATURE_COLUMNS if f in df.columns]
128
+ available_attrs = [a for a in PROTECTED_ATTRIBUTES if a in df.columns]
129
+
130
+ proxies = detect_proxy_variables(df, available_features, available_attrs)
131
+
132
+ for proxy in proxies:
133
+ alerts.append({
134
+ "id": f"proxy-{proxy['feature']}-{proxy['protected_attribute']}-{uuid.uuid4().hex[:8]}",
135
+ "type": AlertType.PROXY_VARIABLE.value,
136
+ "severity": proxy["severity"],
137
+ "title": f"Proxy Variable Detected: {proxy['feature']}",
138
+ "description": proxy["description"],
139
+ "affected_groups": [],
140
+ "affected_features": [proxy["feature"]],
141
+ "metric_value": proxy["correlation"],
142
+ "threshold": 0.15,
143
+ "recommendation": (
144
+ f"Consider removing or transforming '{proxy['feature']}' to prevent "
145
+ f"indirect discrimination via '{proxy['protected_attribute']}'."
146
+ ),
147
+ })
148
+
149
+ # ── 3. Sort by priority: critical > warning > info ────────────────────
150
+ severity_order = {Severity.CRITICAL.value: 0, Severity.WARNING.value: 1, Severity.INFO.value: 2}
151
+ alerts.sort(key=lambda a: (severity_order.get(a["severity"], 99), -(a.get("metric_value") or 0)))
152
+
153
+ return alerts
backend/services/data_service.py ADDED
@@ -0,0 +1,193 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ FairSight — Data Service
3
+ Generates a realistic synthetic dataset for bias-detection demos.
4
+ Handles loading, validation, and profiling of datasets.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ import numpy as np
10
+ import pandas as pd
11
+ from pathlib import Path
12
+
13
+
14
+ # ── Singleton dataset cache ───────────────────────────────────────────────
15
+ _dataset_cache: pd.DataFrame | None = None
16
+
17
+
18
+ def _generate_synthetic_dataset(n: int = 5000, seed: int = 42) -> pd.DataFrame:
19
+ """
20
+ Generate a synthetic lending/hiring dataset with realistic biases baked in
21
+ to demonstrate the detection capabilities of FairSight.
22
+ """
23
+ rng = np.random.default_rng(seed)
24
+
25
+ # ── Demographics ──────────────────────────────────────────────────────
26
+ race_groups = ["White", "Black", "Hispanic", "Asian"]
27
+ race_weights = [0.35, 0.25, 0.25, 0.15]
28
+ race = rng.choice(race_groups, size=n, p=race_weights)
29
+
30
+ gender = rng.choice(["Male", "Female"], size=n, p=[0.52, 0.48])
31
+
32
+ age = np.clip(rng.normal(40, 12, size=n).astype(int), 18, 75)
33
+ disability = rng.choice(["Yes", "No"], size=n, p=[0.12, 0.88])
34
+
35
+ # ── Features ──────────────────────────────────────────────────────────
36
+ # Credit score: correlated with race (introducing bias)
37
+ base_credit = rng.normal(680, 80, size=n)
38
+ race_credit_bias = np.where(
39
+ race == "White", 20,
40
+ np.where(race == "Asian", 15,
41
+ np.where(race == "Hispanic", -10, -25))
42
+ )
43
+ credit_score = np.clip(base_credit + race_credit_bias, 300, 850).astype(int)
44
+
45
+ # Zip code: strongly correlated with race (proxy variable)
46
+ zip_base = np.where(
47
+ race == "White", rng.choice(range(10001, 10050), size=n),
48
+ np.where(race == "Black", rng.choice(range(10051, 10100), size=n),
49
+ np.where(race == "Hispanic", rng.choice(range(10101, 10150), size=n),
50
+ rng.choice(range(10151, 10200), size=n)))
51
+ )
52
+ zip_code = zip_base
53
+
54
+ # Income: correlated with gender and race
55
+ base_income = rng.lognormal(mean=10.8, sigma=0.5, size=n)
56
+ gender_income_bias = np.where(gender == "Male", 1.08, 0.92)
57
+ race_income_bias = np.where(
58
+ race == "White", 1.05,
59
+ np.where(race == "Asian", 1.03,
60
+ np.where(race == "Hispanic", 0.93, 0.88))
61
+ )
62
+ income = (base_income * gender_income_bias * race_income_bias).astype(int)
63
+
64
+ # Education level (1–5)
65
+ education = np.clip(rng.normal(3, 1, size=n).astype(int), 1, 5)
66
+
67
+ # Employment years
68
+ employment_years = np.clip(rng.exponential(8, size=n).astype(int), 0, 40)
69
+
70
+ # Debt ratio
71
+ debt_ratio = np.clip(rng.beta(2, 5, size=n), 0, 1).round(2)
72
+
73
+ # Loan amount
74
+ loan_amount = (rng.lognormal(mean=10, sigma=0.8, size=n)).astype(int)
75
+
76
+ # School name: another proxy for race/socioeconomic status
77
+ school_categories = np.where(
78
+ race == "White", rng.choice(["Ivy_League", "State_Univ", "Community_College"], size=n, p=[0.15, 0.55, 0.30]),
79
+ np.where(race == "Asian", rng.choice(["Ivy_League", "State_Univ", "Community_College"], size=n, p=[0.20, 0.50, 0.30]),
80
+ np.where(race == "Hispanic", rng.choice(["Ivy_League", "State_Univ", "Community_College"], size=n, p=[0.05, 0.40, 0.55]),
81
+ rng.choice(["Ivy_League", "State_Univ", "Community_College"], size=n, p=[0.05, 0.35, 0.60])))
82
+ )
83
+
84
+ # Neighborhood: socioeconomic proxy
85
+ neighborhood = np.where(
86
+ income > 70000, "Affluent",
87
+ np.where(income > 40000, "Middle_Class", "Low_Income")
88
+ )
89
+
90
+ # ── Qualification (ground truth) ──────────────────────────────────────
91
+ # Based on objective factors
92
+ qual_score = (
93
+ (credit_score - 300) / 550 * 0.35 +
94
+ np.clip(income / 100000, 0, 1) * 0.25 +
95
+ education / 5 * 0.15 +
96
+ np.clip(employment_years / 20, 0, 1) * 0.15 +
97
+ (1 - debt_ratio) * 0.10
98
+ )
99
+ qualified = (qual_score > 0.45).astype(int)
100
+
101
+ # ── Decision (model output) — includes bias ──────────────────────────
102
+ # Start from qualification, then add discriminatory bias
103
+ decision_score = qual_score.copy()
104
+
105
+ # Race bias in decisions
106
+ race_decision_bias = np.where(
107
+ race == "White", 0.06,
108
+ np.where(race == "Asian", 0.02,
109
+ np.where(race == "Hispanic", -0.04, -0.10))
110
+ )
111
+ decision_score += race_decision_bias
112
+
113
+ # Gender bias
114
+ gender_decision_bias = np.where(gender == "Male", 0.03, -0.03)
115
+ decision_score += gender_decision_bias
116
+
117
+ # Age bias (against older applicants)
118
+ age_decision_bias = np.where(age > 55, -0.05, np.where(age < 25, -0.02, 0.0))
119
+ decision_score += age_decision_bias
120
+
121
+ # Add noise
122
+ decision_score += rng.normal(0, 0.05, size=n)
123
+
124
+ decision = (decision_score > 0.50).astype(int)
125
+
126
+ # ── Predicted probability (model confidence) ──────────────────────────
127
+ predicted_prob = np.clip(decision_score + rng.normal(0, 0.08, size=n), 0, 1).round(3)
128
+
129
+ # ── Assemble DataFrame ────────────────────────────────────────────────
130
+ df = pd.DataFrame({
131
+ "gender": gender,
132
+ "race": race,
133
+ "age": age,
134
+ "disability": disability,
135
+ "credit_score": credit_score,
136
+ "zip_code": zip_code,
137
+ "income": income,
138
+ "education": education,
139
+ "employment_years": employment_years,
140
+ "debt_ratio": debt_ratio,
141
+ "loan_amount": loan_amount,
142
+ "school_name": school_categories,
143
+ "neighborhood": neighborhood,
144
+ "qualified": qualified,
145
+ "decision": decision,
146
+ "predicted_prob": predicted_prob,
147
+ })
148
+
149
+ return df
150
+
151
+
152
+ def get_dataset() -> pd.DataFrame:
153
+ """Return the cached dataset, generating it if needed."""
154
+ global _dataset_cache
155
+
156
+ if _dataset_cache is not None:
157
+ return _dataset_cache
158
+
159
+ data_dir = Path(__file__).parent.parent / "data"
160
+ data_path = data_dir / "sample_dataset.csv"
161
+
162
+ if data_path.exists():
163
+ _dataset_cache = pd.read_csv(data_path)
164
+ else:
165
+ os.makedirs(data_dir, exist_ok=True)
166
+ _dataset_cache = _generate_synthetic_dataset()
167
+ _dataset_cache.to_csv(data_path, index=False)
168
+
169
+ return _dataset_cache
170
+
171
+
172
+ def reload_dataset(df: pd.DataFrame | None = None) -> pd.DataFrame:
173
+ """Force-reload or replace dataset."""
174
+ global _dataset_cache
175
+ if df is not None:
176
+ _dataset_cache = df
177
+ else:
178
+ _dataset_cache = None
179
+ return get_dataset()
180
+
181
+
182
+ def get_dataset_info() -> dict:
183
+ """Return metadata about the loaded dataset."""
184
+ df = get_dataset()
185
+ protected_found = [c for c in ["gender", "race", "age", "disability"] if c in df.columns]
186
+ return {
187
+ "rows": len(df),
188
+ "columns": len(df.columns),
189
+ "column_names": list(df.columns),
190
+ "protected_attributes_found": protected_found,
191
+ "decision_column": "decision",
192
+ "qualification_column": "qualified",
193
+ }
backend/services/fairness_engine.py ADDED
@@ -0,0 +1,273 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ FairSight — Fairness Engine
3
+ Core fairness metric computations: disparate impact, equal opportunity gap,
4
+ calibration gap, and per-group breakdowns.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import numpy as np
9
+ import pandas as pd
10
+ from typing import Optional
11
+
12
+ from config import (
13
+ DI_CRITICAL_THRESHOLD,
14
+ DI_WARNING_THRESHOLD,
15
+ EO_CRITICAL_THRESHOLD,
16
+ EO_WARNING_THRESHOLD,
17
+ CAL_CRITICAL_THRESHOLD,
18
+ CAL_WARNING_THRESHOLD,
19
+ )
20
+ from models.enums import MetricStatus
21
+
22
+
23
+ # ═══════════════════════════════════════════════════════════════════════════
24
+ # Disparate Impact
25
+ # ═══════════════════════════════════════════════════════════════════════════
26
+
27
+ def compute_disparate_impact(
28
+ df: pd.DataFrame,
29
+ protected_attr: str,
30
+ decision_col: str = "decision",
31
+ reference_group: Optional[str] = None,
32
+ ) -> dict:
33
+ """
34
+ Compute disparate impact ratio.
35
+
36
+ DI = P(decision=1 | unprivileged) / P(decision=1 | privileged)
37
+
38
+ Returns the minimum DI across all group comparisons (worst-case).
39
+ """
40
+ groups = df[protected_attr].unique()
41
+ approval_rates = {}
42
+
43
+ for g in groups:
44
+ mask = df[protected_attr] == g
45
+ n_group = mask.sum()
46
+ if n_group == 0:
47
+ continue
48
+ approval_rates[g] = df.loc[mask, decision_col].mean()
49
+
50
+ if not approval_rates:
51
+ return {"ratio": 1.0, "status": MetricStatus.PASSING, "group_rates": {}}
52
+
53
+ # Reference group = highest approval rate (privileged)
54
+ if reference_group is None or reference_group not in approval_rates:
55
+ reference_group = max(approval_rates, key=approval_rates.get)
56
+
57
+ ref_rate = approval_rates[reference_group]
58
+ if ref_rate == 0:
59
+ return {"ratio": 0.0, "status": MetricStatus.CRITICAL, "group_rates": approval_rates, "reference_group": reference_group}
60
+
61
+ # Compute DI for each non-reference group
62
+ di_ratios = {}
63
+ for g, rate in approval_rates.items():
64
+ if g == reference_group:
65
+ di_ratios[g] = 1.0
66
+ else:
67
+ di_ratios[g] = rate / ref_rate
68
+
69
+ min_di = min(di_ratios.values())
70
+
71
+ if min_di < DI_CRITICAL_THRESHOLD:
72
+ status = MetricStatus.CRITICAL
73
+ elif min_di < DI_WARNING_THRESHOLD:
74
+ status = MetricStatus.NEEDS_REVIEW
75
+ else:
76
+ status = MetricStatus.PASSING
77
+
78
+ return {
79
+ "ratio": round(min_di, 3),
80
+ "status": status,
81
+ "group_rates": {g: round(r, 3) for g, r in approval_rates.items()},
82
+ "group_di_ratios": {g: round(r, 3) for g, r in di_ratios.items()},
83
+ "reference_group": reference_group,
84
+ }
85
+
86
+
87
+ # ═══════════════════════════════════════════════════════════════════════════
88
+ # Equal Opportunity Gap
89
+ # ═══════════════════════════════════════════════════════════════════════════
90
+
91
+ def compute_equal_opportunity_gap(
92
+ df: pd.DataFrame,
93
+ protected_attr: str,
94
+ decision_col: str = "decision",
95
+ qualification_col: str = "qualified",
96
+ reference_group: Optional[str] = None,
97
+ ) -> dict:
98
+ """
99
+ Equal Opportunity Gap = |TPR_privileged − TPR_unprivileged|
100
+
101
+ TPR = P(decision=1 | qualified=1, group)
102
+ """
103
+ groups = df[protected_attr].unique()
104
+ tpr_by_group = {}
105
+
106
+ for g in groups:
107
+ mask = (df[protected_attr] == g) & (df[qualification_col] == 1)
108
+ n_qualified = mask.sum()
109
+ if n_qualified == 0:
110
+ continue
111
+ tpr_by_group[g] = df.loc[mask, decision_col].mean()
112
+
113
+ if not tpr_by_group:
114
+ return {"gap": 0.0, "status": MetricStatus.PASSING, "tpr_by_group": {}}
115
+
116
+ if reference_group is None or reference_group not in tpr_by_group:
117
+ reference_group = max(tpr_by_group, key=tpr_by_group.get)
118
+
119
+ ref_tpr = tpr_by_group[reference_group]
120
+
121
+ gaps = {}
122
+ for g, tpr in tpr_by_group.items():
123
+ gaps[g] = abs(ref_tpr - tpr)
124
+
125
+ max_gap = max(gaps.values())
126
+
127
+ if max_gap > EO_CRITICAL_THRESHOLD:
128
+ status = MetricStatus.CRITICAL
129
+ elif max_gap > EO_WARNING_THRESHOLD:
130
+ status = MetricStatus.NEEDS_REVIEW
131
+ else:
132
+ status = MetricStatus.PASSING
133
+
134
+ return {
135
+ "gap": round(max_gap, 3),
136
+ "status": status,
137
+ "tpr_by_group": {g: round(v, 3) for g, v in tpr_by_group.items()},
138
+ "gaps": {g: round(v, 3) for g, v in gaps.items()},
139
+ "reference_group": reference_group,
140
+ }
141
+
142
+
143
+ # ════════════════════════════════���══════════════════════════════════════════
144
+ # Calibration Gap
145
+ # ═══════════════════════════════════════════════════════════════════════════
146
+
147
+ def compute_calibration_gap(
148
+ df: pd.DataFrame,
149
+ protected_attr: str,
150
+ predicted_col: str = "predicted_prob",
151
+ qualification_col: str = "qualified",
152
+ n_bins: int = 10,
153
+ ) -> dict:
154
+ """
155
+ Calibration Gap: measures how well predictions match reality
156
+ across demographic groups.
157
+
158
+ Bins predicted probabilities and compares actual positive rate
159
+ per bin across groups.
160
+ """
161
+ groups = df[protected_attr].unique()
162
+ calibrations = {}
163
+
164
+ for g in groups:
165
+ mask = df[protected_attr] == g
166
+ subset = df.loc[mask]
167
+ if len(subset) < 20:
168
+ continue
169
+
170
+ bins = pd.cut(subset[predicted_col], bins=n_bins, labels=False, duplicates="drop")
171
+ if bins.isna().all():
172
+ continue
173
+
174
+ bin_stats = subset.groupby(bins).agg(
175
+ predicted_mean=(predicted_col, "mean"),
176
+ actual_mean=(qualification_col, "mean"),
177
+ count=(qualification_col, "count"),
178
+ )
179
+ # Weighted average calibration error for this group
180
+ total = bin_stats["count"].sum()
181
+ if total == 0:
182
+ continue
183
+ weighted_error = (
184
+ (bin_stats["predicted_mean"] - bin_stats["actual_mean"]).abs() *
185
+ bin_stats["count"] / total
186
+ ).sum()
187
+
188
+ calibrations[g] = round(weighted_error, 4)
189
+
190
+ if not calibrations:
191
+ return {"gap": 0.0, "status": MetricStatus.PASSING, "calibrations": {}}
192
+
193
+ # Gap = max difference in calibration error between groups
194
+ cal_values = list(calibrations.values())
195
+ gap = max(cal_values) - min(cal_values)
196
+
197
+ if gap > CAL_CRITICAL_THRESHOLD:
198
+ status = MetricStatus.CRITICAL
199
+ elif gap > CAL_WARNING_THRESHOLD:
200
+ status = MetricStatus.NEEDS_REVIEW
201
+ else:
202
+ status = MetricStatus.PASSING
203
+
204
+ return {
205
+ "gap": round(gap, 4),
206
+ "status": status,
207
+ "calibrations": calibrations,
208
+ }
209
+
210
+
211
+ # ═══════════════════════════════════════════════════════════════════════════
212
+ # Scatter Plot Data (predicted vs actual)
213
+ # ═══════════════════════════════════════════════════════════════════════════
214
+
215
+ def compute_scatter_data(
216
+ df: pd.DataFrame,
217
+ protected_attr: str,
218
+ predicted_col: str = "predicted_prob",
219
+ qualification_col: str = "qualified",
220
+ sample_size: int = 300,
221
+ ) -> list[dict]:
222
+ """
223
+ Produce scatter-plot data: predicted probability vs. actual qualification,
224
+ color-coded by group. Downsample for frontend performance.
225
+ """
226
+ sampled = df.sample(n=min(sample_size, len(df)), random_state=42)
227
+
228
+ points = []
229
+ for _, row in sampled.iterrows():
230
+ points.append({
231
+ "actual": float(row[qualification_col]),
232
+ "predicted": float(row[predicted_col]),
233
+ "group": str(row[protected_attr]),
234
+ })
235
+
236
+ return points
237
+
238
+
239
+ # ═══════════════════════════════════════════════════════════════════════════
240
+ # Aggregate Fairness Score (0–100)
241
+ # ═══════════════════════════════════════════════════════════════════════════
242
+
243
+ def compute_overall_fairness_score(
244
+ di_ratio: float,
245
+ eo_gap: float,
246
+ cal_gap: float,
247
+ ) -> float:
248
+ """
249
+ Combine the three metrics into a single 0-100 score.
250
+
251
+ Weights: DI=40%, EO=35%, Calibration=25%
252
+ Each sub-score is scaled so that threshold-passing = 90+
253
+ """
254
+ # DI score: 1.0 → 100, 0.80 → 70, 0.60 → 40
255
+ di_score = min(100, max(0, (di_ratio / 1.0) * 100))
256
+
257
+ # EO score: 0.0 gap → 100, 0.15 gap → 50
258
+ eo_score = min(100, max(0, (1 - eo_gap / 0.30) * 100))
259
+
260
+ # Cal score: 0.0 gap → 100, 0.12 gap → 60
261
+ cal_score = min(100, max(0, (1 - cal_gap / 0.30) * 100))
262
+
263
+ overall = di_score * 0.40 + eo_score * 0.35 + cal_score * 0.25
264
+ return round(overall, 1)
265
+
266
+
267
+ def get_metric_status_label(status: MetricStatus) -> str:
268
+ """Convert MetricStatus enum to display label."""
269
+ return {
270
+ MetricStatus.PASSING: "Passing",
271
+ MetricStatus.NEEDS_REVIEW: "Needs review",
272
+ MetricStatus.CRITICAL: "Critical issue",
273
+ }[status]
backend/services/proxy_detector.py ADDED
@@ -0,0 +1,140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ FairSight — Proxy Variable Detector
3
+ Identifies features that appear neutral but encode protected characteristics
4
+ (e.g. ZIP code acting as a racial proxy, school name encoding socioeconomic status).
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import numpy as np
9
+ import pandas as pd
10
+ from scipy.stats import chi2_contingency
11
+ from sklearn.preprocessing import LabelEncoder
12
+
13
+ from config import PROXY_CORRELATION_THRESHOLD, PROXY_HIGH_THRESHOLD
14
+
15
+
16
+ def _cramers_v(x: pd.Series, y: pd.Series) -> float:
17
+ """
18
+ Compute Cramér's V statistic for two categorical variables.
19
+ Returns a value between 0 (no association) and 1 (perfect association).
20
+ """
21
+ # Build a contingency table
22
+ contingency = pd.crosstab(x, y)
23
+
24
+ if contingency.shape[0] < 2 or contingency.shape[1] < 2:
25
+ return 0.0
26
+
27
+ chi2, _, _, _ = chi2_contingency(contingency)
28
+ n = contingency.sum().sum()
29
+
30
+ min_dim = min(contingency.shape[0], contingency.shape[1]) - 1
31
+ if min_dim == 0 or n == 0:
32
+ return 0.0
33
+
34
+ return float(np.sqrt(chi2 / (n * min_dim)))
35
+
36
+
37
+ def _point_biserial_like(feature: pd.Series, protected: pd.Series) -> float:
38
+ """
39
+ For a numeric feature and a categorical protected attribute,
40
+ compute a normalized between-group variance measure (eta-squared).
41
+ """
42
+ groups = protected.unique()
43
+ grand_mean = feature.mean()
44
+
45
+ ss_between = 0.0
46
+ ss_total = ((feature - grand_mean) ** 2).sum()
47
+
48
+ for g in groups:
49
+ mask = protected == g
50
+ group_mean = feature[mask].mean()
51
+ n_g = mask.sum()
52
+ ss_between += n_g * (group_mean - grand_mean) ** 2
53
+
54
+ if ss_total == 0:
55
+ return 0.0
56
+
57
+ eta_sq = ss_between / ss_total
58
+ return float(np.sqrt(eta_sq)) # return sqrt for a correlation-like scale
59
+
60
+
61
+ def compute_feature_correlations(
62
+ df: pd.DataFrame,
63
+ feature_cols: list[str],
64
+ protected_attrs: list[str],
65
+ ) -> list[dict]:
66
+ """
67
+ Compute correlation between each feature and each protected attribute.
68
+ Uses Cramér's V for categorical-categorical and eta for numeric-categorical.
69
+
70
+ Returns a list of dicts: {feature, protected_attribute, correlation}
71
+ """
72
+ results = []
73
+
74
+ for feat in feature_cols:
75
+ if feat not in df.columns:
76
+ continue
77
+
78
+ for attr in protected_attrs:
79
+ if attr not in df.columns:
80
+ continue
81
+
82
+ feat_series = df[feat].copy()
83
+ attr_series = df[attr].copy()
84
+
85
+ # Determine if feature is numeric or categorical
86
+ if pd.api.types.is_numeric_dtype(feat_series):
87
+ corr = _point_biserial_like(feat_series, attr_series)
88
+ else:
89
+ corr = _cramers_v(feat_series, attr_series)
90
+
91
+ results.append({
92
+ "feature": feat,
93
+ "protected_attribute": attr,
94
+ "correlation": round(corr, 2),
95
+ })
96
+
97
+ return results
98
+
99
+
100
+ def detect_proxy_variables(
101
+ df: pd.DataFrame,
102
+ feature_cols: list[str],
103
+ protected_attrs: list[str],
104
+ threshold: float = PROXY_CORRELATION_THRESHOLD,
105
+ ) -> list[dict]:
106
+ """
107
+ Identify proxy variables — features whose correlation with a protected
108
+ attribute exceeds the threshold.
109
+
110
+ Returns list of {feature, protected_attribute, correlation, severity}
111
+ sorted by correlation descending.
112
+ """
113
+ all_corrs = compute_feature_correlations(df, feature_cols, protected_attrs)
114
+
115
+ proxies = []
116
+ for item in all_corrs:
117
+ if item["correlation"] >= threshold:
118
+ severity = "critical" if item["correlation"] >= PROXY_HIGH_THRESHOLD else "warning"
119
+ proxies.append({
120
+ **item,
121
+ "severity": severity,
122
+ "description": (
123
+ f"'{item['feature']}' has a {item['correlation']:.2f} correlation "
124
+ f"with '{item['protected_attribute']}', potentially acting as a proxy."
125
+ ),
126
+ })
127
+
128
+ proxies.sort(key=lambda x: x["correlation"], reverse=True)
129
+ return proxies
130
+
131
+
132
+ def get_heatmap_data(
133
+ df: pd.DataFrame,
134
+ feature_cols: list[str],
135
+ protected_attrs: list[str],
136
+ ) -> list[dict]:
137
+ """
138
+ Return heatmap data (feature × protected attribute correlation matrix).
139
+ """
140
+ return compute_feature_correlations(df, feature_cols, protected_attrs)
backend/services/remediation_service.py ADDED
@@ -0,0 +1,302 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ FairSight — Remediation Service
3
+ Generates prioritized, concrete interventions with expected impact estimates.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import uuid
8
+ import numpy as np
9
+ import pandas as pd
10
+
11
+ from services.data_service import get_dataset
12
+ from services.fairness_engine import (
13
+ compute_disparate_impact,
14
+ compute_equal_opportunity_gap,
15
+ compute_calibration_gap,
16
+ )
17
+ from services.proxy_detector import detect_proxy_variables
18
+ from config import PROTECTED_ATTRIBUTES, FEATURE_COLUMNS
19
+
20
+
21
+ def _estimate_proxy_removal_impact(
22
+ df: pd.DataFrame,
23
+ proxy_feature: str,
24
+ protected_attr: str,
25
+ correlation: float,
26
+ ) -> dict:
27
+ """Estimate the DI/EO/Cal improvement from removing a proxy feature."""
28
+ # Higher correlation → larger expected improvement
29
+ di_improvement = min(0.15, correlation * 0.25)
30
+ eo_improvement = min(0.10, correlation * 0.15)
31
+ cal_improvement = min(0.05, correlation * 0.08)
32
+
33
+ return {
34
+ "di": round(di_improvement, 3),
35
+ "eo": round(eo_improvement, 3),
36
+ "cal": round(cal_improvement, 3),
37
+ }
38
+
39
+
40
+ def _estimate_reweight_impact(df: pd.DataFrame, protected_attr: str) -> dict:
41
+ """Estimate impact of reweighting training data for a protected attribute."""
42
+ di = compute_disparate_impact(df, protected_attr)
43
+ gap_from_parity = max(0, 1.0 - di["ratio"])
44
+
45
+ # Reweighting typically recovers 40–60% of the gap
46
+ di_improvement = round(gap_from_parity * 0.50, 3)
47
+ eo_improvement = round(gap_from_parity * 0.35, 3)
48
+ cal_improvement = round(gap_from_parity * 0.10, 3)
49
+
50
+ return {"di": di_improvement, "eo": eo_improvement, "cal": cal_improvement}
51
+
52
+
53
+ def _estimate_threshold_impact(df: pd.DataFrame, protected_attr: str) -> dict:
54
+ """Estimate impact of group-specific threshold adjustment."""
55
+ di = compute_disparate_impact(df, protected_attr)
56
+ gap = max(0, 1.0 - di["ratio"])
57
+
58
+ di_improvement = round(gap * 0.70, 3)
59
+ eo_improvement = round(gap * 0.20, 3)
60
+ cal_improvement = round(gap * 0.05, 3)
61
+
62
+ return {"di": di_improvement, "eo": eo_improvement, "cal": cal_improvement}
63
+
64
+
65
+ def generate_interventions(df: pd.DataFrame) -> list[dict]:
66
+ """
67
+ Generate a prioritized list of remediation interventions.
68
+ """
69
+ interventions: list[dict] = []
70
+ priority_counter = 1
71
+
72
+ available_features = [f for f in FEATURE_COLUMNS if f in df.columns]
73
+ available_attrs = [a for a in PROTECTED_ATTRIBUTES if a in df.columns]
74
+
75
+ # ── 1. Proxy removal recommendations ──────────────────────────────────
76
+ proxies = detect_proxy_variables(df, available_features, available_attrs)
77
+
78
+ for proxy in proxies:
79
+ impact = _estimate_proxy_removal_impact(
80
+ df, proxy["feature"], proxy["protected_attribute"], proxy["correlation"]
81
+ )
82
+ interventions.append({
83
+ "id": f"rem-proxy-{proxy['feature']}-{uuid.uuid4().hex[:8]}",
84
+ "type": "remove_proxy",
85
+ "title": f"Remove proxy feature: {proxy['feature']}",
86
+ "description": (
87
+ f"'{proxy['feature']}' correlates {proxy['correlation']:.2f} with "
88
+ f"'{proxy['protected_attribute']}'. Removing or transforming this feature "
89
+ f"would reduce indirect discrimination."
90
+ ),
91
+ "priority": priority_counter,
92
+ "expected_di_improvement": impact["di"],
93
+ "expected_eo_improvement": impact["eo"],
94
+ "expected_cal_improvement": impact["cal"],
95
+ "risk_level": "low" if proxy["correlation"] < 0.30 else "medium",
96
+ "implementation_effort": "low",
97
+ "affected_features": [proxy["feature"]],
98
+ })
99
+ priority_counter += 1
100
+
101
+ # ── 2. Reweighting recommendations (per protected attr) ───────────────
102
+ for attr in available_attrs:
103
+ di = compute_disparate_impact(df, attr)
104
+ if di["ratio"] < 0.90:
105
+ impact = _estimate_reweight_impact(df, attr)
106
+ interventions.append({
107
+ "id": f"rem-reweight-{attr}-{uuid.uuid4().hex[:8]}",
108
+ "type": "reweight_data",
109
+ "title": f"Reweight training data for {attr}",
110
+ "description": (
111
+ f"Apply sample weights to balance representation and outcomes "
112
+ f"across '{attr}' groups. This adjusts the training objective without "
113
+ f"changing the feature set."
114
+ ),
115
+ "priority": priority_counter,
116
+ "expected_di_improvement": impact["di"],
117
+ "expected_eo_improvement": impact["eo"],
118
+ "expected_cal_improvement": impact["cal"],
119
+ "risk_level": "medium",
120
+ "implementation_effort": "medium",
121
+ "affected_features": [],
122
+ })
123
+ priority_counter += 1
124
+
125
+ # ── 3. Threshold adjustment recommendations ─────���────────────────────
126
+ for attr in available_attrs:
127
+ di = compute_disparate_impact(df, attr)
128
+ if di["ratio"] < 0.85:
129
+ impact = _estimate_threshold_impact(df, attr)
130
+ interventions.append({
131
+ "id": f"rem-threshold-{attr}-{uuid.uuid4().hex[:8]}",
132
+ "type": "adjust_threshold",
133
+ "title": f"Adjust decision thresholds for {attr}",
134
+ "description": (
135
+ f"Apply group-specific decision thresholds to equalize approval rates "
136
+ f"across '{attr}' groups. This is a post-processing intervention."
137
+ ),
138
+ "priority": priority_counter,
139
+ "expected_di_improvement": impact["di"],
140
+ "expected_eo_improvement": impact["eo"],
141
+ "expected_cal_improvement": impact["cal"],
142
+ "risk_level": "high",
143
+ "implementation_effort": "low",
144
+ "affected_features": [],
145
+ })
146
+ priority_counter += 1
147
+
148
+ # ── 4. Post-processing calibration ────────────────────────────────────
149
+ for attr in available_attrs:
150
+ cal = compute_calibration_gap(df, attr)
151
+ if cal["gap"] > 0.05:
152
+ interventions.append({
153
+ "id": f"rem-calibrate-{attr}-{uuid.uuid4().hex[:8]}",
154
+ "type": "post_process",
155
+ "title": f"Calibrate predictions across {attr} groups",
156
+ "description": (
157
+ f"Apply Platt scaling or isotonic regression per '{attr}' group "
158
+ f"to ensure predicted probabilities match actual outcomes equally."
159
+ ),
160
+ "priority": priority_counter,
161
+ "expected_di_improvement": 0.02,
162
+ "expected_eo_improvement": 0.03,
163
+ "expected_cal_improvement": round(cal["gap"] * 0.60, 3),
164
+ "risk_level": "low",
165
+ "implementation_effort": "medium",
166
+ "affected_features": [],
167
+ })
168
+ priority_counter += 1
169
+
170
+ return interventions
171
+
172
+
173
+ def get_fairness_constraints() -> list[dict]:
174
+ """
175
+ Return adjustable fairness constraint sliders.
176
+ """
177
+ return [
178
+ {
179
+ "name": "min_disparate_impact",
180
+ "current_value": 0.80,
181
+ "min_value": 0.50,
182
+ "max_value": 1.00,
183
+ "step": 0.05,
184
+ "description": "Minimum acceptable disparate impact ratio (EEOC 4/5ths rule = 0.80)",
185
+ },
186
+ {
187
+ "name": "max_equal_opportunity_gap",
188
+ "current_value": 0.10,
189
+ "min_value": 0.00,
190
+ "max_value": 0.30,
191
+ "step": 0.01,
192
+ "description": "Maximum acceptable equal opportunity gap between groups",
193
+ },
194
+ {
195
+ "name": "max_calibration_gap",
196
+ "current_value": 0.08,
197
+ "min_value": 0.00,
198
+ "max_value": 0.20,
199
+ "step": 0.01,
200
+ "description": "Maximum acceptable calibration gap between groups",
201
+ },
202
+ {
203
+ "name": "min_accuracy",
204
+ "current_value": 0.75,
205
+ "min_value": 0.50,
206
+ "max_value": 0.95,
207
+ "step": 0.01,
208
+ "description": "Minimum acceptable overall model accuracy after remediation",
209
+ },
210
+ ]
211
+
212
+
213
+ def apply_remediation(
214
+ df: pd.DataFrame,
215
+ intervention_ids: list[str],
216
+ constraints: dict[str, float],
217
+ protected_attr: str = "race",
218
+ ) -> dict:
219
+ """
220
+ Simulate the effect of applying selected interventions.
221
+ Returns before/after comparison.
222
+ """
223
+ # Current metrics
224
+ di_before = compute_disparate_impact(df, protected_attr)
225
+ eo_before = compute_equal_opportunity_gap(df, protected_attr)
226
+ cal_before = compute_calibration_gap(df, protected_attr)
227
+
228
+ # Simulate the remediation
229
+ df_remediated = df.copy()
230
+
231
+ # Parse which types of interventions are selected
232
+ has_proxy_removal = any("proxy" in iid for iid in intervention_ids)
233
+ has_reweighting = any("reweight" in iid for iid in intervention_ids)
234
+ has_threshold = any("threshold" in iid for iid in intervention_ids)
235
+ has_calibration = any("calibrate" in iid for iid in intervention_ids)
236
+
237
+ # Apply simulated effects
238
+ if has_threshold or has_reweighting:
239
+ # Shift decisions for underprivileged groups
240
+ groups = df_remediated[protected_attr].unique()
241
+ rates = {g: df_remediated.loc[df_remediated[protected_attr] == g, "decision"].mean() for g in groups}
242
+ max_rate = max(rates.values())
243
+
244
+ for g, rate in rates.items():
245
+ if rate < max_rate * 0.90:
246
+ mask = (df_remediated[protected_attr] == g) & (df_remediated["decision"] == 0)
247
+ n_to_flip = int(mask.sum() * 0.3) # flip ~30% of rejections
248
+ if has_threshold:
249
+ n_to_flip = int(mask.sum() * 0.5)
250
+ flip_indices = df_remediated[mask].sample(n=min(n_to_flip, mask.sum()), random_state=42).index
251
+ df_remediated.loc[flip_indices, "decision"] = 1
252
+
253
+ if has_proxy_removal:
254
+ # Simulate removing proxy influence by adding noise to decisions
255
+ # that were affected by proxy variables
256
+ rng = np.random.default_rng(42)
257
+ noise = rng.choice([0, 1], size=len(df_remediated), p=[0.95, 0.05])
258
+ decisions = df_remediated["decision"].values.copy()
259
+ # Only flip some denials to approvals for disadvantaged groups
260
+ groups = df_remediated[protected_attr].unique()
261
+ rates = {g: df_remediated.loc[df_remediated[protected_attr] == g, "decision"].mean() for g in groups}
262
+ max_rate = max(rates.values())
263
+
264
+ for g, rate in rates.items():
265
+ if rate < max_rate * 0.95:
266
+ flip_prob = min(0.15, (max_rate - rate) * 0.5)
267
+ mask = (df_remediated[protected_attr] == g) & (df_remediated["decision"] == 0)
268
+ n_flip = int(mask.sum() * flip_prob)
269
+ if n_flip > 0:
270
+ flip_idx = df_remediated[mask].sample(n=n_flip, random_state=42).index
271
+ df_remediated.loc[flip_idx, "decision"] = 1
272
+
273
+ # Compute after metrics
274
+ di_after = compute_disparate_impact(df_remediated, protected_attr)
275
+ eo_after = compute_equal_opportunity_gap(df_remediated, protected_attr)
276
+ cal_after = compute_calibration_gap(df_remediated, protected_attr)
277
+
278
+ # Build approval rates before/after
279
+ groups = df[protected_attr].unique()
280
+
281
+ def get_approval_rates(data: pd.DataFrame, attr: str) -> list[dict]:
282
+ rates = []
283
+ for g in sorted(data[attr].unique()):
284
+ mask = data[attr] == g
285
+ rates.append({
286
+ "group": str(g),
287
+ "rate": round(float(data.loc[mask, "decision"].mean()), 3),
288
+ "count": int(mask.sum()),
289
+ })
290
+ return rates
291
+
292
+ return {
293
+ "original_disparate_impact": di_before["ratio"],
294
+ "new_disparate_impact": di_after["ratio"],
295
+ "original_eo_gap": eo_before["gap"],
296
+ "new_eo_gap": eo_after["gap"],
297
+ "original_cal_gap": cal_before["gap"],
298
+ "new_cal_gap": cal_after["gap"],
299
+ "approval_rates_before": get_approval_rates(df, protected_attr),
300
+ "approval_rates_after": get_approval_rates(df_remediated, protected_attr),
301
+ "interventions_applied": intervention_ids,
302
+ }
backend/services/simulator_service.py ADDED
@@ -0,0 +1,167 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ FairSight — Simulator Service
3
+ What-if simulation engine: adjust thresholds, reweighting, calibration,
4
+ and feature removal to see real-time impact on fairness metrics.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import numpy as np
9
+ import pandas as pd
10
+
11
+ from services.fairness_engine import (
12
+ compute_disparate_impact,
13
+ compute_equal_opportunity_gap,
14
+ compute_calibration_gap,
15
+ )
16
+ from config import PROTECTED_ATTRIBUTES
17
+
18
+
19
+ def simulate(
20
+ df: pd.DataFrame,
21
+ threshold_adjustment: float = 0.0,
22
+ reweighting_strength: float = 0.0,
23
+ calibration_strength: float = 0.0,
24
+ remove_features: list[str] | None = None,
25
+ protected_attribute: str = "race",
26
+ ) -> dict:
27
+ """
28
+ Run a what-if simulation with the given parameters.
29
+
30
+ Parameters:
31
+ - threshold_adjustment: Shift the decision boundary (negative = more approvals)
32
+ - reweighting_strength: 0-1, how aggressively to uplift underrepresented groups
33
+ - calibration_strength: 0-1, Platt-style calibration adjustment
34
+ - remove_features: list of feature names to exclude (proxy removal simulation)
35
+ - protected_attribute: which attribute to evaluate fairness against
36
+
37
+ Returns before/after metrics and approval rates.
38
+ """
39
+ if protected_attribute not in df.columns:
40
+ protected_attribute = "race"
41
+
42
+ rng = np.random.default_rng(42)
43
+ df_sim = df.copy()
44
+
45
+ # ── Original metrics ──────────────────────────────────────────────────
46
+ orig_di = compute_disparate_impact(df, protected_attribute)
47
+ orig_eo = compute_equal_opportunity_gap(df, protected_attribute)
48
+ orig_cal = compute_calibration_gap(df, protected_attribute)
49
+
50
+ # ── 1. Threshold adjustment ───────────────────────────────────────────
51
+ if threshold_adjustment != 0.0:
52
+ # Shift the effective threshold on predicted_prob
53
+ # Negative adjustment → lower threshold → more approvals
54
+ new_threshold = 0.50 + threshold_adjustment
55
+ if "predicted_prob" in df_sim.columns:
56
+ df_sim["decision"] = (df_sim["predicted_prob"] >= new_threshold).astype(int)
57
+
58
+ # ── 2. Feature removal (proxy removal) ────────────────────────────────
59
+ if remove_features:
60
+ # Simulate feature removal by reducing the correlation between
61
+ # the feature and the decision. We do this by re-computing decisions
62
+ # without the proxy influence.
63
+ for feature in remove_features:
64
+ if feature in df_sim.columns:
65
+ groups = df_sim[protected_attribute].unique()
66
+ approval_rates = {
67
+ g: df_sim.loc[df_sim[protected_attribute] == g, "decision"].mean()
68
+ for g in groups
69
+ }
70
+ max_rate = max(approval_rates.values())
71
+
72
+ for g, rate in approval_rates.items():
73
+ if rate < max_rate:
74
+ gap = max_rate - rate
75
+ # Remove a portion of the gap proportional to feature removal
76
+ target_improvement = gap * 0.40
77
+ mask_denied = (df_sim[protected_attribute] == g) & (df_sim["decision"] == 0)
78
+ n_flip = int(mask_denied.sum() * target_improvement)
79
+ if n_flip > 0:
80
+ flip_idx = df_sim[mask_denied].sample(
81
+ n=min(n_flip, mask_denied.sum()), random_state=42
82
+ ).index
83
+ df_sim.loc[flip_idx, "decision"] = 1
84
+
85
+ # ── 3. Reweighting ────────────────────────────────────────────────────
86
+ if reweighting_strength > 0:
87
+ groups = df_sim[protected_attribute].unique()
88
+ approval_rates = {
89
+ g: df_sim.loc[df_sim[protected_attribute] == g, "decision"].mean()
90
+ for g in groups
91
+ }
92
+ max_rate = max(approval_rates.values())
93
+
94
+ for g, rate in approval_rates.items():
95
+ if rate < max_rate:
96
+ gap = max_rate - rate
97
+ improvement = gap * reweighting_strength * 0.6
98
+ mask_denied = (df_sim[protected_attribute] == g) & (df_sim["decision"] == 0)
99
+ n_flip = int(mask_denied.sum() * improvement)
100
+ if n_flip > 0:
101
+ flip_idx = df_sim[mask_denied].sample(
102
+ n=min(n_flip, mask_denied.sum()), random_state=42
103
+ ).index
104
+ df_sim.loc[flip_idx, "decision"] = 1
105
+
106
+ # ── 4. Calibration ────────────────────────────────────────────────────
107
+ if calibration_strength > 0 and "predicted_prob" in df_sim.columns:
108
+ groups = df_sim[protected_attribute].unique()
109
+
110
+ for g in groups:
111
+ mask = df_sim[protected_attribute] == g
112
+ subset = df_sim.loc[mask]
113
+
114
+ # Move predicted probabilities toward actual qualification rates
115
+ actual_rate = subset["qualified"].mean()
116
+ predicted_mean = subset["predicted_prob"].mean()
117
+ adjustment = (actual_rate - predicted_mean) * calibration_strength
118
+
119
+ df_sim.loc[mask, "predicted_prob"] = np.clip(
120
+ df_sim.loc[mask, "predicted_prob"] + adjustment, 0, 1
121
+ )
122
+
123
+ # Re-threshold after calibration
124
+ df_sim["decision"] = (df_sim["predicted_prob"] >= 0.50 + threshold_adjustment).astype(int)
125
+
126
+ # ── Simulated metrics ─────────────────────────────────────────────────
127
+ sim_di = compute_disparate_impact(df_sim, protected_attribute)
128
+ sim_eo = compute_equal_opportunity_gap(df_sim, protected_attribute)
129
+ sim_cal = compute_calibration_gap(df_sim, protected_attribute)
130
+
131
+ # ── Approval rates ────────────────────────────────────────────────────
132
+ def build_rates(data: pd.DataFrame) -> list[dict]:
133
+ rates = []
134
+ for g in sorted(data[protected_attribute].unique()):
135
+ mask = data[protected_attribute] == g
136
+ rates.append({
137
+ "group": str(g),
138
+ "rate": round(float(data.loc[mask, "decision"].mean()), 3),
139
+ "count": int(mask.sum()),
140
+ })
141
+ return rates
142
+
143
+ original_rates = build_rates(df)
144
+ simulated_rates = build_rates(df_sim)
145
+
146
+ # ── Accuracy impact ───────────────────────────────────────────────────
147
+ original_accuracy = (df["decision"] == df["qualified"]).mean()
148
+ simulated_accuracy = (df_sim["decision"] == df_sim["qualified"]).mean()
149
+ accuracy_impact = round(simulated_accuracy - original_accuracy, 4)
150
+
151
+ # ── Overall approval change ───────────────────────────────────────────
152
+ original_approval = df["decision"].mean()
153
+ simulated_approval = df_sim["decision"].mean()
154
+ overall_change = round(simulated_approval - original_approval, 4)
155
+
156
+ return {
157
+ "original_approval_rates": original_rates,
158
+ "simulated_approval_rates": simulated_rates,
159
+ "original_disparate_impact": orig_di["ratio"],
160
+ "simulated_disparate_impact": sim_di["ratio"],
161
+ "original_eo_gap": orig_eo["gap"],
162
+ "simulated_eo_gap": sim_eo["gap"],
163
+ "original_cal_gap": orig_cal["gap"],
164
+ "simulated_cal_gap": sim_cal["gap"],
165
+ "overall_approval_change": overall_change,
166
+ "accuracy_impact": accuracy_impact,
167
+ }
cloudbuild.yaml ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ steps:
2
+ - name: node:20
3
+ entrypoint: npm
4
+ args: ["install"]
5
+ dir: frontend
6
+ - name: node:20
7
+ entrypoint: npm
8
+ args: ["run", "build"]
9
+ dir: frontend
10
+ - name: gcr.io/firebase-ci/firebase
11
+ entrypoint: firebase
12
+ args: ["deploy", "--only", "hosting", "--project", "fairlens-494713"]
13
+ - name: gcr.io/cloud-builders/docker
14
+ args: ["build", "-t", "us-central1-docker.pkg.dev/fairlens-494713/fairlens/fairsight-backend", "./backend"]
15
+ - name: gcr.io/cloud-builders/docker
16
+ args: ["push", "us-central1-docker.pkg.dev/fairlens-494713/fairlens/fairsight-backend"]
17
+ - name: gcr.io/google.com/cloudsdktool/cloud-sdk
18
+ entrypoint: gcloud
19
+ args:
20
+ [
21
+ "run",
22
+ "deploy",
23
+ "fairsight-backend",
24
+ "--image",
25
+ "us-central1-docker.pkg.dev/fairlens-494713/fairlens/fairsight-backend",
26
+ "--region",
27
+ "us-central1",
28
+ "--platform",
29
+ "managed",
30
+ "--allow-unauthenticated"
31
+ ]
32
+
33
+ images:
34
+ - us-central1-docker.pkg.dev/fairlens-494713/fairlens/fairsight-backend
firebase.json ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "firestore": {
3
+ "rules": "firestore.rules",
4
+ "indexes": "firestore.indexes.json"
5
+ },
6
+ "storage": {
7
+ "rules": "storage.rules"
8
+ },
9
+ "functions": {
10
+ "source": "functions"
11
+ },
12
+ "hosting": {
13
+ "public": "frontend/dist",
14
+ "ignore": [
15
+ "firebase.json",
16
+ "**/.*",
17
+ "**/node_modules/**"
18
+ ],
19
+ "rewrites": [
20
+ {
21
+ "source": "**",
22
+ "destination": "/index.html"
23
+ }
24
+ ]
25
+ }
26
+ }
27
+
firestore.indexes.json ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "indexes": [
3
+ {
4
+ "collectionGroup": "analyses",
5
+ "queryScope": "COLLECTION",
6
+ "fields": [
7
+ { "fieldPath": "orgId", "order": "ASCENDING" },
8
+ { "fieldPath": "createdAt", "order": "DESCENDING" }
9
+ ]
10
+ },
11
+ {
12
+ "collectionGroup": "alerts",
13
+ "queryScope": "COLLECTION",
14
+ "fields": [
15
+ { "fieldPath": "orgId", "order": "ASCENDING" },
16
+ { "fieldPath": "severity", "order": "ASCENDING" },
17
+ { "fieldPath": "createdAt", "order": "DESCENDING" }
18
+ ]
19
+ },
20
+ {
21
+ "collectionGroup": "remediations",
22
+ "queryScope": "COLLECTION",
23
+ "fields": [
24
+ { "fieldPath": "orgId", "order": "ASCENDING" },
25
+ { "fieldPath": "analysisId", "order": "ASCENDING" },
26
+ { "fieldPath": "createdAt", "order": "DESCENDING" }
27
+ ]
28
+ }
29
+ ],
30
+ "fieldOverrides": []
31
+ }
32
+
firestore.rules ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ rules_version = '2';
2
+ service cloud.firestore {
3
+ match /databases/{database}/documents {
4
+ function signedIn() {
5
+ return request.auth != null;
6
+ }
7
+
8
+ function isAdmin() {
9
+ return signedIn() && request.auth.token.admin == true;
10
+ }
11
+
12
+ function orgMatches(resourceOrgId) {
13
+ return signedIn() && request.auth.token.orgId != null && request.auth.token.orgId == resourceOrgId;
14
+ }
15
+
16
+ function canReadOrgData() {
17
+ return isAdmin() || orgMatches(resource.data.orgId);
18
+ }
19
+
20
+ function canWriteOrgData() {
21
+ return isAdmin() || (
22
+ signedIn() &&
23
+ request.resource.data.orgId == request.auth.token.orgId &&
24
+ request.resource.data.createdByUid == request.auth.uid
25
+ );
26
+ }
27
+
28
+ match /analyses/{docId} {
29
+ allow read: if canReadOrgData();
30
+ allow create, update: if canWriteOrgData();
31
+ allow delete: if isAdmin();
32
+ }
33
+
34
+ match /alerts/{docId} {
35
+ allow read: if canReadOrgData();
36
+ allow create, update: if canWriteOrgData();
37
+ allow delete: if isAdmin();
38
+ }
39
+
40
+ match /datasets/{docId} {
41
+ allow read: if canReadOrgData();
42
+ allow create, update: if canWriteOrgData();
43
+ allow delete: if isAdmin();
44
+ }
45
+
46
+ match /remediations/{docId} {
47
+ allow read: if canReadOrgData();
48
+ allow create, update: if canWriteOrgData();
49
+ allow delete: if isAdmin();
50
+ }
51
+
52
+ match /auditLogs/{docId} {
53
+ allow read: if isAdmin();
54
+ allow create: if signedIn();
55
+ allow update, delete: if false;
56
+ }
57
+
58
+ match /users/{uid} {
59
+ allow read: if signedIn() && (request.auth.uid == uid || isAdmin());
60
+ allow create, update: if signedIn() && request.auth.uid == uid;
61
+ allow delete: if false;
62
+ }
63
+ }
64
+ }
65
+
frontend/.env.example ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ VITE_FIREBASE_API_KEY=
2
+ VITE_FIREBASE_AUTH_DOMAIN=fairlens-494713.firebaseapp.com
3
+ VITE_FIREBASE_PROJECT_ID=fairlens-494713
4
+ VITE_FIREBASE_STORAGE_BUCKET=fairlens-494713.firebasestorage.app
5
+ VITE_FIREBASE_MESSAGING_SENDER_ID=884728849298
6
+ VITE_FIREBASE_APP_ID=
7
+ VITE_FIREBASE_FUNCTIONS_REGION=us-central1
8
+
frontend/index.html ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>FairSight</title>
7
+ </head>
8
+ <body>
9
+ <div id="root"></div>
10
+ <script type="module" src="/src/main.jsx"></script>
11
+ </body>
12
+ </html>
13
+
frontend/package.json ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "fairsight-frontend",
3
+ "private": true,
4
+ "version": "0.1.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "vite build",
9
+ "preview": "vite preview"
10
+ },
11
+ "dependencies": {
12
+ "firebase": "^11.7.1",
13
+ "papaparse": "^5.5.3",
14
+ "react": "^18.3.1",
15
+ "react-dom": "^18.3.1",
16
+ "react-router-dom": "^6.30.0",
17
+ "recharts": "^2.15.3"
18
+ },
19
+ "devDependencies": {
20
+ "@vitejs/plugin-react": "^4.4.1",
21
+ "vite": "^5.4.19"
22
+ }
23
+ }
24
+
frontend/src/App.jsx ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Navigate, Route, Routes } from "react-router-dom";
2
+ import { useAuth } from "./contexts/AuthContext";
3
+ import AppShell from "./components/AppShell";
4
+ import DashboardPage from "./pages/DashboardPage";
5
+ import MetricsPage from "./pages/MetricsPage";
6
+ import AlertsPage from "./pages/AlertsPage";
7
+ import SimulatorPage from "./pages/SimulatorPage";
8
+ import UploadPage from "./pages/UploadPage";
9
+ import RemediationPage from "./pages/RemediationPage";
10
+ import LoginPage from "./pages/LoginPage";
11
+
12
+ function ProtectedRoute({ children }) {
13
+ const { user, loading } = useAuth();
14
+
15
+ if (loading) {
16
+ return <div className="screen-center">Loading...</div>;
17
+ }
18
+
19
+ if (!user) {
20
+ return <Navigate to="/login" replace />;
21
+ }
22
+
23
+ return children;
24
+ }
25
+
26
+ export default function App() {
27
+ return (
28
+ <Routes>
29
+ <Route path="/login" element={<LoginPage />} />
30
+ <Route
31
+ path="/"
32
+ element={
33
+ <ProtectedRoute>
34
+ <AppShell />
35
+ </ProtectedRoute>
36
+ }
37
+ >
38
+ <Route index element={<DashboardPage />} />
39
+ <Route path="metrics" element={<MetricsPage />} />
40
+ <Route path="alerts" element={<AlertsPage />} />
41
+ <Route path="simulator" element={<SimulatorPage />} />
42
+ <Route path="upload" element={<UploadPage />} />
43
+ <Route path="remediations" element={<RemediationPage />} />
44
+ </Route>
45
+ </Routes>
46
+ );
47
+ }
48
+
frontend/src/components/AppShell.jsx ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NavLink, Outlet } from "react-router-dom";
2
+ import { useState } from "react";
3
+ import { useAuth } from "../contexts/AuthContext";
4
+ import { httpsCallable } from "firebase/functions";
5
+ import { functions } from "../lib/firebase";
6
+ import GeminiChat from "./GeminiChat";
7
+
8
+ const navItems = [
9
+ ["Dashboard", "/"],
10
+ ["Metrics", "/metrics"],
11
+ ["Alerts", "/alerts"],
12
+ ["Simulator", "/simulator"],
13
+ ["Upload", "/upload"],
14
+ ["Remediations", "/remediations"]
15
+ ];
16
+
17
+ export default function AppShell() {
18
+ const { user, logout } = useAuth();
19
+ const [starting, setStarting] = useState(false);
20
+
21
+ const startServer = async () => {
22
+ setStarting(true);
23
+ try {
24
+ const callable = httpsCallable(functions, "startServer");
25
+ const response = await callable({});
26
+ alert("Server started successfully!");
27
+ } catch (error) {
28
+ alert("Failed to start server: " + error.message);
29
+ } finally {
30
+ setStarting(false);
31
+ }
32
+ };
33
+
34
+ return (
35
+ <div className="app-shell">
36
+ <aside className="sidebar">
37
+ <div>
38
+ <p className="eyebrow">FairSight</p>
39
+ <h1>Bias review workspace</h1>
40
+ </div>
41
+ <nav className="nav-list">
42
+ {navItems.map(([label, path]) => (
43
+ <NavLink
44
+ key={path}
45
+ to={path}
46
+ end={path === "/"}
47
+ className={({ isActive }) => `nav-link ${isActive ? "active" : ""}`}
48
+ >
49
+ {label}
50
+ </NavLink>
51
+ ))}
52
+ </nav>
53
+ <div className="user-card">
54
+ <div>{user?.email}</div>
55
+ <button className="secondary-button" onClick={startServer} disabled={starting}>
56
+ {starting ? "Starting..." : "START Server"}
57
+ </button>
58
+ <button className="secondary-button" onClick={logout}>
59
+ Sign out
60
+ </button>
61
+ </div>
62
+ </aside>
63
+ <main className="content">
64
+ <Outlet />
65
+ </main>
66
+ <GeminiChat />
67
+ </div>
68
+ );
69
+ }
70
+
frontend/src/components/Charts.jsx ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ Bar,
3
+ BarChart,
4
+ CartesianGrid,
5
+ Line,
6
+ LineChart,
7
+ PolarAngleAxis,
8
+ PolarGrid,
9
+ Radar,
10
+ RadarChart,
11
+ ResponsiveContainer,
12
+ Scatter,
13
+ ScatterChart,
14
+ Tooltip,
15
+ XAxis,
16
+ YAxis
17
+ } from "recharts";
18
+
19
+ export function ApprovalRatesChart({ data }) {
20
+ return (
21
+ <ResponsiveContainer width="100%" height={280}>
22
+ <BarChart data={data}>
23
+ <CartesianGrid strokeDasharray="3 3" />
24
+ <XAxis dataKey="group" />
25
+ <YAxis />
26
+ <Tooltip />
27
+ <Bar dataKey="rate" fill="#2563eb" />
28
+ </BarChart>
29
+ </ResponsiveContainer>
30
+ );
31
+ }
32
+
33
+ export function FairnessRadar({ data }) {
34
+ return (
35
+ <ResponsiveContainer width="100%" height={280}>
36
+ <RadarChart data={data}>
37
+ <PolarGrid />
38
+ <PolarAngleAxis dataKey="metric" />
39
+ <Radar dataKey="score" stroke="#0f766e" fill="#14b8a6" fillOpacity={0.4} />
40
+ <Tooltip />
41
+ </RadarChart>
42
+ </ResponsiveContainer>
43
+ );
44
+ }
45
+
46
+ export function CalibrationScatter({ data }) {
47
+ return (
48
+ <ResponsiveContainer width="100%" height={300}>
49
+ <ScatterChart>
50
+ <CartesianGrid strokeDasharray="3 3" />
51
+ <XAxis dataKey="predicted" name="Predicted" />
52
+ <YAxis dataKey="actual" name="Actual" />
53
+ <Tooltip cursor={{ strokeDasharray: "3 3" }} />
54
+ <Scatter data={data} fill="#7c3aed" />
55
+ </ScatterChart>
56
+ </ResponsiveContainer>
57
+ );
58
+ }
59
+
60
+ export function BeforeAfterLine({ data }) {
61
+ return (
62
+ <ResponsiveContainer width="100%" height={280}>
63
+ <LineChart data={data}>
64
+ <CartesianGrid strokeDasharray="3 3" />
65
+ <XAxis dataKey="group" />
66
+ <YAxis />
67
+ <Tooltip />
68
+ <Line type="monotone" dataKey="before" stroke="#475569" />
69
+ <Line type="monotone" dataKey="after" stroke="#16a34a" />
70
+ </LineChart>
71
+ </ResponsiveContainer>
72
+ );
73
+ }
74
+
frontend/src/components/GeminiChat.jsx ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { httpsCallable } from "firebase/functions";
2
+ import { useState } from "react";
3
+ import { functions } from "../lib/firebase";
4
+
5
+ export default function GeminiChat() {
6
+ const [open, setOpen] = useState(false);
7
+ const [question, setQuestion] = useState("");
8
+ const [messages, setMessages] = useState([]);
9
+ const [loading, setLoading] = useState(false);
10
+
11
+ const ask = async () => {
12
+ if (!question.trim()) return;
13
+ setLoading(true);
14
+ const nextQuestion = question;
15
+ setMessages((current) => [...current, { role: "user", text: nextQuestion }]);
16
+ setQuestion("");
17
+
18
+ try {
19
+ const callable = httpsCallable(functions, "geminiExplain");
20
+ const result = await callable({ question: nextQuestion });
21
+ setMessages((current) => [...current, { role: "assistant", text: result.data.answer }]);
22
+ } catch (error) {
23
+ setMessages((current) => [
24
+ ...current,
25
+ { role: "assistant", text: error.message || "Explanation service unavailable." }
26
+ ]);
27
+ } finally {
28
+ setLoading(false);
29
+ }
30
+ };
31
+
32
+ return (
33
+ <div className={`chat-widget ${open ? "open" : ""}`}>
34
+ <button className="chat-toggle" onClick={() => setOpen((current) => !current)}>
35
+ Explainability Bot
36
+ </button>
37
+ {open && (
38
+ <div className="chat-panel">
39
+ <div className="chat-messages">
40
+ {messages.map((message, index) => (
41
+ <div key={`${message.role}-${index}`} className={`chat-bubble ${message.role}`}>
42
+ {message.text}
43
+ </div>
44
+ ))}
45
+ </div>
46
+ <div className="chat-input-row">
47
+ <input
48
+ value={question}
49
+ onChange={(event) => setQuestion(event.target.value)}
50
+ placeholder="Why is my disparate impact score low?"
51
+ />
52
+ <button onClick={ask} disabled={loading}>
53
+ Send
54
+ </button>
55
+ </div>
56
+ </div>
57
+ )}
58
+ </div>
59
+ );
60
+ }
61
+
frontend/src/contexts/AuthContext.jsx ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { createContext, useContext, useEffect, useMemo, useState } from "react";
2
+ import {
3
+ GoogleAuthProvider,
4
+ createUserWithEmailAndPassword,
5
+ onAuthStateChanged,
6
+ signInWithEmailAndPassword,
7
+ signInWithPopup,
8
+ signOut
9
+ } from "firebase/auth";
10
+ import { auth } from "../lib/firebase";
11
+
12
+ const AuthContext = createContext(null);
13
+ const provider = new GoogleAuthProvider();
14
+
15
+ export function AuthProvider({ children }) {
16
+ const [user, setUser] = useState(null);
17
+ const [loading, setLoading] = useState(true);
18
+
19
+ useEffect(() => {
20
+ const unsub = onAuthStateChanged(auth, (nextUser) => {
21
+ setUser(nextUser);
22
+ setLoading(false);
23
+ });
24
+ return unsub;
25
+ }, []);
26
+
27
+ const value = useMemo(
28
+ () => ({
29
+ user,
30
+ loading,
31
+ signInWithGoogle: () => signInWithPopup(auth, provider),
32
+ signInWithEmail: (email, password) => signInWithEmailAndPassword(auth, email, password),
33
+ signUpWithEmail: (email, password) => createUserWithEmailAndPassword(auth, email, password),
34
+ logout: () => signOut(auth)
35
+ }),
36
+ [user, loading]
37
+ );
38
+
39
+ return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
40
+ }
41
+
42
+ export function useAuth() {
43
+ return useContext(AuthContext);
44
+ }
45
+
frontend/src/hooks/useOrgCollection.js ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { collection, onSnapshot, orderBy, query, where } from "firebase/firestore";
2
+ import { useEffect, useState } from "react";
3
+ import { db } from "../lib/firebase";
4
+ import { useAuth } from "../contexts/AuthContext";
5
+
6
+ export function useOrgCollection(collectionName, extraConstraints = []) {
7
+ const { user } = useAuth();
8
+ const [data, setData] = useState([]);
9
+ const [loading, setLoading] = useState(true);
10
+
11
+ useEffect(() => {
12
+ if (!user) {
13
+ setData([]);
14
+ setLoading(false);
15
+ return;
16
+ }
17
+
18
+ const orgId = user?.reloadUserInfo?.customAttributes
19
+ ? JSON.parse(user.reloadUserInfo.customAttributes).orgId
20
+ : "demo-org";
21
+
22
+ const q = query(
23
+ collection(db, collectionName),
24
+ where("orgId", "==", orgId),
25
+ ...extraConstraints,
26
+ orderBy("createdAt", "desc")
27
+ );
28
+
29
+ const unsub = onSnapshot(q, (snapshot) => {
30
+ setData(snapshot.docs.map((doc) => ({ id: doc.id, ...doc.data() })));
31
+ setLoading(false);
32
+ });
33
+
34
+ return unsub;
35
+ }, [collectionName, extraConstraints, user]);
36
+
37
+ return { data, loading };
38
+ }
39
+
frontend/src/lib/firebase.js ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { initializeApp } from "firebase/app";
2
+ import { getAuth } from "firebase/auth";
3
+ import { getFirestore } from "firebase/firestore";
4
+ import { getFunctions } from "firebase/functions";
5
+ import { getStorage } from "firebase/storage";
6
+
7
+ const firebaseConfig = {
8
+ apiKey: import.meta.env.VITE_FIREBASE_API_KEY,
9
+ authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN,
10
+ projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID,
11
+ storageBucket: import.meta.env.VITE_FIREBASE_STORAGE_BUCKET,
12
+ messagingSenderId: import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID,
13
+ appId: import.meta.env.VITE_FIREBASE_APP_ID
14
+ };
15
+
16
+ const app = initializeApp(firebaseConfig);
17
+
18
+ export const auth = getAuth(app);
19
+ export const db = getFirestore(app);
20
+ export const storage = getStorage(app);
21
+ export const functions = getFunctions(
22
+ app,
23
+ import.meta.env.VITE_FIREBASE_FUNCTIONS_REGION || "us-central1"
24
+ );
25
+
frontend/src/main.jsx ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from "react";
2
+ import ReactDOM from "react-dom/client";
3
+ import { BrowserRouter } from "react-router-dom";
4
+ import App from "./App";
5
+ import { AuthProvider } from "./contexts/AuthContext";
6
+ import "./styles/app.css";
7
+
8
+ ReactDOM.createRoot(document.getElementById("root")).render(
9
+ <React.StrictMode>
10
+ <BrowserRouter>
11
+ <AuthProvider>
12
+ <App />
13
+ </AuthProvider>
14
+ </BrowserRouter>
15
+ </React.StrictMode>
16
+ );
17
+
frontend/src/pages/AlertsPage.jsx ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useOrgCollection } from "../hooks/useOrgCollection";
2
+
3
+ export default function AlertsPage() {
4
+ const { data: alerts, loading } = useOrgCollection("alerts");
5
+
6
+ if (loading) {
7
+ return <div className="panel">Loading alerts...</div>;
8
+ }
9
+
10
+ return (
11
+ <div className="page-grid">
12
+ {alerts.map((alert) => (
13
+ <section key={alert.id} className="panel">
14
+ <div className="row-between">
15
+ <h3>{alert.title}</h3>
16
+ <span className={`chip ${alert.severity}`}>{alert.severity}</span>
17
+ </div>
18
+ <p>{alert.description}</p>
19
+ <p className="muted">Affected features: {(alert.affectedFeatures || []).join(", ") || "None"}</p>
20
+ <p className="muted">Recommended action: {alert.recommendation}</p>
21
+ </section>
22
+ ))}
23
+ </div>
24
+ );
25
+ }
26
+
frontend/src/pages/DashboardPage.jsx ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useMemo } from "react";
2
+ import { ApprovalRatesChart, FairnessRadar } from "../components/Charts";
3
+ import { useOrgCollection } from "../hooks/useOrgCollection";
4
+
5
+ export default function DashboardPage() {
6
+ const { data: analyses, loading } = useOrgCollection("analyses");
7
+ const latest = analyses[0];
8
+
9
+ const radar = useMemo(() => latest?.fairnessScores || [], [latest]);
10
+
11
+ if (loading) {
12
+ return <div className="panel">Loading dashboard...</div>;
13
+ }
14
+
15
+ return (
16
+ <div className="page-grid">
17
+ <section className="panel summary-grid">
18
+ <div>
19
+ <p className="eyebrow">Overall fairness score</p>
20
+ <div className="metric-xl">{latest?.overallFairnessScore ?? "--"}</div>
21
+ </div>
22
+ <div>
23
+ <p className="eyebrow">Groups analyzed</p>
24
+ <div className="metric-xl">{latest?.groupsAnalyzed ?? 0}</div>
25
+ </div>
26
+ <div>
27
+ <p className="eyebrow">Flagged features</p>
28
+ <div className="metric-xl">{latest?.flaggedFeatures ?? 0}</div>
29
+ </div>
30
+ </section>
31
+ <section className="panel">
32
+ <h3>Approval rates by group</h3>
33
+ <ApprovalRatesChart data={latest?.approvalRates || []} />
34
+ </section>
35
+ <section className="panel">
36
+ <h3>Fairness profile</h3>
37
+ <FairnessRadar data={radar} />
38
+ </section>
39
+ <section className="panel">
40
+ <h3>Proxy heatmap</h3>
41
+ <div className="heatmap">
42
+ {(latest?.heatmap || []).map((cell) => (
43
+ <div key={`${cell.feature}-${cell.protected_attribute}`} className="heatmap-row">
44
+ <span>{cell.feature}</span>
45
+ <span>{cell.protected_attribute}</span>
46
+ <span>{cell.correlation}</span>
47
+ </div>
48
+ ))}
49
+ </div>
50
+ </section>
51
+ </div>
52
+ );
53
+ }
54
+
frontend/src/pages/LoginPage.jsx ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState } from "react";
2
+ import { Navigate } from "react-router-dom";
3
+ import { useAuth } from "../contexts/AuthContext";
4
+
5
+ export default function LoginPage() {
6
+ const { user, signInWithEmail, signInWithGoogle, signUpWithEmail } = useAuth();
7
+ const [email, setEmail] = useState("");
8
+ const [password, setPassword] = useState("");
9
+ const [createMode, setCreateMode] = useState(false);
10
+
11
+ if (user) {
12
+ return <Navigate to="/" replace />;
13
+ }
14
+
15
+ const submit = async (event) => {
16
+ event.preventDefault();
17
+ if (createMode) {
18
+ await signUpWithEmail(email, password);
19
+ } else {
20
+ await signInWithEmail(email, password);
21
+ }
22
+ };
23
+
24
+ return (
25
+ <div className="screen-center">
26
+ <form className="panel auth-panel" onSubmit={submit}>
27
+ <p className="eyebrow">FairSight</p>
28
+ <h2>Sign in</h2>
29
+ <input value={email} onChange={(e) => setEmail(e.target.value)} placeholder="Email" />
30
+ <input
31
+ type="password"
32
+ value={password}
33
+ onChange={(e) => setPassword(e.target.value)}
34
+ placeholder="Password"
35
+ />
36
+ <button type="submit">{createMode ? "Create account" : "Sign in"}</button>
37
+ <button type="button" className="secondary-button" onClick={() => signInWithGoogle()}>
38
+ Continue with Google
39
+ </button>
40
+ <button
41
+ type="button"
42
+ className="link-button"
43
+ onClick={() => setCreateMode((current) => !current)}
44
+ >
45
+ {createMode ? "Already have an account?" : "Need an account?"}
46
+ </button>
47
+ </form>
48
+ </div>
49
+ );
50
+ }
51
+
frontend/src/pages/MetricsPage.jsx ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState } from "react";
2
+ import { CalibrationScatter } from "../components/Charts";
3
+ import { useOrgCollection } from "../hooks/useOrgCollection";
4
+
5
+ export default function MetricsPage() {
6
+ const { data: analyses } = useOrgCollection("analyses");
7
+ const [attribute, setAttribute] = useState("race");
8
+ const latest = analyses.find((item) => item.protectedAttribute === attribute) || analyses[0];
9
+
10
+ return (
11
+ <div className="page-grid">
12
+ <section className="panel toolbar">
13
+ <h2>Fairness metrics</h2>
14
+ <select value={attribute} onChange={(event) => setAttribute(event.target.value)}>
15
+ <option value="race">Race</option>
16
+ <option value="gender">Gender</option>
17
+ <option value="age">Age</option>
18
+ <option value="disability">Disability</option>
19
+ </select>
20
+ </section>
21
+ <section className="panel metrics-grid">
22
+ <div>
23
+ <p className="eyebrow">Disparate impact</p>
24
+ <div className="metric-lg">{latest?.disparateImpactRatio ?? "--"}</div>
25
+ </div>
26
+ <div>
27
+ <p className="eyebrow">Equal opportunity gap</p>
28
+ <div className="metric-lg">{latest?.equalOpportunityGap ?? "--"}</div>
29
+ </div>
30
+ <div>
31
+ <p className="eyebrow">Calibration gap</p>
32
+ <div className="metric-lg">{latest?.calibrationGap ?? "--"}</div>
33
+ </div>
34
+ </section>
35
+ <section className="panel">
36
+ <h3>Group outcomes</h3>
37
+ <div className="list">
38
+ {(latest?.groupDetails || []).map((item) => (
39
+ <div key={item.group} className="list-row">
40
+ <span>{item.group}</span>
41
+ <span>{item.value}</span>
42
+ <span>{item.sample_size}</span>
43
+ </div>
44
+ ))}
45
+ </div>
46
+ </section>
47
+ <section className="panel">
48
+ <h3>Predicted vs actual</h3>
49
+ <CalibrationScatter data={latest?.scatterData || []} />
50
+ </section>
51
+ </div>
52
+ );
53
+ }
54
+
frontend/src/pages/RemediationPage.jsx ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useOrgCollection } from "../hooks/useOrgCollection";
2
+
3
+ export default function RemediationPage() {
4
+ const { data: remediations } = useOrgCollection("remediations");
5
+
6
+ return (
7
+ <div className="page-grid">
8
+ {remediations.map((item) => (
9
+ <section key={item.id} className="panel">
10
+ <div className="row-between">
11
+ <h3>{item.title}</h3>
12
+ <span className="chip info">{item.type}</span>
13
+ </div>
14
+ <p>{item.description}</p>
15
+ <p className="muted">Expected DI improvement: {item.expectedDiImprovement}</p>
16
+ </section>
17
+ ))}
18
+ </div>
19
+ );
20
+ }
21
+
frontend/src/pages/SimulatorPage.jsx ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { httpsCallable } from "firebase/functions";
2
+ import { useState } from "react";
3
+ import { BeforeAfterLine } from "../components/Charts";
4
+ import { functions } from "../lib/firebase";
5
+
6
+ export default function SimulatorPage() {
7
+ const [thresholdAdjustment, setThresholdAdjustment] = useState(0);
8
+ const [reweightingStrength, setReweightingStrength] = useState(0.2);
9
+ const [calibrationStrength, setCalibrationStrength] = useState(0.1);
10
+ const [result, setResult] = useState(null);
11
+
12
+ const runSimulation = async () => {
13
+ const callable = httpsCallable(functions, "runSimulator");
14
+ const response = await callable({
15
+ thresholdAdjustment,
16
+ reweightingStrength,
17
+ calibrationStrength,
18
+ removeFeatures: [],
19
+ protectedAttribute: "race"
20
+ });
21
+ setResult(response.data);
22
+ };
23
+
24
+ const chartData = (result?.originalApprovalRates || []).map((row, index) => ({
25
+ group: row.group,
26
+ before: row.rate,
27
+ after: result?.simulatedApprovalRates?.[index]?.rate ?? row.rate
28
+ }));
29
+
30
+ return (
31
+ <div className="page-grid">
32
+ <section className="panel">
33
+ <h2>What-if simulator</h2>
34
+ <label>
35
+ Threshold
36
+ <input
37
+ type="range"
38
+ min="-0.5"
39
+ max="0.5"
40
+ step="0.05"
41
+ value={thresholdAdjustment}
42
+ onChange={(e) => setThresholdAdjustment(Number(e.target.value))}
43
+ />
44
+ </label>
45
+ <label>
46
+ Reweighting
47
+ <input
48
+ type="range"
49
+ min="0"
50
+ max="1"
51
+ step="0.05"
52
+ value={reweightingStrength}
53
+ onChange={(e) => setReweightingStrength(Number(e.target.value))}
54
+ />
55
+ </label>
56
+ <label>
57
+ Calibration
58
+ <input
59
+ type="range"
60
+ min="0"
61
+ max="1"
62
+ step="0.05"
63
+ value={calibrationStrength}
64
+ onChange={(e) => setCalibrationStrength(Number(e.target.value))}
65
+ />
66
+ </label>
67
+ <button onClick={runSimulation}>Run simulation</button>
68
+ </section>
69
+ <section className="panel">
70
+ <h3>Approval rate change</h3>
71
+ <BeforeAfterLine data={chartData} />
72
+ </section>
73
+ </div>
74
+ );
75
+ }
76
+
frontend/src/pages/UploadPage.jsx ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import Papa from "papaparse";
2
+ import { getDownloadURL, ref, uploadBytes } from "firebase/storage";
3
+ import { useState } from "react";
4
+ import { storage } from "../lib/firebase";
5
+ import { useAuth } from "../contexts/AuthContext";
6
+
7
+ export default function UploadPage() {
8
+ const { user } = useAuth();
9
+ const [preview, setPreview] = useState([]);
10
+ const [columns, setColumns] = useState([]);
11
+ const [status, setStatus] = useState("");
12
+
13
+ const handleFile = async (file) => {
14
+ setStatus("Uploading...");
15
+
16
+ Papa.parse(file, {
17
+ header: true,
18
+ preview: 5,
19
+ complete: async (results) => {
20
+ setPreview(results.data);
21
+ setColumns(results.meta.fields || []);
22
+ }
23
+ });
24
+
25
+ const orgId = "demo-org";
26
+ const objectRef = ref(storage, `uploads/${orgId}/${Date.now()}-${file.name}`);
27
+ await uploadBytes(objectRef, file, {
28
+ contentType: file.type || "text/csv",
29
+ customMetadata: {
30
+ uploadedByUid: user?.uid || "",
31
+ orgId
32
+ }
33
+ });
34
+
35
+ await getDownloadURL(objectRef);
36
+ setStatus("Upload complete. Analysis trigger queued.");
37
+ };
38
+
39
+ return (
40
+ <div className="page-grid">
41
+ <section className="panel">
42
+ <h2>Dataset upload</h2>
43
+ <input type="file" accept=".csv" onChange={(e) => handleFile(e.target.files[0])} />
44
+ <p className="muted">{status}</p>
45
+ <div className="list">
46
+ {columns.map((column) => (
47
+ <div key={column} className="list-row">
48
+ <span>{column}</span>
49
+ <span>
50
+ {["race", "gender", "age", "disability"].includes(column) ? "Protected attribute" : "Feature"}
51
+ </span>
52
+ </div>
53
+ ))}
54
+ </div>
55
+ </section>
56
+ <section className="panel">
57
+ <h3>Preview</h3>
58
+ <pre className="code-block">{JSON.stringify(preview, null, 2)}</pre>
59
+ </section>
60
+ </div>
61
+ );
62
+ }
63
+
frontend/src/styles/app.css ADDED
@@ -0,0 +1,227 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ color-scheme: light;
3
+ font-family: Inter, Arial, sans-serif;
4
+ color: #0f172a;
5
+ background: #f8fafc;
6
+ }
7
+
8
+ * {
9
+ box-sizing: border-box;
10
+ }
11
+
12
+ body {
13
+ margin: 0;
14
+ }
15
+
16
+ button,
17
+ input,
18
+ select {
19
+ font: inherit;
20
+ }
21
+
22
+ button {
23
+ border: 0;
24
+ border-radius: 8px;
25
+ background: #2563eb;
26
+ color: white;
27
+ padding: 10px 14px;
28
+ cursor: pointer;
29
+ }
30
+
31
+ input,
32
+ select {
33
+ width: 100%;
34
+ border: 1px solid #cbd5e1;
35
+ border-radius: 8px;
36
+ padding: 10px 12px;
37
+ }
38
+
39
+ .secondary-button {
40
+ background: #e2e8f0;
41
+ color: #0f172a;
42
+ }
43
+
44
+ .link-button {
45
+ background: transparent;
46
+ color: #2563eb;
47
+ padding: 0;
48
+ }
49
+
50
+ .app-shell {
51
+ display: grid;
52
+ grid-template-columns: 260px 1fr;
53
+ min-height: 100vh;
54
+ }
55
+
56
+ .sidebar {
57
+ display: flex;
58
+ flex-direction: column;
59
+ justify-content: space-between;
60
+ padding: 24px;
61
+ background: #0f172a;
62
+ color: white;
63
+ }
64
+
65
+ .nav-list {
66
+ display: grid;
67
+ gap: 8px;
68
+ margin-top: 24px;
69
+ }
70
+
71
+ .nav-link {
72
+ color: #cbd5e1;
73
+ text-decoration: none;
74
+ padding: 10px 12px;
75
+ border-radius: 8px;
76
+ }
77
+
78
+ .nav-link.active {
79
+ background: #1d4ed8;
80
+ color: white;
81
+ }
82
+
83
+ .content {
84
+ padding: 24px;
85
+ }
86
+
87
+ .page-grid {
88
+ display: grid;
89
+ gap: 20px;
90
+ }
91
+
92
+ .panel {
93
+ background: white;
94
+ border: 1px solid #e2e8f0;
95
+ border-radius: 8px;
96
+ padding: 20px;
97
+ }
98
+
99
+ .summary-grid,
100
+ .metrics-grid {
101
+ display: grid;
102
+ gap: 16px;
103
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
104
+ }
105
+
106
+ .toolbar,
107
+ .row-between,
108
+ .list-row,
109
+ .chat-input-row {
110
+ display: flex;
111
+ align-items: center;
112
+ justify-content: space-between;
113
+ gap: 12px;
114
+ }
115
+
116
+ .list,
117
+ .heatmap {
118
+ display: grid;
119
+ gap: 10px;
120
+ }
121
+
122
+ .eyebrow,
123
+ .muted {
124
+ color: #64748b;
125
+ }
126
+
127
+ .metric-xl {
128
+ font-size: 44px;
129
+ font-weight: 700;
130
+ }
131
+
132
+ .metric-lg {
133
+ font-size: 28px;
134
+ font-weight: 700;
135
+ }
136
+
137
+ .screen-center {
138
+ min-height: 100vh;
139
+ display: grid;
140
+ place-items: center;
141
+ }
142
+
143
+ .auth-panel {
144
+ width: min(420px, calc(100vw - 32px));
145
+ display: grid;
146
+ gap: 12px;
147
+ }
148
+
149
+ .user-card {
150
+ display: grid;
151
+ gap: 12px;
152
+ }
153
+
154
+ .chip {
155
+ padding: 4px 10px;
156
+ border-radius: 999px;
157
+ font-size: 12px;
158
+ text-transform: capitalize;
159
+ background: #e2e8f0;
160
+ }
161
+
162
+ .chip.critical {
163
+ background: #fee2e2;
164
+ color: #b91c1c;
165
+ }
166
+
167
+ .chip.warning {
168
+ background: #fef3c7;
169
+ color: #b45309;
170
+ }
171
+
172
+ .chip.info {
173
+ background: #dbeafe;
174
+ color: #1d4ed8;
175
+ }
176
+
177
+ .code-block {
178
+ overflow: auto;
179
+ max-height: 300px;
180
+ background: #0f172a;
181
+ color: #e2e8f0;
182
+ border-radius: 8px;
183
+ padding: 12px;
184
+ }
185
+
186
+ .chat-widget {
187
+ position: fixed;
188
+ right: 20px;
189
+ bottom: 20px;
190
+ }
191
+
192
+ .chat-panel {
193
+ width: 320px;
194
+ background: white;
195
+ border: 1px solid #cbd5e1;
196
+ border-radius: 8px;
197
+ padding: 12px;
198
+ margin-top: 10px;
199
+ }
200
+
201
+ .chat-messages {
202
+ display: grid;
203
+ gap: 8px;
204
+ max-height: 260px;
205
+ overflow: auto;
206
+ margin-bottom: 12px;
207
+ }
208
+
209
+ .chat-bubble {
210
+ padding: 10px;
211
+ border-radius: 8px;
212
+ }
213
+
214
+ .chat-bubble.user {
215
+ background: #dbeafe;
216
+ }
217
+
218
+ .chat-bubble.assistant {
219
+ background: #f1f5f9;
220
+ }
221
+
222
+ @media (max-width: 900px) {
223
+ .app-shell {
224
+ grid-template-columns: 1fr;
225
+ }
226
+ }
227
+
frontend/vite.config.js ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ import { defineConfig } from "vite";
2
+ import react from "@vitejs/plugin-react";
3
+
4
+ export default defineConfig({
5
+ plugins: [react()],
6
+ server: {
7
+ port: 5173
8
+ }
9
+ });
10
+
functions/.env.example ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ FAIRNESS_BACKEND_URL=
2
+ GEMINI_API_KEY=
3
+ VERTEX_AI_ENDPOINT=
4
+ GOOGLE_CLOUD_PROJECT=fairlens-494713
5
+