File size: 11,286 Bytes
e9ce6e9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
"""
server/app.py β€” FastAPI wrapper for the Unified Fintech Risk Gateway
====================================================================
OpenEnv / Meta PyTorch Hackathon compliant server.

Endpoints
---------
GET  /           β†’ health check (Hugging Face / automated grader probe)
GET  /reset      β†’ health check (grader pings before issuing POST /reset)
POST /reset      β†’ re-initialise the environment for a given task
POST /step       β†’ advance one step with a typed UFRGAction
GET  /state      β†’ inspect current observation without side-effects

Design decisions
----------------
* env is a **module-level singleton** kept alive across episodes so that
  curriculum_level and adversary Q-table persist (re-instantiating on
  every /reset would wipe those cross-episode accumulators).
* _env_lock (asyncio.Lock) serialises all env mutations. FastAPI uses an
  async event loop β€” without the lock, a concurrent /step coroutine can
  interleave between the 'await request.json()' and 'env.reset()' calls
  in /reset, silently corrupting mid-episode state.
* _episode_active tracks whether the client has called POST /reset in this
  session. /step and /state return 400 until the first explicit reset.
* Actions are validated through AEPOAction Pydantic model before they
  reach env.step(), so malformed payloads return HTTP 422 automatically.
* Observations are serialised with .model_dump() for OpenEnv clients.
"""

import asyncio
import os

import uvicorn
from fastapi import FastAPI, HTTPException, Request
from fastapi.staticfiles import StaticFiles
from pydantic import ValidationError

from unified_gateway import AEPOAction, AEPOObservation, UFRGAction, UFRGObservation, UFRGReward, UnifiedFintechEnv

# ---------------------------------------------------------------------------
# Application bootstrap
# ---------------------------------------------------------------------------

app = FastAPI(
    title="Autonomous Enterprise Payment Orchestrator (AEPO)",
    description=(
        "OpenEnv-compliant causally-structured simulation of a UPI payment risk gateway. "
        "Supports three difficulty tiers: easy, medium, hard."
    ),
    version="0.2.0",
)

# ---------------------------------------------------------------------------
# Module-level singleton state
# ---------------------------------------------------------------------------

# Single env instance β€” kept alive so curriculum_level and adversary Q-table
# accumulate across episodes (re-instantiating would wipe them).
env = UnifiedFintechEnv()
env.reset(options={"task": "easy"})   # prime env to a valid state on startup

# asyncio.Lock β€” serialises all env mutations against event-loop interleaving.
# Must be created at module level (not inside an async function) so it is
# shared across all coroutines running in the same event loop.
_env_lock: asyncio.Lock = asyncio.Lock()

# True only after the client has called POST /reset at least once in this
# session.  /step and /state return HTTP 400 until this flag is set.
# Note: the module-level env.reset() above does NOT set this flag β€” that
# call primes the env but the client has not yet started an episode.
_episode_active: bool = False


# ---------------------------------------------------------------------------
# Health checks  (GET probes β€” must return 200 OK, never 405)
# ---------------------------------------------------------------------------

@app.get("/", tags=["health"])
async def root_health_check():
    """
    Root health-check.

    Hugging Face Spaces and many automated graders issue a GET / to verify
    the container is responsive before running evaluation.  This endpoint
    must exist and return 200 OK.
    """
    return {
        "status": "healthy",
        "message": "AEPO is live. Use POST /reset to initialise a task.",
    }


@app.get("/reset", tags=["health"])
async def reset_health_check():
    """
    Pre-flight health-check for /reset.

    Some evaluation harnesses issue GET /reset to confirm the route is
    registered before sending POST /reset.  Returning 200 OK satisfies that
    probe without having any side-effects on the running environment.
    """
    return {
        "status": "healthy",
        "message": "Route /reset is live. Send POST /reset with {\"task\": \"easy|medium|hard\"} to begin.",
    }


@app.get("/contract", tags=["health"])
async def contract_info():
    """
    OpenEnv 4-tuple contract declaration (Fix 9.4 β€” Gymnasium 4-tuple bridge).

    Advertises the AEPO step() return format so judges and automated
    graders can verify the tuple arity without reading source code.

    AEPO uses the OpenEnv 4-tuple contract, NOT Gymnasium's 5-tuple:
        POST /step β†’ { observation, reward, done, info }   ← 4 fields
        POST /reset β†’ { observation, info }                ← 2 fields

    Gymnasium's 5-tuple (terminated + truncated separate) is only exposed
    via GymnasiumCompatWrapper for check_env CI validation. All submission
    evaluation paths (graders, inference, this server) use the 4-tuple.
    """
    return {
        "step_tuple": "4-tuple",
        "step_format": env.STEP_TUPLE_FORMAT,
        "openenv_compliant": env.IS_OPENENV_COMPLIANT,
        "gymnasium_compat_wrapper": "GymnasiumCompatWrapper (5-tuple, CI only)",
        "note": (
            "AEPO never truncates β€” episodes end via crash, fraud, or 100-step limit. "
            "Hence Gymnasium's 'truncated' field is always False in the wrapper."
        ),
    }


# ---------------------------------------------------------------------------
# POST /reset β€” task-driven environment initialisation
# ---------------------------------------------------------------------------

@app.post("/reset", tags=["env"])
async def reset_env(request: Request):
    """
    Re-initialise the environment for a new episode.

    Request body (JSON, optional)
    ------------------------------
    ``task`` : str, default ``"easy"``
        Difficulty tier β€” one of ``"easy"``, ``"medium"``, ``"hard"``.

    Returns
    -------
    JSON object with an ``observation`` key containing the initial
    ``UFRGObservation`` dict.
    """
    global _episode_active

    # Parse task before acquiring the lock β€” I/O (JSON decode) outside critical section.
    try:
        body = await request.json()
        task_name: str = body.get("task", "easy")
    except Exception:
        task_name = "easy"

    if task_name not in {"easy", "medium", "hard"}:
        raise HTTPException(
            status_code=422,
            detail=f"Invalid task '{task_name}'. Must be one of: easy, medium, hard.",
        )

    # Acquire lock before touching env β€” prevents a concurrent /step coroutine
    # from interleaving between this point and env.reset() below.
    async with _env_lock:
        # NOT re-instantiation: keep singleton alive so curriculum_level and
        # adversary Q-table persist across episodes.
        obs, _info = env.reset(options={"task": task_name})
        _episode_active = True

    return {"observation": obs.model_dump(), "info": _info}


# ---------------------------------------------------------------------------
# POST /step β€” advance one time-step
# ---------------------------------------------------------------------------

@app.post("/step", tags=["env"])
async def step_env(request: Request):
    """
    Advance the environment by one step.

    Request body (JSON)
    --------------------
    ``action`` : dict
        A JSON object with keys matching AEPOAction fields. Required: ``risk_decision``,
        ``infra_routing``, ``crypto_verify``. Optional (safe defaults provided):
        ``db_retry_policy``, ``settlement_policy``, ``app_priority``.

    Returns
    -------
    JSON object conforming to the OpenEnv step response spec:
    ``{ observation, reward, done, info }``.
    """
    # Guard: client must call POST /reset before stepping.
    if not _episode_active:
        raise HTTPException(
            status_code=400,
            detail="No active episode. Call POST /reset with a task before stepping.",
        )

    # Parse and validate action outside the lock β€” CPU work, no env mutation.
    try:
        body = await request.json()
        action_dict = body.get("action")
        if action_dict is None:
            raise HTTPException(
                status_code=422,
                detail="Request body must contain an 'action' key.",
            )
    except HTTPException:
        raise
    except Exception as exc:
        raise HTTPException(status_code=400, detail=f"Malformed JSON body: {exc}") from exc

    try:
        action = AEPOAction(**action_dict)
    except (ValidationError, TypeError) as exc:
        raise HTTPException(status_code=422, detail=str(exc)) from exc

    async with _env_lock:
        obs, typed_reward, done, info = env.step(action)

    return {
        "observation": obs.model_dump(),
        "reward": typed_reward.value,
        "reward_breakdown": typed_reward.breakdown,
        "done": bool(done),
        "info": info,
    }


# ---------------------------------------------------------------------------
# GET /state β€” non-destructive observation peek
# ---------------------------------------------------------------------------

@app.get("/state", tags=["env"])
async def get_state():
    """
    Return the most-recent observation without advancing the clock.

    Satisfies the OpenEnv ``state()`` contract: any evaluation harness can
    inspect the current environment state without triggering side-effects.
    Returns HTTP 400 if called before POST /reset.
    """
    if not _episode_active:
        raise HTTPException(
            status_code=400,
            detail="No active episode. Call POST /reset with a task first.",
        )
    async with _env_lock:
        current_obs = env.state()
    return {"observation": current_obs.model_dump()}


# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------

def main() -> None:  # pragma: no cover
    uvicorn.run("server.app:app", host="0.0.0.0", port=7860)


# ---------------------------------------------------------------------------
# Static frontend β€” mounted LAST so explicit API routes take priority
# ---------------------------------------------------------------------------
# When deployed in Docker / HF Spaces the Next.js dashboard is built to a
# static export at `frontend/out/`.  FastAPI serves it at "/" so the Space
# shows the interactive dashboard by default.
#
# Explicit routes (/reset, /step, /state, /contract, /health) are resolved by
# Starlette's router BEFORE it falls through to this mounted sub-app, so there
# is no collision between the API and the static files.
#
# In local development (no Docker), this directory may not exist; the guard
# prevents a startup crash while keeping the server fully functional for
# openenv validate and pytest.
_FRONTEND_OUT = os.path.join(os.path.dirname(__file__), "..", "frontend", "out")
if os.path.isdir(_FRONTEND_OUT):
    app.mount(
        "/",
        StaticFiles(directory=_FRONTEND_OUT, html=True),
        name="frontend",
    )


if __name__ == "__main__":
    main()