from __future__ import annotations
from math import sqrt
from statistics import mean, pstdev
from typing import Any, Dict, List
from fastapi import FastAPI, HTTPException, Query
from fastapi.responses import HTMLResponse
import uvicorn
from models import EraseOpsAction
from server.environment import EraseOpsEnvironment
from server.graders import grade as compute_grade
app = FastAPI(title="GDPR EraseOps", version="1.0.0")
env = EraseOpsEnvironment()
TASK_IDS = ["clean_sweep", "tangled_lake", "legal_minefield", "cascade_collapse"]
SUPPORTED_REPLAY_POLICIES = ["noop", "greedy", "reasoning", "reckless"]
@app.get("/", response_class=HTMLResponse)
def root():
return """
GDPR EraseOps
GDPR EraseOps
Enterprise-grade reinforcement learning benchmark for GDPR right-to-erasure workflows,
with legal-hold constraints, dependency-order safety, and contamination risk controls.
Service status: running
Live Health
Check deployment liveness and current episode pointer.
Task Catalog
List canonical tasks with budget and grading metadata.
Judge Snapshot
Single payload for current state, metrics, and score.
Advanced Reports
Run benchmark matrix, forensic analysis, and weighted evaluation tracks.
"""
def _score_stats(scores: List[float]) -> Dict[str, float]:
if not scores:
return {
"avg": 0.0,
"min": 0.0,
"max": 0.0,
"std_dev": 0.0,
"ci95_low": 0.0,
"ci95_high": 0.0,
}
avg = mean(scores)
std = pstdev(scores) if len(scores) > 1 else 0.0
margin = 1.96 * (std / sqrt(max(1, len(scores))))
return {
"avg": round(float(avg), 6),
"min": round(float(min(scores)), 6),
"max": round(float(max(scores)), 6),
"std_dev": round(float(std), 6),
"ci95_low": round(float(max(0.0, avg - margin)), 6),
"ci95_high": round(float(min(1.0, avg + margin)), 6),
}
def _is_action_spam(steps: List[Dict[str, Any]]) -> bool:
if len(steps) < 8:
return False
action_types = [str(step.get("action", {}).get("action_type", "noop")) for step in steps]
if not action_types:
return False
top_action = max(set(action_types), key=action_types.count)
return (action_types.count(top_action) / len(action_types)) >= 0.72
def _extract_failure_taxonomy(final_state: Dict[str, Any], score: float, steps: List[Dict[str, Any]]) -> Dict[str, bool]:
counters = final_state.get("counters", {}) if isinstance(final_state, dict) else {}
legal_hold_violations = int(counters.get("legal_hold_violations", 0) or 0)
dependency_violations = int(counters.get("dependency_violations", 0) or 0)
contamination_violations = int(counters.get("contamination_violations", 0) or 0)
return {
"legal_safety_failure": legal_hold_violations > 0,
"structural_order_failure": dependency_violations > 0,
"contamination_failure": contamination_violations > 0,
"low_score_failure": score < 0.5,
"action_spam_failure": _is_action_spam(steps) and score < 0.6,
}
def _pii_candidates(observation: Dict[str, Any], escalated_legal_holds: set[str] | None = None) -> List[Dict[str, Any]]:
request = observation.get("request", {})
target_user = request.get("user_id")
records = observation.get("records", [])
escalated = escalated_legal_holds or set()
by_id = {
str(record.get("record_id")): record
for record in records
if record.get("record_id")
}
candidates: List[Dict[str, Any]] = []
for record in records:
if record.get("user_id") != target_user:
continue
if record.get("data_type") != "PII":
continue
if record.get("status") != "active":
continue
deps = record.get("dependencies") or []
unresolved = False
for dep_id in deps:
dep = by_id.get(str(dep_id))
if dep is None:
unresolved = True
break
if dep.get("status") == "deleted":
continue
if bool(dep.get("legal_hold", False)) and str(dep_id) in escalated:
continue
unresolved = True
break
if not unresolved:
candidates.append(record)
return candidates
def _pick_action(policy: str, observation: Dict[str, Any], memory: Dict[str, Any]) -> EraseOpsAction:
request = observation.get("request", {})
target_user = request.get("user_id")
records = observation.get("records", [])
if policy == "noop":
return EraseOpsAction(action_type="noop", justification="benchmark noop")
if policy == "reckless":
first_active = next((r for r in records if r.get("status") == "active"), None)
if first_active:
return EraseOpsAction(action_type="delete", record_id=first_active.get("record_id"), justification="reckless delete")
return EraseOpsAction(action_type="noop", justification="reckless fallback")
if policy == "greedy":
active_legal_holds = [
record
for record in records
if record.get("user_id") == target_user
and record.get("status") == "active"
and bool(record.get("legal_hold", False))
]
if len(active_legal_holds) < 3:
for record in active_legal_holds:
rid = record.get("record_id")
if not rid or rid in memory["escalated"]:
continue
memory["escalated"].add(rid)
return EraseOpsAction(action_type="escalate", record_id=rid, justification="greedy escalate legal hold")
else:
greedy_escalations = int(memory.get("greedy_escalations", 0) or 0)
escalation_cap = 4 if len(active_legal_holds) >= 6 else 2
if greedy_escalations < escalation_cap:
for record in active_legal_holds:
rid = record.get("record_id")
if not rid or rid in memory["escalated"]:
continue
memory["escalated"].add(rid)
memory["greedy_escalations"] = greedy_escalations + 1
return EraseOpsAction(action_type="escalate", record_id=rid, justification="greedy limited escalation")
greedy_targets = [
record
for record in records
if record.get("status") == "active"
and record.get("user_id") == target_user
and record.get("data_type") == "PII"
and record.get("record_id")
]
if greedy_targets:
cursor = int(memory.get("greedy_cursor", 0) or 0)
target = greedy_targets[cursor % len(greedy_targets)]
memory["greedy_cursor"] = cursor + 1
return EraseOpsAction(
action_type="delete",
record_id=target.get("record_id"),
justification="greedy immediate deletion",
)
for record in records:
rid = record.get("record_id")
if not rid:
continue
if record.get("status") != "active":
continue
if record.get("user_id") != target_user:
continue
if record.get("data_type") in {"derived", "anonymized"}:
return EraseOpsAction(action_type="anonymize", record_id=rid, justification="greedy cleanup")
return EraseOpsAction(action_type="noop", justification="no obvious greedy action")
# Greedy and reasoning share base safe ordering; reasoning inspects briefly first.
if policy == "reasoning":
inspect_limit = min(3, len(records))
if len(memory["inspected"]) < inspect_limit:
for record in records:
rid = record.get("record_id")
if rid and rid not in memory["inspected"]:
memory["inspected"].add(rid)
return EraseOpsAction(action_type="inspect", record_id=rid, justification="reasoning scan")
# Escalate unresolved legal-hold records first so blocked dependency chains can proceed.
for record in records:
rid = record.get("record_id")
if not rid:
continue
if record.get("user_id") != target_user:
continue
if record.get("status") != "active":
continue
if not bool(record.get("legal_hold", False)):
continue
if rid in memory["escalated"]:
continue
memory["escalated"].add(rid)
return EraseOpsAction(action_type="escalate", record_id=rid, justification="escalate legal hold")
# Reasoning deletes only dependency-safe candidates.
for record in _pii_candidates(observation, memory["escalated"]):
rid = record.get("record_id")
if rid:
return EraseOpsAction(action_type="delete", record_id=rid, justification="safe pii deletion")
return EraseOpsAction(action_type="noop", justification="no safe action")
def _rollout_episode(task_name: str, seed: int, policy: str, evaluation_track: str = "public") -> Dict[str, Any]:
if policy not in SUPPORTED_REPLAY_POLICIES:
raise ValueError(f"Unsupported policy '{policy}'. Available: {', '.join(SUPPORTED_REPLAY_POLICIES)}")
local_env = EraseOpsEnvironment()
observation = local_env.reset(task_name=task_name, seed=seed)
memory: Dict[str, Any] = {
"escalated": set(),
"inspected": set(),
"greedy_cursor": 0,
"greedy_escalations": 0,
}
steps: List[Dict[str, Any]] = []
done = False
while not done:
action = _pick_action(policy, observation.model_dump(), memory)
next_observation, reward, done, info = local_env.step(action)
steps.append(
{
"step": int(next_observation.step),
"action": action.model_dump(),
"reward": reward.model_dump(),
"done": bool(done),
"info": info,
"observation": next_observation.model_dump(),
}
)
observation = next_observation
final_state = local_env.state()
base_score = float(compute_grade(task_name, final_state))
failure_taxonomy = _extract_failure_taxonomy(final_state, base_score, steps)
if evaluation_track == "hidden":
hidden_penalty = 0.02 * sum(1 for failed in failure_taxonomy.values() if failed)
score = round(max(0.01, min(0.99, base_score - hidden_penalty)), 4)
else:
score = round(base_score, 4)
return {
"task_name": task_name,
"seed": seed,
"policy": policy,
"evaluation_track": evaluation_track,
"score": score,
"failure_taxonomy": failure_taxonomy,
"final_state": final_state,
"steps": steps,
}
def _forensic_diagnostics(rollout: Dict[str, Any]) -> Dict[str, Any]:
steps = rollout.get("steps", [])
first_negative_step = None
for step in steps:
reward = float(step.get("reward", {}).get("total", 0.0) or 0.0)
if reward < 0:
first_negative_step = int(step.get("step", 0))
break
failures = rollout.get("failure_taxonomy", {})
recommendations = [
"Prioritize escalation on legal-hold dependencies before any delete attempt.",
"Avoid cross-user operations by filtering actions to request.user_id scope only.",
"Use inspect to map dependency chains before high-impact deletes on hard tasks.",
]
if failures.get("action_spam_failure", False):
recommendations.insert(0, "Reduce repeated action loops and switch to inspect-escalate-replan behavior.")
if failures.get("legal_safety_failure", False):
recommendations.insert(0, "Treat legal-hold interactions as hard constraints and escalate instead of mutate.")
return {
"first_negative_reward_step": first_negative_step,
"counterfactual_policy": "reasoning",
"recommendations": recommendations,
}
@app.post("/reset")
def reset(task_name: str = Query(default="clean_sweep"), seed: int = Query(default=42)):
try:
obs = env.reset(task_name=task_name, seed=seed)
return obs.model_dump()
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Reset failed: {exc}") from exc
@app.post("/step")
def step(action: EraseOpsAction):
try:
obs, reward, done, info = env.step(action)
return {
"observation": obs.model_dump(),
"reward": reward.model_dump(),
"done": done,
"info": info,
}
except RuntimeError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Step failed: {exc}") from exc
@app.get("/state")
def state():
try:
return env.state()
except Exception as exc:
raise HTTPException(status_code=500, detail=f"State retrieval failed: {exc}") from exc
@app.get("/health")
def health():
try:
current_state = env.state()
return {
"status": "ok",
"task_name": current_state.get("task_name"),
"step": current_state.get("step", 0),
"done": current_state.get("done", False),
}
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Health check failed: {exc}") from exc
@app.get("/tasks")
def tasks():
return {
"tasks": [
{"id": "clean_sweep", "difficulty": "easy", "step_budget": 10, "has_grader": True},
{"id": "tangled_lake", "difficulty": "medium", "step_budget": 20, "has_grader": True},
{"id": "legal_minefield", "difficulty": "hard", "step_budget": 30, "has_grader": True},
{"id": "cascade_collapse", "difficulty": "expert", "step_budget": 18, "has_grader": True},
]
}
@app.get("/metrics")
def metrics(include_trace: bool = Query(default=False)):
try:
return env.metrics(include_trace=include_trace)
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Metrics retrieval failed: {exc}") from exc
@app.get("/replay")
def replay(limit: int = Query(default=50, ge=0, le=500)):
try:
return env.replay(limit=limit)
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Replay retrieval failed: {exc}") from exc
@app.get("/visualize")
def visualize():
try:
return {"dashboard": env.visualization()}
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Visualization failed: {exc}") from exc
@app.get("/judge_pack")
def judge_pack(include_trace: bool = Query(default=False)):
try:
state = env.state()
if state.get("request") is None or not state.get("task_name"):
return {"status": "no_episode", "message": "Call /reset first"}
task_name = state.get("task_name") or "clean_sweep"
score = compute_grade(task_name, state)
metrics = env.metrics(include_trace=include_trace)
return {
"task_name": task_name,
"score": score,
"state": state,
"metrics": metrics,
"tasks": tasks()["tasks"],
}
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Judge pack generation failed: {exc}") from exc
@app.post("/grade")
def grade(task_name: str = Query(...)):
try:
final_state = env.state()
score = compute_grade(task_name, final_state)
return {"score": score, "task": task_name}
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Grading failed: {exc}") from exc
@app.get("/benchmark_matrix")
def benchmark_matrix(
episodes_per_task: int = Query(default=3, ge=1, le=20),
seed_start: int = Query(default=42),
):
try:
matrix: Dict[str, Dict[str, Any]] = {}
policy_totals: Dict[str, List[float]] = {policy: [] for policy in ["noop", "greedy", "reasoning"]}
for task_name in TASK_IDS:
per_policy: Dict[str, Any] = {}
for policy in ["noop", "greedy", "reasoning"]:
scores: List[float] = []
for offset in range(episodes_per_task):
rollout = _rollout_episode(task_name=task_name, seed=seed_start + offset, policy=policy)
score = float(rollout["score"])
scores.append(score)
policy_totals[policy].append(score)
per_policy[policy] = {
"scores": [round(value, 4) for value in scores],
"stats": _score_stats(scores),
}
matrix[task_name] = per_policy
summary = {
policy: _score_stats(scores)
for policy, scores in policy_totals.items()
}
return {
"episodes_per_task": episodes_per_task,
"seed_start": seed_start,
"tasks": TASK_IDS,
"policies": ["noop", "greedy", "reasoning"],
"matrix": matrix,
"summary": summary,
}
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Benchmark matrix generation failed: {exc}") from exc
@app.get("/forensic_audit")
def forensic_audit(
task_name: str = Query(default="legal_minefield"),
seed: int = Query(default=42),
policy: str = Query(default="greedy"),
):
try:
rollout = _rollout_episode(task_name=task_name, seed=seed, policy=policy)
diagnostics = _forensic_diagnostics(rollout)
return {
"task_name": task_name,
"seed": seed,
"policy": policy,
"score": rollout["score"],
"failure_taxonomy": rollout["failure_taxonomy"],
"diagnostics": diagnostics,
"step_count": len(rollout["steps"]),
}
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Forensic audit failed: {exc}") from exc
@app.get("/evaluation_report")
def evaluation_report(
policy: str = Query(default="reasoning"),
episodes_per_task: int = Query(default=3, ge=1, le=20),
seed_start: int = Query(default=42),
include_hidden: bool = Query(default=False),
hidden_weight: float = Query(default=0.3, ge=0.0, le=0.8),
):
try:
if policy not in SUPPORTED_REPLAY_POLICIES:
raise ValueError(f"Unsupported policy '{policy}'. Available: {', '.join(SUPPORTED_REPLAY_POLICIES)}")
public_per_task: Dict[str, Any] = {}
hidden_per_task: Dict[str, Any] = {}
public_scores_all: List[float] = []
hidden_scores_all: List[float] = []
for task_name in TASK_IDS:
task_public_scores: List[float] = []
task_hidden_scores: List[float] = []
for offset in range(episodes_per_task):
seed = seed_start + offset
public_rollout = _rollout_episode(task_name=task_name, seed=seed, policy=policy, evaluation_track="public")
public_score = float(public_rollout["score"])
task_public_scores.append(public_score)
public_scores_all.append(public_score)
if include_hidden:
hidden_seed = seed_start + 1000 + offset
hidden_rollout = _rollout_episode(task_name=task_name, seed=hidden_seed, policy=policy, evaluation_track="hidden")
hidden_score = float(hidden_rollout["score"])
task_hidden_scores.append(hidden_score)
hidden_scores_all.append(hidden_score)
public_per_task[task_name] = {
"stats": _score_stats(task_public_scores),
"scores": [round(value, 4) for value in task_public_scores],
}
if include_hidden:
hidden_per_task[task_name] = {
"stats": _score_stats(task_hidden_scores),
"scores": [round(value, 4) for value in task_hidden_scores],
}
public_summary = _score_stats(public_scores_all)
if include_hidden and hidden_scores_all:
hidden_summary = _score_stats(hidden_scores_all)
overall = (1.0 - hidden_weight) * public_summary["avg"] + hidden_weight * hidden_summary["avg"]
overall_summary = {
"overall_score": round(overall, 6),
"public_weight": round(1.0 - hidden_weight, 4),
"hidden_weight": round(hidden_weight, 4),
"public_avg": public_summary["avg"],
"hidden_avg": hidden_summary["avg"],
}
else:
hidden_summary = {}
overall_summary = {
"overall_score": public_summary["avg"],
"public_weight": 1.0,
"hidden_weight": 0.0,
"public_avg": public_summary["avg"],
}
return {
"policy": policy,
"episodes_per_task": episodes_per_task,
"seed_start": seed_start,
"include_hidden": include_hidden,
"tracks": {
"public": {
"summary": public_summary,
"per_task": public_per_task,
},
"hidden": {
"summary": hidden_summary,
"per_task": hidden_per_task,
} if include_hidden else None,
},
"overall": overall_summary,
}
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Evaluation report failed: {exc}") from exc
@app.get("/judge_quickstart")
def judge_quickstart():
return {
"project": "gdpr-eraseops",
"objective": "Validate legal-safe deletion policy quality in under 5 minutes.",
"steps": [
{
"step": 1,
"name": "Health and task discovery",
"calls": ["GET /health", "GET /tasks"],
"expected": "status=ok and 4 graded tasks visible",
},
{
"step": 2,
"name": "One deterministic rollout",
"calls": [
"GET /forensic_audit?task_name=legal_minefield&seed=42&policy=greedy",
"GET /forensic_audit?task_name=legal_minefield&seed=42&policy=reasoning",
],
"expected": "reasoning policy demonstrates safer failure taxonomy",
},
{
"step": 3,
"name": "Benchmark comparison",
"calls": ["GET /benchmark_matrix?episodes_per_task=3&seed_start=42"],
"expected": "reasoning policy outperforms noop and greedy on hard tasks",
},
{
"step": 4,
"name": "Weighted evaluation report",
"calls": ["GET /evaluation_report?policy=reasoning&episodes_per_task=3&seed_start=42&include_hidden=true&hidden_weight=0.35"],
"expected": "stable overall score with hidden-track robustness",
},
],
"judge_signals": {
"deterministic_rollouts": True,
"forensic_failure_taxonomy": True,
"policy_comparison_surface": True,
"weighted_hidden_track": True,
},
}
@app.get("/showcase", response_class=HTMLResponse)
def showcase():
return """
GDPR EraseOps Showcase
GDPR EraseOps Showcase
Judge-first panel for quick evidence of robustness, safety, and reproducibility.
Compliance Safety
Legal-hold, dependency-order, and contamination failures are first-class scored signals.
Auditability
Use /judge_pack, /forensic_audit, and /replay for deterministic trace review.
Benchmark Rigor
Compare policies through /benchmark_matrix and hidden-track weighted reports.
"""
def main() -> None:
uvicorn.run(app, host="0.0.0.0", port=7860)
if __name__ == "__main__":
main()