Spaces:
Sleeping
Sleeping
Upload folder using huggingface_hub
Browse files- Dockerfile +31 -0
- README.md +85 -6
- cace_env/__init__.py +10 -0
- cace_env/client.py +96 -0
- cace_env/dataset.py +56 -0
- cace_env/inference.py +316 -0
- cace_env/models.py +104 -0
- cace_env/pipeline.py +162 -0
- cace_env/reward.py +106 -0
- cace_env/server.py +409 -0
- data/all_cases.json +0 -0
- data/master_dataset.json +0 -0
- data/pipeline_cache.json +0 -0
- openenv.yaml +31 -0
- pyproject.toml +31 -0
Dockerfile
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Build from openenv-base — required by OpenEnv spec
|
| 2 |
+
# openenv-base includes: Python 3.11, uvicorn, fastapi, openenv-core
|
| 3 |
+
FROM ghcr.io/meta-pytorch/openenv-base:latest
|
| 4 |
+
|
| 5 |
+
WORKDIR /app
|
| 6 |
+
|
| 7 |
+
# Install Python dependencies
|
| 8 |
+
COPY pyproject.toml .
|
| 9 |
+
RUN pip install --no-cache-dir -e ".[all]"
|
| 10 |
+
|
| 11 |
+
# Copy environment package
|
| 12 |
+
COPY cace_env/ ./cace_env/
|
| 13 |
+
|
| 14 |
+
# Copy data (Oversight Board cases + pipeline cache)
|
| 15 |
+
COPY data/ ./data/
|
| 16 |
+
|
| 17 |
+
# HuggingFace Spaces uses port 7860
|
| 18 |
+
EXPOSE 7860
|
| 19 |
+
|
| 20 |
+
# Environment config
|
| 21 |
+
ENV DATASET_PATH=data/all_cases.json
|
| 22 |
+
ENV MODE=v1
|
| 23 |
+
ENV PYTHONUNBUFFERED=1
|
| 24 |
+
ENV PYTHONPATH=/app
|
| 25 |
+
|
| 26 |
+
# Healthcheck for openenv validate
|
| 27 |
+
HEALTHCHECK --interval=30s --timeout=10s --start-period=90s --retries=3 \
|
| 28 |
+
CMD curl -f http://localhost:7860/health || exit 1
|
| 29 |
+
|
| 30 |
+
# Start server using the FastAPI app from server.py
|
| 31 |
+
CMD ["uvicorn", "cace_env.server:app", "--host", "0.0.0.0", "--port", "7860", "--workers", "1"]
|
README.md
CHANGED
|
@@ -1,10 +1,89 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
| 7 |
-
pinned:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: CACE — Cultural Context Arbitration Environment
|
| 3 |
+
emoji: ⚖️
|
| 4 |
+
colorFrom: indigo
|
| 5 |
+
colorTo: purple
|
| 6 |
sdk: docker
|
| 7 |
+
pinned: true
|
| 8 |
+
license: apache-2.0
|
| 9 |
+
tags:
|
| 10 |
+
- reinforcement-learning
|
| 11 |
+
- openenv
|
| 12 |
+
- content-moderation
|
| 13 |
+
- multi-agent
|
| 14 |
+
- rlvr
|
| 15 |
+
- grpo
|
| 16 |
---
|
| 17 |
|
| 18 |
+
# Cultural Context Arbitration Environment (CACE)
|
| 19 |
+
|
| 20 |
+
**OpenEnv Hackathon 2026 | Theme 1 (Multi-Agent) + Theme 3.1 (World Modeling)**
|
| 21 |
+
|
| 22 |
+
Trains a single LLM policy via GRPO to make culturally-aware content moderation decisions — using Meta's Oversight Board rulings (200+ binding public decisions) as the **verifiable reward oracle**.
|
| 23 |
+
|
| 24 |
+
## Quick Start
|
| 25 |
+
|
| 26 |
+
```python
|
| 27 |
+
from cace_env import CACEEnvClient, CACEAction
|
| 28 |
+
import asyncio
|
| 29 |
+
|
| 30 |
+
async def main():
|
| 31 |
+
# Connect to HF Space
|
| 32 |
+
async with CACEEnvClient(base_url="ws://YOUR_USERNAME-cace-env.hf.space") as env:
|
| 33 |
+
|
| 34 |
+
# V1: single case episode
|
| 35 |
+
result = await env.reset()
|
| 36 |
+
print(result.observation.observation[:200])
|
| 37 |
+
|
| 38 |
+
# Make a moderation decision (0=ALLOW, 1=REMOVE, 2=ALLOW_WITH_LABEL, 3=ESCALATE, 4=RESTRICT)
|
| 39 |
+
result = await env.step(CACEAction(action_int=0))
|
| 40 |
+
print(f"Reward: {result.reward:.4f} | Correct: {result.observation.reward_breakdown['correct']}")
|
| 41 |
+
|
| 42 |
+
asyncio.run(main())
|
| 43 |
+
```
|
| 44 |
+
|
| 45 |
+
## V1 vs V2 Modes
|
| 46 |
+
|
| 47 |
+
| | V1 (Simple) | V2 (Network) |
|
| 48 |
+
|--|--|--|
|
| 49 |
+
| Episode | 1 post → 1 decision | 20 posts on social graph → pick 8 → 8 decisions |
|
| 50 |
+
| Observation | Enriched single case | Network batch with spread signals |
|
| 51 |
+
| Reward | 3-track reward | 3-track + network spread bonus |
|
| 52 |
+
| Use for | Quick training | Full demo |
|
| 53 |
+
|
| 54 |
+
## Three-Track Reward
|
| 55 |
+
|
| 56 |
+
| Track | Weight | Measures |
|
| 57 |
+
|-------|--------|---------|
|
| 58 |
+
| Cultural Meaning Resolution | 40% | Correct interpretation of culturally local language |
|
| 59 |
+
| Harm Detection Under Context | 35% | Catching real harm that looks ambiguous |
|
| 60 |
+
| Policy Calibration + Escalation | 25% | Right tool for right case — no lazy escalation |
|
| 61 |
+
|
| 62 |
+
Combined reward: **[-1.0, +1.0]** (normalised for GRPO)
|
| 63 |
+
|
| 64 |
+
## Architecture
|
| 65 |
+
|
| 66 |
+
```
|
| 67 |
+
4 Frozen Agents (Groq/Azure — inference only, no gradients):
|
| 68 |
+
Intake Agent → language, region, policy clause
|
| 69 |
+
Cultural Context → charitable cultural interpretation
|
| 70 |
+
Adversarial Challenge → stress-tests the cultural argument
|
| 71 |
+
Policy Alignment → Meta Community Standards anchor
|
| 72 |
+
|
| 73 |
+
1 Trainable Agent (GRPO via Unsloth + TRL):
|
| 74 |
+
Decision Agent → ALLOW | REMOVE | ALLOW_WITH_LABEL | ESCALATE | RESTRICT_DISTRIBUTION
|
| 75 |
+
|
| 76 |
+
Reward Oracle: Meta Oversight Board — 200+ binding public decisions
|
| 77 |
+
No LLM judge. Fully deterministic reward.
|
| 78 |
+
```
|
| 79 |
+
|
| 80 |
+
## Environment API
|
| 81 |
+
|
| 82 |
+
```
|
| 83 |
+
POST /reset → start episode (returns CACEObservation)
|
| 84 |
+
POST /step → apply CACEAction (returns CACEObservation with reward)
|
| 85 |
+
GET /state → current CACEState (for debugging)
|
| 86 |
+
GET /health → liveness check
|
| 87 |
+
GET /docs → FastAPI Swagger UI
|
| 88 |
+
GET /web → OpenEnv web interface
|
| 89 |
+
```
|
cace_env/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
cace_env — Cultural Context Arbitration Environment
|
| 3 |
+
OpenEnv-compatible RL environment for culturally-aware content moderation.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from cace_env.models import CACEAction, CACEObservation, CACEState
|
| 7 |
+
from cace_env.client import CACEEnvClient
|
| 8 |
+
|
| 9 |
+
__all__ = ["CACEAction", "CACEObservation", "CACEState", "CACEEnvClient"]
|
| 10 |
+
__version__ = "0.2.0"
|
cace_env/client.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
cace_env/client.py
|
| 3 |
+
CACEEnvClient — typed async/sync client for the CACE environment.
|
| 4 |
+
Inherits from openenv.core.EnvClient.
|
| 5 |
+
|
| 6 |
+
Usage (async):
|
| 7 |
+
async with CACEEnvClient(base_url="ws://localhost:7860") as env:
|
| 8 |
+
result = await env.reset()
|
| 9 |
+
result = await env.step(CACEAction(action_int=0))
|
| 10 |
+
|
| 11 |
+
Usage (sync):
|
| 12 |
+
with CACEEnvClient(base_url="ws://localhost:7860").sync() as env:
|
| 13 |
+
result = env.reset()
|
| 14 |
+
result = env.step(CACEAction(action_int=0))
|
| 15 |
+
|
| 16 |
+
Usage (from Docker):
|
| 17 |
+
env = await CACEEnvClient.from_docker_image("cace-env:latest")
|
| 18 |
+
async with env:
|
| 19 |
+
result = await env.reset()
|
| 20 |
+
|
| 21 |
+
Usage (from HF Space):
|
| 22 |
+
env = await CACEEnvClient.from_env("YOUR_USERNAME/cace-env")
|
| 23 |
+
async with env:
|
| 24 |
+
result = await env.reset()
|
| 25 |
+
"""
|
| 26 |
+
|
| 27 |
+
from typing import Any, Dict, Optional
|
| 28 |
+
from openenv.core.env_client import EnvClient
|
| 29 |
+
from cace_env.models import CACEAction, CACEObservation, CACEState
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class CACEEnvClient(EnvClient[CACEAction, CACEObservation, CACEState]):
|
| 33 |
+
"""
|
| 34 |
+
Typed async client for the Cultural Context Arbitration Environment.
|
| 35 |
+
|
| 36 |
+
Implements the two abstract parse methods required by EnvClient:
|
| 37 |
+
_parse_result(payload) → StepResult[CACEObservation]
|
| 38 |
+
_parse_state(payload) → CACEState
|
| 39 |
+
"""
|
| 40 |
+
|
| 41 |
+
def _parse_result(self, payload: Dict[str, Any]):
|
| 42 |
+
"""Parse server step/reset response into StepResult[CACEObservation]."""
|
| 43 |
+
from openenv.core.env_client import StepResult
|
| 44 |
+
obs_data = payload.get("observation", payload)
|
| 45 |
+
if isinstance(obs_data, str):
|
| 46 |
+
obs = CACEObservation(
|
| 47 |
+
observation=obs_data,
|
| 48 |
+
case_id=payload.get("case_id", "unknown"),
|
| 49 |
+
language=payload.get("language", "Unknown"),
|
| 50 |
+
region=payload.get("region", "Unknown"),
|
| 51 |
+
complexity=payload.get("complexity", "medium"),
|
| 52 |
+
done=payload.get("done", False),
|
| 53 |
+
reward=payload.get("reward", None),
|
| 54 |
+
)
|
| 55 |
+
else:
|
| 56 |
+
obs = CACEObservation(**obs_data)
|
| 57 |
+
|
| 58 |
+
return StepResult(
|
| 59 |
+
observation=obs,
|
| 60 |
+
done=payload.get("done", obs.done),
|
| 61 |
+
reward=payload.get("reward", obs.reward),
|
| 62 |
+
info=payload.get("info", {}),
|
| 63 |
+
)
|
| 64 |
+
|
| 65 |
+
def _parse_state(self, payload: Dict[str, Any]) -> CACEState:
|
| 66 |
+
"""Parse server state response into CACEState."""
|
| 67 |
+
return CACEState(**{k: v for k, v in payload.items() if k in CACEState.model_fields})
|
| 68 |
+
|
| 69 |
+
# ── Convenience methods ───────────────────────────────────────────────────
|
| 70 |
+
|
| 71 |
+
async def reset_v1(self, seed: Optional[int] = None):
|
| 72 |
+
"""Reset in V1 mode (single case)."""
|
| 73 |
+
return await self.reset(mode="v1", seed=seed)
|
| 74 |
+
|
| 75 |
+
async def reset_v2(self, seed: Optional[int] = None):
|
| 76 |
+
"""Reset in V2 mode (network batch of 20 posts)."""
|
| 77 |
+
return await self.reset(mode="v2", seed=seed)
|
| 78 |
+
|
| 79 |
+
async def decide(self, action_int: int, selected_indices: list[int] | None = None):
|
| 80 |
+
"""Shorthand for step with a moderation decision."""
|
| 81 |
+
action = CACEAction(action_int=action_int, selected_indices=selected_indices)
|
| 82 |
+
return await self.step(action)
|
| 83 |
+
|
| 84 |
+
# ── Class methods for easy instantiation ──────────────────────────────────
|
| 85 |
+
|
| 86 |
+
@classmethod
|
| 87 |
+
async def from_local(cls, port: int = 7860, **kwargs):
|
| 88 |
+
"""Connect to a locally running server."""
|
| 89 |
+
client = cls(base_url=f"ws://localhost:{port}", **kwargs)
|
| 90 |
+
await client.connect()
|
| 91 |
+
return client
|
| 92 |
+
|
| 93 |
+
# Inherited from EnvClient:
|
| 94 |
+
# CACEEnvClient.from_docker_image("cace-env:latest")
|
| 95 |
+
# CACEEnvClient.from_env("username/cace-env")
|
| 96 |
+
# .sync() → SyncEnvClient wrapper for synchronous use
|
cace_env/dataset.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
cace_env/dataset.py
|
| 3 |
+
Case dataset loader. Supports v1 (single case) and v2 (batch of 20 for network layer).
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import json, random
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
ACTION_MAP = {0: "ALLOW", 1: "REMOVE", 2: "ALLOW_WITH_LABEL", 3: "ESCALATE", 4: "RESTRICT_DISTRIBUTION"}
|
| 11 |
+
REQUIRED = {"id", "post_text", "board_outcome", "language", "region", "complexity"}
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class CaseDataset:
|
| 15 |
+
def __init__(self, path: str, seed: int = 42):
|
| 16 |
+
with open(path, encoding="utf-8") as f:
|
| 17 |
+
raw = json.load(f)
|
| 18 |
+
|
| 19 |
+
cases = [c for c in raw if REQUIRED.issubset(c.keys())]
|
| 20 |
+
random.seed(seed)
|
| 21 |
+
random.shuffle(cases)
|
| 22 |
+
split = int(len(cases) * 0.8)
|
| 23 |
+
self.train = cases[:split]
|
| 24 |
+
self.eval = cases[split:]
|
| 25 |
+
self._mode = "train"
|
| 26 |
+
self._by_id = {c["id"]: c for c in cases}
|
| 27 |
+
self._cache: dict = {}
|
| 28 |
+
|
| 29 |
+
# Load pipeline cache if available
|
| 30 |
+
cache_path = Path(path).parent / "pipeline_cache.json"
|
| 31 |
+
if cache_path.exists():
|
| 32 |
+
with open(cache_path, encoding="utf-8") as f:
|
| 33 |
+
self._cache = json.load(f)
|
| 34 |
+
|
| 35 |
+
print(f"[Dataset] {len(self.train)} train | {len(self.eval)} eval | {len(self._cache)} cached")
|
| 36 |
+
|
| 37 |
+
@property
|
| 38 |
+
def pool(self):
|
| 39 |
+
return self.train if self._mode == "train" else self.eval
|
| 40 |
+
|
| 41 |
+
def sample(self, prefer_hard: bool = False) -> dict:
|
| 42 |
+
if prefer_hard:
|
| 43 |
+
weights = [3 if c["complexity"]=="high" else 2 if c["complexity"]=="medium" else 1 for c in self.pool]
|
| 44 |
+
return random.choices(self.pool, weights=weights, k=1)[0]
|
| 45 |
+
return random.choice(self.pool)
|
| 46 |
+
|
| 47 |
+
def sample_batch(self, n: int = 20) -> list[dict]:
|
| 48 |
+
"""Sample n cases for V2 network batch."""
|
| 49 |
+
return random.choices(self.pool, k=n)
|
| 50 |
+
|
| 51 |
+
def get_cache(self, case_id: str) -> dict:
|
| 52 |
+
return self._cache.get(case_id, {})
|
| 53 |
+
|
| 54 |
+
def set_train(self): self._mode = "train"
|
| 55 |
+
def set_eval(self): self._mode = "eval"
|
| 56 |
+
def __len__(self): return len(self.pool)
|
cace_env/inference.py
ADDED
|
@@ -0,0 +1,316 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
inference.py
|
| 3 |
+
Run inference against the CACE OpenEnv environment and plot reward curves.
|
| 4 |
+
|
| 5 |
+
Usage:
|
| 6 |
+
python inference.py
|
| 7 |
+
python inference.py --episodes 20 --model Sannidhay/cace-grpo-model
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
import os, json, argparse, time
|
| 11 |
+
import requests
|
| 12 |
+
import matplotlib.pyplot as plt
|
| 13 |
+
import matplotlib.gridspec as gridspec
|
| 14 |
+
import numpy as np
|
| 15 |
+
from datetime import datetime
|
| 16 |
+
|
| 17 |
+
# ── Config ────────────────────────────────────────────────────────────────────
|
| 18 |
+
|
| 19 |
+
ENV_URL = os.environ.get("ENV_BASE_URL", "https://sannidhay-cace-env.hf.space")
|
| 20 |
+
HF_TOKEN = os.environ.get("HF_TOKEN", "")
|
| 21 |
+
|
| 22 |
+
ACTION_MAP = {
|
| 23 |
+
0: "ALLOW", 1: "REMOVE", 2: "ALLOW_WITH_LABEL",
|
| 24 |
+
3: "ESCALATE", 4: "RESTRICT_DISTRIBUTION",
|
| 25 |
+
}
|
| 26 |
+
ACTION_COLORS = {
|
| 27 |
+
"ALLOW": "#2ecc71", "REMOVE": "#e74c3c",
|
| 28 |
+
"ALLOW_WITH_LABEL": "#f39c12", "ESCALATE": "#9b59b6",
|
| 29 |
+
"RESTRICT_DISTRIBUTION": "#3498db",
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
# ── Simple LLM Decision Agent ─────────────────────────────────────────────────
|
| 33 |
+
|
| 34 |
+
def get_decision_from_model(observation: str, model: str = None) -> tuple[str, int]:
|
| 35 |
+
"""
|
| 36 |
+
Get moderation decision from model.
|
| 37 |
+
Uses SFT/GRPO model if available, falls back to rule-based.
|
| 38 |
+
"""
|
| 39 |
+
obs_upper = observation.upper()
|
| 40 |
+
|
| 41 |
+
# Rule-based fallback (works without GPU)
|
| 42 |
+
if "REMOVE" in obs_upper and ("HATE" in obs_upper or "VIOLENCE" in obs_upper or "HARM" in obs_upper):
|
| 43 |
+
if "CULTURAL" in obs_upper and "LEGITIMATE" in obs_upper:
|
| 44 |
+
return "ESCALATE", 3
|
| 45 |
+
return "REMOVE", 1
|
| 46 |
+
elif "ALLOW" in obs_upper and "CULTURAL" in obs_upper:
|
| 47 |
+
return "ALLOW", 0
|
| 48 |
+
elif "HIGH" in obs_upper and "COMPLEX" in obs_upper:
|
| 49 |
+
return "ESCALATE", 3
|
| 50 |
+
else:
|
| 51 |
+
return "ALLOW", 0
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
# ── OpenEnv client ────────────────────────────────────────────────────────────
|
| 55 |
+
|
| 56 |
+
class CACEClient:
|
| 57 |
+
def __init__(self, base_url: str):
|
| 58 |
+
self.base_url = base_url.rstrip("/")
|
| 59 |
+
self.session = requests.Session()
|
| 60 |
+
if HF_TOKEN:
|
| 61 |
+
self.session.headers["Authorization"] = f"Bearer {HF_TOKEN}"
|
| 62 |
+
|
| 63 |
+
def health(self) -> bool:
|
| 64 |
+
try:
|
| 65 |
+
r = self.session.get(f"{self.base_url}/health", timeout=15)
|
| 66 |
+
return r.status_code == 200 and r.json().get("status") == "ok"
|
| 67 |
+
except Exception as e:
|
| 68 |
+
print(f"[DEBUG] Health check failed: {e}")
|
| 69 |
+
return False
|
| 70 |
+
|
| 71 |
+
def wait_until_ready(self, max_wait: int = 120):
|
| 72 |
+
print(f"[DEBUG] Waiting for server at {self.base_url} ...")
|
| 73 |
+
for i in range(max_wait):
|
| 74 |
+
if self.health():
|
| 75 |
+
print(f"[DEBUG] Server is ready!")
|
| 76 |
+
return True
|
| 77 |
+
time.sleep(1)
|
| 78 |
+
if i % 10 == 9:
|
| 79 |
+
print(f"[DEBUG] Still waiting... ({i+1}s)")
|
| 80 |
+
raise RuntimeError(f"Server not ready after {max_wait}s")
|
| 81 |
+
|
| 82 |
+
def reset(self) -> str:
|
| 83 |
+
r = self.session.post(f"{self.base_url}/reset", timeout=60)
|
| 84 |
+
r.raise_for_status()
|
| 85 |
+
obs_r = self.session.get(f"{self.base_url}/observation", timeout=30)
|
| 86 |
+
return obs_r.json()["observation"]
|
| 87 |
+
|
| 88 |
+
def step(self, action_int: int) -> dict:
|
| 89 |
+
r = self.session.post(
|
| 90 |
+
f"{self.base_url}/step",
|
| 91 |
+
json={"action_int": action_int},
|
| 92 |
+
timeout=30,
|
| 93 |
+
)
|
| 94 |
+
r.raise_for_status()
|
| 95 |
+
return r.json()
|
| 96 |
+
|
| 97 |
+
def info(self) -> dict:
|
| 98 |
+
r = self.session.get(f"{self.base_url}/info", timeout=10)
|
| 99 |
+
return r.json()
|
| 100 |
+
|
| 101 |
+
def metrics(self) -> dict:
|
| 102 |
+
r = self.session.get(f"{self.base_url}/metrics", timeout=10)
|
| 103 |
+
return r.json()
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
# ── Run episodes ──────────────────────────────────────────────────────────────
|
| 107 |
+
|
| 108 |
+
def run_episodes(env: CACEClient, n_episodes: int, model: str = None) -> list[dict]:
|
| 109 |
+
results = []
|
| 110 |
+
|
| 111 |
+
for ep in range(1, n_episodes + 1):
|
| 112 |
+
obs = env.reset()
|
| 113 |
+
|
| 114 |
+
decision, action_int = get_decision_from_model(obs, model)
|
| 115 |
+
result = env.step(action_int)
|
| 116 |
+
|
| 117 |
+
reward = result.get("reward", 0.0)
|
| 118 |
+
done = result.get("done", True)
|
| 119 |
+
info = result.get("info", {})
|
| 120 |
+
ground_truth= info.get("ground_truth", "?")
|
| 121 |
+
correct = info.get("correct", decision == ground_truth)
|
| 122 |
+
language = info.get("language", "Unknown")
|
| 123 |
+
region = info.get("region", "Unknown")
|
| 124 |
+
breakdown = info.get("reward_breakdown", {})
|
| 125 |
+
|
| 126 |
+
ep_result = {
|
| 127 |
+
"episode": ep,
|
| 128 |
+
"decision": decision,
|
| 129 |
+
"ground_truth": ground_truth,
|
| 130 |
+
"reward": float(reward),
|
| 131 |
+
"correct": correct,
|
| 132 |
+
"done": done,
|
| 133 |
+
"language": language,
|
| 134 |
+
"region": region,
|
| 135 |
+
"t1_cultural": breakdown.get("track1_cultural", 0),
|
| 136 |
+
"t2_harm": breakdown.get("track2_harm", 0),
|
| 137 |
+
"t3_policy": breakdown.get("track3_policy", 0),
|
| 138 |
+
}
|
| 139 |
+
results.append(ep_result)
|
| 140 |
+
|
| 141 |
+
status = "✓" if correct else "✗"
|
| 142 |
+
print(
|
| 143 |
+
f"[STEP] ep={ep} decision={decision} gt={ground_truth} "
|
| 144 |
+
f"reward={reward:+.3f} correct={str(correct).lower()} {status} "
|
| 145 |
+
f"lang={language}"
|
| 146 |
+
)
|
| 147 |
+
|
| 148 |
+
return results
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
# ── Plotting ──────────────────────────────────────────────────────────────────
|
| 152 |
+
|
| 153 |
+
def plot_results(results: list[dict], save_path: str = "cace_inference_results.png"):
|
| 154 |
+
episodes = [r["episode"] for r in results]
|
| 155 |
+
rewards = [r["reward"] for r in results]
|
| 156 |
+
correct = [r["correct"] for r in results]
|
| 157 |
+
decisions= [r["decision"] for r in results]
|
| 158 |
+
|
| 159 |
+
# Running averages
|
| 160 |
+
window = min(5, len(results))
|
| 161 |
+
avg_rewards = np.convolve(rewards, np.ones(window)/window, mode='valid')
|
| 162 |
+
avg_correct = np.convolve([1 if c else 0 for c in correct], np.ones(window)/window, mode='valid')
|
| 163 |
+
|
| 164 |
+
fig = plt.figure(figsize=(16, 10))
|
| 165 |
+
fig.patch.set_facecolor('#0f1117')
|
| 166 |
+
gs = gridspec.GridSpec(2, 3, figure=fig, hspace=0.4, wspace=0.35)
|
| 167 |
+
|
| 168 |
+
GOLD = "#FFD700"
|
| 169 |
+
GREEN = "#2ecc71"
|
| 170 |
+
RED = "#e74c3c"
|
| 171 |
+
BLUE = "#3498db"
|
| 172 |
+
PURPLE = "#9b59b6"
|
| 173 |
+
BG = '#0f1117'
|
| 174 |
+
PANEL = '#1a1d2e'
|
| 175 |
+
|
| 176 |
+
def style_ax(ax, title):
|
| 177 |
+
ax.set_facecolor(PANEL)
|
| 178 |
+
ax.set_title(title, color=GOLD, fontsize=11, fontweight='bold', pad=8)
|
| 179 |
+
ax.tick_params(colors='white')
|
| 180 |
+
ax.xaxis.label.set_color('white')
|
| 181 |
+
ax.yaxis.label.set_color('white')
|
| 182 |
+
for spine in ax.spines.values():
|
| 183 |
+
spine.set_edgecolor('#333')
|
| 184 |
+
|
| 185 |
+
# ── Plot 1: Reward per episode ────────────────────────────────────────────
|
| 186 |
+
ax1 = fig.add_subplot(gs[0, :2])
|
| 187 |
+
style_ax(ax1, "Reward per Episode")
|
| 188 |
+
colors = [GREEN if r > 0 else RED for r in rewards]
|
| 189 |
+
ax1.bar(episodes, rewards, color=colors, alpha=0.7, label="Episode reward")
|
| 190 |
+
if len(avg_rewards) > 0:
|
| 191 |
+
x_avg = episodes[window-1:]
|
| 192 |
+
ax1.plot(x_avg, avg_rewards, color=GOLD, linewidth=2.5,
|
| 193 |
+
label=f"Rolling avg (n={window})", zorder=5)
|
| 194 |
+
ax1.axhline(0, color='white', linewidth=0.5, linestyle='--', alpha=0.3)
|
| 195 |
+
ax1.set_xlabel("Episode")
|
| 196 |
+
ax1.set_ylabel("Reward")
|
| 197 |
+
ax1.legend(facecolor=PANEL, labelcolor='white', fontsize=9)
|
| 198 |
+
ax1.set_ylim(-1.2, 1.2)
|
| 199 |
+
|
| 200 |
+
# ── Plot 2: Accuracy ──────────────────────────────────────────────────────
|
| 201 |
+
ax2 = fig.add_subplot(gs[0, 2])
|
| 202 |
+
style_ax(ax2, "Accuracy")
|
| 203 |
+
accuracy = sum(correct) / len(correct)
|
| 204 |
+
ax2.pie(
|
| 205 |
+
[accuracy, 1-accuracy],
|
| 206 |
+
labels=["Correct", "Wrong"],
|
| 207 |
+
colors=[GREEN, RED],
|
| 208 |
+
autopct='%1.0f%%',
|
| 209 |
+
textprops={'color': 'white', 'fontsize': 11},
|
| 210 |
+
startangle=90,
|
| 211 |
+
)
|
| 212 |
+
ax2.set_title(f"Accuracy\n{accuracy*100:.1f}% ({sum(correct)}/{len(correct)})",
|
| 213 |
+
color=GOLD, fontsize=11, fontweight='bold')
|
| 214 |
+
|
| 215 |
+
# ── Plot 3: Decision distribution ─────────────────────────────────────────
|
| 216 |
+
ax3 = fig.add_subplot(gs[1, 0])
|
| 217 |
+
style_ax(ax3, "Decision Distribution")
|
| 218 |
+
from collections import Counter
|
| 219 |
+
dec_counts = Counter(decisions)
|
| 220 |
+
labels = list(dec_counts.keys())
|
| 221 |
+
vals = list(dec_counts.values())
|
| 222 |
+
bar_colors = [ACTION_COLORS.get(l, BLUE) for l in labels]
|
| 223 |
+
bars = ax3.bar(labels, vals, color=bar_colors, alpha=0.85)
|
| 224 |
+
ax3.set_xticklabels(labels, rotation=20, ha='right', fontsize=8)
|
| 225 |
+
for bar, val in zip(bars, vals):
|
| 226 |
+
ax3.text(bar.get_x() + bar.get_width()/2, bar.get_height() + 0.1,
|
| 227 |
+
str(val), ha='center', color='white', fontsize=9)
|
| 228 |
+
ax3.set_ylabel("Count")
|
| 229 |
+
|
| 230 |
+
# ── Plot 4: Three-track reward breakdown ──────────────────────────────────
|
| 231 |
+
ax4 = fig.add_subplot(gs[1, 1])
|
| 232 |
+
style_ax(ax4, "3-Track Reward Breakdown (avg)")
|
| 233 |
+
t1_avg = np.mean([r["t1_cultural"] for r in results])
|
| 234 |
+
t2_avg = np.mean([r["t2_harm"] for r in results])
|
| 235 |
+
t3_avg = np.mean([r["t3_policy"] for r in results])
|
| 236 |
+
tracks = ["Cultural\n(40%)", "Harm\n(35%)", "Policy\n(25%)"]
|
| 237 |
+
vals = [t1_avg, t2_avg, t3_avg]
|
| 238 |
+
bar_colors2 = [GOLD, PURPLE, BLUE]
|
| 239 |
+
bars2 = ax4.bar(tracks, vals, color=bar_colors2, alpha=0.85)
|
| 240 |
+
for bar, val in zip(bars2, vals):
|
| 241 |
+
ax4.text(bar.get_x() + bar.get_width()/2, bar.get_height() + 0.5,
|
| 242 |
+
f"{val:.1f}", ha='center', color='white', fontsize=10)
|
| 243 |
+
ax4.set_ylabel("Score (0-100)")
|
| 244 |
+
ax4.set_ylim(0, 110)
|
| 245 |
+
|
| 246 |
+
# ── Plot 5: Running accuracy ───────────────────────────────────────────────
|
| 247 |
+
ax5 = fig.add_subplot(gs[1, 2])
|
| 248 |
+
style_ax(ax5, "Running Accuracy")
|
| 249 |
+
if len(avg_correct) > 0:
|
| 250 |
+
x_acc = episodes[window-1:]
|
| 251 |
+
ax5.plot(x_acc, avg_correct * 100, color=GREEN, linewidth=2.5)
|
| 252 |
+
ax5.fill_between(x_acc, avg_correct * 100, alpha=0.2, color=GREEN)
|
| 253 |
+
ax5.axhline(50, color='white', linewidth=0.5, linestyle='--', alpha=0.3)
|
| 254 |
+
ax5.set_xlabel("Episode")
|
| 255 |
+
ax5.set_ylabel("Accuracy (%)")
|
| 256 |
+
ax5.set_ylim(0, 105)
|
| 257 |
+
|
| 258 |
+
# ── Title ──────────────────────────────────────────────────────────────────
|
| 259 |
+
fig.suptitle(
|
| 260 |
+
"CACE — Cultural Context Arbitration Environment\nInference Results",
|
| 261 |
+
color=GOLD, fontsize=14, fontweight='bold', y=1.01
|
| 262 |
+
)
|
| 263 |
+
|
| 264 |
+
plt.savefig(save_path, dpi=150, bbox_inches='tight', facecolor=BG)
|
| 265 |
+
print(f"\n[PLOT] Saved → {save_path}")
|
| 266 |
+
|
| 267 |
+
# Also save JSON
|
| 268 |
+
json_path = save_path.replace(".png", ".json")
|
| 269 |
+
with open(json_path, "w") as f:
|
| 270 |
+
json.dump({
|
| 271 |
+
"summary": {
|
| 272 |
+
"episodes": len(results),
|
| 273 |
+
"accuracy": accuracy,
|
| 274 |
+
"avg_reward": float(np.mean(rewards)),
|
| 275 |
+
"total_correct": int(sum(correct)),
|
| 276 |
+
},
|
| 277 |
+
"episodes": results,
|
| 278 |
+
}, f, indent=2)
|
| 279 |
+
print(f"[DATA] Saved → {json_path}")
|
| 280 |
+
plt.show()
|
| 281 |
+
|
| 282 |
+
|
| 283 |
+
# ── Main ──────────────────────────────────────────────────────────────────────
|
| 284 |
+
|
| 285 |
+
def main():
|
| 286 |
+
parser = argparse.ArgumentParser()
|
| 287 |
+
parser.add_argument("--episodes", type=int, default=20)
|
| 288 |
+
parser.add_argument("--model", default=None, help="HF model repo for decisions")
|
| 289 |
+
parser.add_argument("--env-url", default=ENV_URL)
|
| 290 |
+
parser.add_argument("--output", default="cace_inference_results.png")
|
| 291 |
+
args = parser.parse_args()
|
| 292 |
+
|
| 293 |
+
env = CACEClient(args.env_url)
|
| 294 |
+
env.wait_until_ready()
|
| 295 |
+
|
| 296 |
+
info = env.info()
|
| 297 |
+
print(f"\n[START] env={info.get('name','cace')} model={args.model or 'rule-based'}")
|
| 298 |
+
print(f" action_space={info.get('action_space',{}).get('n')} reward_range={info.get('reward_range')}\n")
|
| 299 |
+
|
| 300 |
+
results = run_episodes(env, args.episodes, args.model)
|
| 301 |
+
|
| 302 |
+
# Summary
|
| 303 |
+
rewards = [r["reward"] for r in results]
|
| 304 |
+
accuracy = sum(r["correct"] for r in results) / len(results)
|
| 305 |
+
print(f"\n[END] episodes={len(results)} accuracy={accuracy:.3f} "
|
| 306 |
+
f"avg_reward={np.mean(rewards):.3f} "
|
| 307 |
+
f"rewards={','.join(f'{r:.2f}' for r in rewards)}")
|
| 308 |
+
|
| 309 |
+
metrics = env.metrics()
|
| 310 |
+
print(f"[METRICS] {metrics}")
|
| 311 |
+
|
| 312 |
+
plot_results(results, args.output)
|
| 313 |
+
|
| 314 |
+
|
| 315 |
+
if __name__ == "__main__":
|
| 316 |
+
main()
|
cace_env/models.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
cace_env/models.py
|
| 3 |
+
Pydantic models for CACE — Action, Observation, State.
|
| 4 |
+
All inherit from openenv.core base classes as required by OpenEnv spec.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from typing import Any, Dict, List, Optional
|
| 8 |
+
from openenv.core import Action, Observation, State
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
# ── Action ────────────────────────────────────────────────────────────────────
|
| 12 |
+
|
| 13 |
+
class CACEAction(Action):
|
| 14 |
+
"""
|
| 15 |
+
The Decision Agent's moderation action.
|
| 16 |
+
|
| 17 |
+
action_int: int in [0, 4]
|
| 18 |
+
0 = ALLOW — content is legitimate
|
| 19 |
+
1 = REMOVE — content violates policy
|
| 20 |
+
2 = ALLOW_WITH_LABEL — allow with warning label
|
| 21 |
+
3 = ESCALATE — refer to human reviewer
|
| 22 |
+
4 = RESTRICT_DISTRIBUTION — limit reach without removal
|
| 23 |
+
|
| 24 |
+
V2 only: selected_indices
|
| 25 |
+
Which posts to review (from 20 seeded posts, pick 8).
|
| 26 |
+
Only used when mode="v2" and prioritisation is active.
|
| 27 |
+
"""
|
| 28 |
+
action_int: int
|
| 29 |
+
selected_indices: Optional[List[int]] = None # V2 prioritisation
|
| 30 |
+
|
| 31 |
+
@property
|
| 32 |
+
def action_str(self) -> str:
|
| 33 |
+
return {
|
| 34 |
+
0: "ALLOW",
|
| 35 |
+
1: "REMOVE",
|
| 36 |
+
2: "ALLOW_WITH_LABEL",
|
| 37 |
+
3: "ESCALATE",
|
| 38 |
+
4: "RESTRICT_DISTRIBUTION",
|
| 39 |
+
}.get(self.action_int, "ESCALATE")
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
# ── Observation ───────────────────────────────────────────────────────────────
|
| 43 |
+
|
| 44 |
+
class CACEObservation(Observation):
|
| 45 |
+
"""
|
| 46 |
+
What the Decision Agent sees after reset() or step().
|
| 47 |
+
Inherits: done, reward, metadata from openenv.core.Observation
|
| 48 |
+
|
| 49 |
+
observation: The full enriched case prompt (post + 4-agent deliberation)
|
| 50 |
+
case_id: Unique Oversight Board case identifier
|
| 51 |
+
language: Detected language of the post
|
| 52 |
+
region: Geographic region
|
| 53 |
+
complexity: low | medium | high
|
| 54 |
+
mode: v1 (single case) | v2 (network batch)
|
| 55 |
+
|
| 56 |
+
V2 only:
|
| 57 |
+
batch_posts: List of 20 post summaries with spread signals
|
| 58 |
+
"""
|
| 59 |
+
observation: str
|
| 60 |
+
case_id: str
|
| 61 |
+
language: str = "Unknown"
|
| 62 |
+
region: str = "Unknown"
|
| 63 |
+
complexity: str = "medium"
|
| 64 |
+
culture_flag: bool = False
|
| 65 |
+
mode: str = "unified" # always unified
|
| 66 |
+
|
| 67 |
+
# Network fields
|
| 68 |
+
batch_posts: Optional[List[Dict[str, Any]]] = None
|
| 69 |
+
network_step: Optional[int] = None
|
| 70 |
+
|
| 71 |
+
# Reward breakdown (populated after step())
|
| 72 |
+
reward_breakdown: Optional[Dict[str, Any]] = None
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
# ── State ─────────────────────────────────────────────────────────────────────
|
| 76 |
+
|
| 77 |
+
class CACEState(State):
|
| 78 |
+
"""
|
| 79 |
+
Internal environment state — visible via state() endpoint.
|
| 80 |
+
Inherits: episode_id, step_count from openenv.core.State
|
| 81 |
+
|
| 82 |
+
Used for debugging, demo rendering, and training metrics.
|
| 83 |
+
"""
|
| 84 |
+
case_id: str = ""
|
| 85 |
+
post_text: str = ""
|
| 86 |
+
language: str = "Unknown"
|
| 87 |
+
region: str = "Unknown"
|
| 88 |
+
policy_clause: str = "Unknown"
|
| 89 |
+
cultural_brief: str = ""
|
| 90 |
+
challenge_brief: str = ""
|
| 91 |
+
policy_anchor: str = ""
|
| 92 |
+
ground_truth: str = ""
|
| 93 |
+
complexity: str = "medium"
|
| 94 |
+
mode: str = "unified"
|
| 95 |
+
total_episodes: int = 0
|
| 96 |
+
correct_decisions: int = 0
|
| 97 |
+
accuracy: float = 0.0
|
| 98 |
+
avg_reward_last_50: float = 0.0
|
| 99 |
+
|
| 100 |
+
# V2 network state
|
| 101 |
+
network_nodes: Optional[int] = None
|
| 102 |
+
network_edges: Optional[int] = None
|
| 103 |
+
posts_in_batch: Optional[int] = None
|
| 104 |
+
posts_selected: Optional[int] = None
|
cace_env/pipeline.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
cace_env/pipeline.py
|
| 3 |
+
4-agent deliberation pipeline.
|
| 4 |
+
Uses pipeline_cache.json first — only calls LLM if cache miss.
|
| 5 |
+
If no API keys set, returns defaults gracefully (no crash).
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import os, json, re
|
| 9 |
+
|
| 10 |
+
_groq_client = None
|
| 11 |
+
_azure_client = None
|
| 12 |
+
|
| 13 |
+
def _groq():
|
| 14 |
+
global _groq_client
|
| 15 |
+
if _groq_client is None:
|
| 16 |
+
from groq import Groq
|
| 17 |
+
_groq_client = Groq(api_key=os.environ["GROQ_API_KEY"])
|
| 18 |
+
return _groq_client
|
| 19 |
+
|
| 20 |
+
def _azure():
|
| 21 |
+
global _azure_client
|
| 22 |
+
if _azure_client is None:
|
| 23 |
+
import openai
|
| 24 |
+
_azure_client = openai.AzureOpenAI(
|
| 25 |
+
api_key =os.environ.get("AZURE_OPENAI_KEY",""),
|
| 26 |
+
api_version ="2023-07-01-preview",
|
| 27 |
+
azure_endpoint=os.environ.get("AZURE_OPENAI_ENDPOINT","https://daplatformai.openai.azure.com/"),
|
| 28 |
+
)
|
| 29 |
+
return _azure_client
|
| 30 |
+
|
| 31 |
+
def llm(system: str, user: str) -> str:
|
| 32 |
+
"""Call LLM — Groq first, Azure fallback. Returns empty string on failure."""
|
| 33 |
+
msgs = [{"role":"system","content":system},{"role":"user","content":user}]
|
| 34 |
+
|
| 35 |
+
if os.environ.get("GROQ_API_KEY"):
|
| 36 |
+
try:
|
| 37 |
+
r = _groq().chat.completions.create(
|
| 38 |
+
model="llama-3.3-70b-versatile", messages=msgs,
|
| 39 |
+
temperature=0.3, max_tokens=512,
|
| 40 |
+
)
|
| 41 |
+
return r.choices[0].message.content.strip()
|
| 42 |
+
except Exception:
|
| 43 |
+
pass
|
| 44 |
+
|
| 45 |
+
if os.environ.get("AZURE_OPENAI_KEY"):
|
| 46 |
+
try:
|
| 47 |
+
r = _azure().chat.completions.create(
|
| 48 |
+
model="gpt-35-turbo", messages=msgs,
|
| 49 |
+
temperature=0.3, max_tokens=512,
|
| 50 |
+
)
|
| 51 |
+
result = r.choices[0].message.content
|
| 52 |
+
if result:
|
| 53 |
+
return result.strip()
|
| 54 |
+
except Exception:
|
| 55 |
+
pass
|
| 56 |
+
|
| 57 |
+
return "" # graceful fallback — no crash
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
INTAKE_SYS = """Analyse a social media post and return ONLY valid JSON:
|
| 61 |
+
{"language":"...","region":"...","policy_clause":"..."}
|
| 62 |
+
region: one of: North America, Latin America, Europe, Eastern Europe, Middle East,
|
| 63 |
+
North Africa, West Africa, East Africa, South Asia, Southeast Asia, East Asia, Unknown.
|
| 64 |
+
policy_clause: exact Meta Community Standard name. No other text."""
|
| 65 |
+
|
| 66 |
+
CULTURAL_SYS = """You are a Cultural Context Analyst for Global South digital discourse.
|
| 67 |
+
Provide the most charitable culturally-informed interpretation in 2-4 sentences."""
|
| 68 |
+
|
| 69 |
+
CHALLENGE_SYS = """You are an Adversarial Policy Challenger for Trust & Safety.
|
| 70 |
+
Stress-test the cultural argument in 2-4 sentences."""
|
| 71 |
+
|
| 72 |
+
POLICY_SYS = """You are a Policy Alignment Anchor for content moderation.
|
| 73 |
+
State: (1) what the Meta Community Standard says, (2) exceptions, (3) decision framework. 3-5 sentences."""
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def enrich(post_text: str, cache: dict = None, case_id: str = None) -> dict:
|
| 77 |
+
"""
|
| 78 |
+
Run 4-agent deliberation pipeline.
|
| 79 |
+
Cache-first: if case_id in cache with cultural_brief, return instantly.
|
| 80 |
+
If no API keys available, returns safe defaults — no crash.
|
| 81 |
+
"""
|
| 82 |
+
# Cache hit — instant return
|
| 83 |
+
if cache and case_id and case_id in cache:
|
| 84 |
+
cached = cache[case_id]
|
| 85 |
+
if isinstance(cached, dict) and cached.get("cultural_brief"):
|
| 86 |
+
return cached
|
| 87 |
+
|
| 88 |
+
state = {
|
| 89 |
+
"post_text": post_text,
|
| 90 |
+
"language": "Unknown",
|
| 91 |
+
"region": "Unknown",
|
| 92 |
+
"policy_clause": "Unknown",
|
| 93 |
+
"cultural_brief": "",
|
| 94 |
+
"challenge_brief":"",
|
| 95 |
+
"policy_anchor": "",
|
| 96 |
+
"similar_cases": [],
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
# If no API keys, return safe defaults immediately
|
| 100 |
+
has_keys = os.environ.get("GROQ_API_KEY") or os.environ.get("AZURE_OPENAI_KEY")
|
| 101 |
+
if not has_keys:
|
| 102 |
+
state["cultural_brief"] = "Cultural context analysis unavailable (no API key configured)."
|
| 103 |
+
state["challenge_brief"] = "Challenge analysis unavailable."
|
| 104 |
+
state["policy_anchor"] = "Policy anchor unavailable."
|
| 105 |
+
return state
|
| 106 |
+
|
| 107 |
+
# Agent 1: Intake
|
| 108 |
+
try:
|
| 109 |
+
raw = llm(INTAKE_SYS, f"Analyse: {post_text[:500]}")
|
| 110 |
+
if raw:
|
| 111 |
+
raw = re.sub(r"^```json\s*|```$", "", raw.strip())
|
| 112 |
+
parsed = json.loads(raw)
|
| 113 |
+
state["language"] = parsed.get("language", "Unknown")
|
| 114 |
+
state["region"] = parsed.get("region", "Unknown")
|
| 115 |
+
state["policy_clause"] = parsed.get("policy_clause", "Unknown")
|
| 116 |
+
except Exception:
|
| 117 |
+
pass
|
| 118 |
+
|
| 119 |
+
# Agent 2: Cultural
|
| 120 |
+
result = llm(CULTURAL_SYS,
|
| 121 |
+
f"Post: {post_text[:400]}\nLanguage: {state['language']}\nRegion: {state['region']}")
|
| 122 |
+
state["cultural_brief"] = result or "Cultural context analysis unavailable."
|
| 123 |
+
|
| 124 |
+
# Agent 3: Challenge
|
| 125 |
+
result = llm(CHALLENGE_SYS,
|
| 126 |
+
f"Post: {post_text[:400]}\nCultural argument: {state['cultural_brief']}")
|
| 127 |
+
state["challenge_brief"] = result or "Challenge analysis unavailable."
|
| 128 |
+
|
| 129 |
+
# Agent 4: Policy
|
| 130 |
+
result = llm(POLICY_SYS,
|
| 131 |
+
f"Policy: {state['policy_clause']}\nCultural: {state['cultural_brief']}\nChallenge: {state['challenge_brief']}")
|
| 132 |
+
state["policy_anchor"] = result or "Policy anchor unavailable."
|
| 133 |
+
|
| 134 |
+
return state
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def build_observation(state: dict, network_signals: dict = None) -> str:
|
| 138 |
+
base = f"""You are a content moderation decision agent for a global social media platform.
|
| 139 |
+
|
| 140 |
+
===== CASE =====
|
| 141 |
+
POST: {state['post_text']}
|
| 142 |
+
LANGUAGE: {state.get('language','Unknown')} | REGION: {state.get('region','Unknown')}
|
| 143 |
+
RELEVANT POLICY: {state.get('policy_clause','Unknown')}
|
| 144 |
+
|
| 145 |
+
===== DELIBERATION =====
|
| 146 |
+
CULTURAL CONTEXT: {state.get('cultural_brief','Not available.')}
|
| 147 |
+
ADVERSARIAL CHALLENGE: {state.get('challenge_brief','Not available.')}
|
| 148 |
+
POLICY ANCHOR: {state.get('policy_anchor','Not available.')}"""
|
| 149 |
+
|
| 150 |
+
if network_signals:
|
| 151 |
+
base += f"""
|
| 152 |
+
|
| 153 |
+
===== NETWORK SIGNALS =====
|
| 154 |
+
Share Velocity: {network_signals.get('share_velocity',0):.2f} | Network Reach: {network_signals.get('network_reach',0):.2f}
|
| 155 |
+
Position: {network_signals.get('network_position','unknown')} | Harm Probability: {network_signals.get('harm_probability',0):.2f}"""
|
| 156 |
+
|
| 157 |
+
base += """
|
| 158 |
+
|
| 159 |
+
===== DECISION =====
|
| 160 |
+
Choose exactly ONE: ALLOW | REMOVE | ALLOW_WITH_LABEL | ESCALATE | RESTRICT_DISTRIBUTION
|
| 161 |
+
Decision:"""
|
| 162 |
+
return base.strip()
|
cace_env/reward.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
cace_env/reward.py
|
| 3 |
+
Three-Track Reward Model for CACE.
|
| 4 |
+
|
| 5 |
+
Track 1 — Cultural Meaning Resolution (weight: 40%)
|
| 6 |
+
Track 2 — Harm Detection Under Context (weight: 35%)
|
| 7 |
+
Track 3 — Policy Calibration + Escalation (weight: 25%)
|
| 8 |
+
Bonus — Network Spread Containment (optional, V2)
|
| 9 |
+
|
| 10 |
+
All scores deterministic — no LLM judge.
|
| 11 |
+
Returns reward in [-1.0, +1.0] for GRPO compatibility.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
ACTION_SEVERITY = {
|
| 15 |
+
"ALLOW": 0, "ALLOW_WITH_LABEL": 1,
|
| 16 |
+
"RESTRICT_DISTRIBUTION": 2, "ESCALATE": 3, "REMOVE": 4,
|
| 17 |
+
}
|
| 18 |
+
ALLOW_OUTCOMES = {"ALLOW", "ALLOW_WITH_LABEL", "RESTRICT_DISTRIBUTION"}
|
| 19 |
+
REMOVE_OUTCOMES = {"REMOVE"}
|
| 20 |
+
|
| 21 |
+
W1, W2, W3 = 0.40, 0.35, 0.25
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _track1(decision: str, ground_truth: str, complexity: str, culture_flag: bool) -> float:
|
| 25 |
+
"""Cultural Meaning Resolution — 0 to 100."""
|
| 26 |
+
score = 70.0 if decision == ground_truth else 0.0
|
| 27 |
+
if decision in REMOVE_OUTCOMES and ground_truth in ALLOW_OUTCOMES:
|
| 28 |
+
score -= 30.0
|
| 29 |
+
if decision == "ALLOW_WITH_LABEL" and ground_truth == "ALLOW":
|
| 30 |
+
score -= 10.0
|
| 31 |
+
mult = 1.2 if (culture_flag and complexity == "high") else 1.0 if complexity == "medium" else 0.8
|
| 32 |
+
score = max(-30.0, min(84.0, score * mult))
|
| 33 |
+
return (score + 30.0) / 1.14
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def _track2(decision: str, ground_truth: str) -> float:
|
| 37 |
+
"""Harm Detection Under Context — 0 to 100."""
|
| 38 |
+
if ground_truth in REMOVE_OUTCOMES:
|
| 39 |
+
if decision in REMOVE_OUTCOMES: score = 70.0
|
| 40 |
+
elif decision in ("ESCALATE", "RESTRICT_DISTRIBUTION"): score = 40.0
|
| 41 |
+
else: score = -100.0
|
| 42 |
+
else:
|
| 43 |
+
score = -20.0 if decision in REMOVE_OUTCOMES else 30.0
|
| 44 |
+
score = max(-100.0, min(70.0, score))
|
| 45 |
+
return (score + 100.0) / 1.70
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def _track3(decision: str, ground_truth: str, complexity: str) -> float:
|
| 49 |
+
"""Policy Calibration + Escalation Discipline — 0 to 100."""
|
| 50 |
+
if decision == ground_truth:
|
| 51 |
+
score = 60.0
|
| 52 |
+
else:
|
| 53 |
+
diff = abs(ACTION_SEVERITY.get(decision, 2) - ACTION_SEVERITY.get(ground_truth, 2))
|
| 54 |
+
score = 25.0 if diff == 1 else 10.0 if diff == 2 else 0.0
|
| 55 |
+
if decision == "ESCALATE":
|
| 56 |
+
score += 40.0 if complexity == "high" else 15.0 if complexity == "medium" else -40.0
|
| 57 |
+
score = max(-40.0, min(100.0, score))
|
| 58 |
+
return (score + 40.0) / 1.40
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def _spread_bonus(decision: str, ground_truth: str, share_velocity: float, network_reach: float) -> float:
|
| 62 |
+
"""Network spread containment bonus — only active in V2."""
|
| 63 |
+
if share_velocity <= 0 or network_reach <= 0:
|
| 64 |
+
return 0.0
|
| 65 |
+
bonus = 0.0
|
| 66 |
+
if decision in REMOVE_OUTCOMES and ground_truth in REMOVE_OUTCOMES:
|
| 67 |
+
bonus += 0.3 * share_velocity * network_reach
|
| 68 |
+
if decision in ALLOW_OUTCOMES and ground_truth in REMOVE_OUTCOMES:
|
| 69 |
+
bonus -= 0.5 * share_velocity * network_reach
|
| 70 |
+
return max(-0.5, min(0.5, bonus))
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def compute_reward(
|
| 74 |
+
decision: str,
|
| 75 |
+
ground_truth: str,
|
| 76 |
+
complexity: str,
|
| 77 |
+
culture_flag: bool = False,
|
| 78 |
+
share_velocity: float = 0.0,
|
| 79 |
+
network_reach: float = 0.0,
|
| 80 |
+
) -> tuple[float, dict]:
|
| 81 |
+
"""
|
| 82 |
+
Compute three-track reward.
|
| 83 |
+
|
| 84 |
+
Returns:
|
| 85 |
+
(combined_reward, breakdown)
|
| 86 |
+
combined_reward: float in [-1.0, +1.0]
|
| 87 |
+
breakdown: per-track scores for logging
|
| 88 |
+
"""
|
| 89 |
+
t1 = _track1(decision, ground_truth, complexity, culture_flag)
|
| 90 |
+
t2 = _track2(decision, ground_truth)
|
| 91 |
+
t3 = _track3(decision, ground_truth, complexity)
|
| 92 |
+
combined = (W1 * t1) + (W2 * t2) + (W3 * t3)
|
| 93 |
+
spread = _spread_bonus(decision, ground_truth, share_velocity, network_reach)
|
| 94 |
+
reward = max(-1.0, min(1.0, (combined / 50.0) - 1.0 + spread))
|
| 95 |
+
|
| 96 |
+
breakdown = {
|
| 97 |
+
"decision": decision, "ground_truth": ground_truth,
|
| 98 |
+
"correct": decision == ground_truth, "complexity": complexity,
|
| 99 |
+
"track1_cultural": round(t1, 2),
|
| 100 |
+
"track2_harm": round(t2, 2),
|
| 101 |
+
"track3_policy": round(t3, 2),
|
| 102 |
+
"combined_0_100": round(combined, 2),
|
| 103 |
+
"spread_bonus": round(spread, 4),
|
| 104 |
+
"combined_reward": round(reward, 4),
|
| 105 |
+
}
|
| 106 |
+
return reward, breakdown
|
cace_env/server.py
ADDED
|
@@ -0,0 +1,409 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
cace_env/server.py
|
| 3 |
+
CACEEnvironment — single unified environment.
|
| 4 |
+
|
| 5 |
+
One episode flow handles everything:
|
| 6 |
+
reset() → seed posts on network → IC spread → enrich all → agent picks + decides
|
| 7 |
+
step() → three-track reward + spread bonus
|
| 8 |
+
|
| 9 |
+
No mode switching. No V1/V2 branching. One clean class.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
import os, uuid, random
|
| 13 |
+
from typing import Optional, List
|
| 14 |
+
|
| 15 |
+
from openenv.core import Environment, create_fastapi_app
|
| 16 |
+
|
| 17 |
+
from cace_env.models import CACEAction, CACEObservation, CACEState
|
| 18 |
+
from cace_env.dataset import CaseDataset, ACTION_MAP
|
| 19 |
+
from cace_env.pipeline import enrich, build_observation
|
| 20 |
+
from cace_env.reward import compute_reward
|
| 21 |
+
|
| 22 |
+
# ── Network (optional — graceful fallback if networkx not installed) ──────────
|
| 23 |
+
try:
|
| 24 |
+
import networkx as nx
|
| 25 |
+
_HAS_NX = True
|
| 26 |
+
except ImportError:
|
| 27 |
+
_HAS_NX = False
|
| 28 |
+
|
| 29 |
+
DATASET_PATH = os.environ.get("DATASET_PATH", "data/all_cases.json")
|
| 30 |
+
BATCH_SIZE = int(os.environ.get("BATCH_SIZE", "20")) # posts per episode
|
| 31 |
+
REVIEW_BUDGET = int(os.environ.get("REVIEW_BUDGET", "8")) # posts agent must review
|
| 32 |
+
NETWORK_STEPS = int(os.environ.get("NETWORK_STEPS", "3")) # IC cascade steps
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
# ── Network helpers ───────────────────────────────────────────────────────────
|
| 36 |
+
|
| 37 |
+
def _build_graph() -> Optional[object]:
|
| 38 |
+
"""
|
| 39 |
+
Watts-Strogatz small-world graph approximating SNAP Facebook ego topology.
|
| 40 |
+
~1000 nodes, avg degree 6, rewiring 0.1.
|
| 41 |
+
Falls back to None if networkx not available.
|
| 42 |
+
"""
|
| 43 |
+
if not _HAS_NX:
|
| 44 |
+
return None
|
| 45 |
+
return nx.watts_strogatz_graph(n=1000, k=6, p=0.1, seed=42)
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def _ic_spread(G, seed_nodes: List[int], steps: int) -> List[dict]:
|
| 49 |
+
"""
|
| 50 |
+
Independent Cascade spread simulation.
|
| 51 |
+
Returns per-seed spread metrics: share_velocity, network_reach, position.
|
| 52 |
+
Falls back to synthetic signals if G is None.
|
| 53 |
+
"""
|
| 54 |
+
if G is None or not _HAS_NX:
|
| 55 |
+
# Synthetic spread signals when networkx unavailable
|
| 56 |
+
signals = []
|
| 57 |
+
for i in range(len(seed_nodes)):
|
| 58 |
+
v = random.uniform(0.05, 0.6)
|
| 59 |
+
signals.append({
|
| 60 |
+
"share_velocity": round(v, 3),
|
| 61 |
+
"network_reach": round(v * 0.5, 3),
|
| 62 |
+
"network_position": random.choice(["hub", "bridge", "edge"]),
|
| 63 |
+
})
|
| 64 |
+
return signals
|
| 65 |
+
|
| 66 |
+
results = []
|
| 67 |
+
avg_deg = sum(dict(G.degree()).values()) / G.number_of_nodes()
|
| 68 |
+
|
| 69 |
+
for seed in seed_nodes:
|
| 70 |
+
active, newly = {seed}, {seed}
|
| 71 |
+
for _ in range(steps):
|
| 72 |
+
nxt = set()
|
| 73 |
+
for node in newly:
|
| 74 |
+
p = 1.0 / max(1, G.degree(node))
|
| 75 |
+
nxt |= {nb for nb in G.neighbors(node)
|
| 76 |
+
if nb not in active and random.random() < p}
|
| 77 |
+
active |= nxt
|
| 78 |
+
newly = nxt
|
| 79 |
+
|
| 80 |
+
reach = len(active) / G.number_of_nodes()
|
| 81 |
+
velocity = min(1.0, reach * 2.0)
|
| 82 |
+
deg = G.degree(seed)
|
| 83 |
+
pos = "hub" if deg > 2*avg_deg else "bridge" if deg > avg_deg else "edge"
|
| 84 |
+
results.append({
|
| 85 |
+
"share_velocity": round(velocity, 3),
|
| 86 |
+
"network_reach": round(reach, 3),
|
| 87 |
+
"network_position": pos,
|
| 88 |
+
})
|
| 89 |
+
return results
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
# ── Environment ───────────────────────────────────────────────────────────────
|
| 93 |
+
|
| 94 |
+
class CACEEnvironment(Environment[CACEAction, CACEObservation, CACEState]):
|
| 95 |
+
"""
|
| 96 |
+
Cultural Context Arbitration Environment — unified V1+V2.
|
| 97 |
+
|
| 98 |
+
Episode flow (always the same):
|
| 99 |
+
|
| 100 |
+
reset()
|
| 101 |
+
1. Sample BATCH_SIZE posts from dataset
|
| 102 |
+
2. Seed on social graph, run IC spread (3 steps)
|
| 103 |
+
3. Attach spread signals to each post
|
| 104 |
+
4. Pre-enrich all posts via 4 frozen agents (uses cache for speed)
|
| 105 |
+
5. Return batch observation — agent sees ALL posts with signals
|
| 106 |
+
|
| 107 |
+
step(action)
|
| 108 |
+
action.selected_indices: which REVIEW_BUDGET posts to review (V2 prioritisation)
|
| 109 |
+
action.action_int: moderation decision (same for all selected posts)
|
| 110 |
+
→ compute 3-track reward + spread bonus per post → return avg reward
|
| 111 |
+
|
| 112 |
+
If action.selected_indices is None (simple single-case use):
|
| 113 |
+
→ treat action.action_int as decision for the first (primary) case
|
| 114 |
+
→ compute 3-track reward without spread bonus
|
| 115 |
+
"""
|
| 116 |
+
|
| 117 |
+
SUPPORTS_CONCURRENT_SESSIONS = True
|
| 118 |
+
|
| 119 |
+
def __init__(self):
|
| 120 |
+
super().__init__()
|
| 121 |
+
self._dataset = CaseDataset(DATASET_PATH)
|
| 122 |
+
self._graph = _build_graph()
|
| 123 |
+
|
| 124 |
+
# Episode state
|
| 125 |
+
self._episode_id: str = ""
|
| 126 |
+
self._batch: List[dict] = [] # [{case, enriched, signals, obs_str}]
|
| 127 |
+
self._step_count: int = 0
|
| 128 |
+
|
| 129 |
+
# Metrics
|
| 130 |
+
self._total_episodes: int = 0
|
| 131 |
+
self._correct: int = 0
|
| 132 |
+
self._rewards: List[float] = []
|
| 133 |
+
|
| 134 |
+
# ── reset ──��──────────────────────────────────────────────────────────────
|
| 135 |
+
|
| 136 |
+
def reset(
|
| 137 |
+
self,
|
| 138 |
+
seed: Optional[int] = None,
|
| 139 |
+
episode_id: Optional[str] = None,
|
| 140 |
+
**kwargs,
|
| 141 |
+
) -> CACEObservation:
|
| 142 |
+
"""
|
| 143 |
+
Start a new episode.
|
| 144 |
+
|
| 145 |
+
1. Sample BATCH_SIZE cases.
|
| 146 |
+
2. Run IC spread on social graph.
|
| 147 |
+
3. Enrich all cases via 4-agent pipeline (from cache when possible).
|
| 148 |
+
4. Return batch observation.
|
| 149 |
+
"""
|
| 150 |
+
if seed is not None:
|
| 151 |
+
random.seed(seed)
|
| 152 |
+
|
| 153 |
+
self._episode_id = episode_id or str(uuid.uuid4())
|
| 154 |
+
self._step_count = 0
|
| 155 |
+
self._total_episodes += 1
|
| 156 |
+
|
| 157 |
+
# 1. Sample batch
|
| 158 |
+
cases = self._dataset.sample_batch(BATCH_SIZE)
|
| 159 |
+
|
| 160 |
+
# 2. Network spread signals
|
| 161 |
+
seed_nodes = random.sample(
|
| 162 |
+
list(self._graph.nodes()) if self._graph else list(range(BATCH_SIZE)),
|
| 163 |
+
min(BATCH_SIZE, 1000 if self._graph else BATCH_SIZE)
|
| 164 |
+
)[:BATCH_SIZE]
|
| 165 |
+
signals = _ic_spread(self._graph, seed_nodes, NETWORK_STEPS)
|
| 166 |
+
|
| 167 |
+
# 3. Enrich all cases (from pipeline cache when available — fast)
|
| 168 |
+
self._batch = []
|
| 169 |
+
for i, case in enumerate(cases):
|
| 170 |
+
cached = self._dataset.get_cache(case["id"])
|
| 171 |
+
enriched = enrich(
|
| 172 |
+
case["post_text"],
|
| 173 |
+
cache={case["id"]: cached},
|
| 174 |
+
case_id=case["id"]
|
| 175 |
+
)
|
| 176 |
+
# Ensure post_text is always in enriched state for build_observation
|
| 177 |
+
enriched["post_text"] = case["post_text"]
|
| 178 |
+
sig = signals[i] if i < len(signals) else {
|
| 179 |
+
"share_velocity": 0.1, "network_reach": 0.05, "network_position": "edge"
|
| 180 |
+
}
|
| 181 |
+
sig["harm_probability"] = 1.0 if case["board_outcome"] == "REMOVE" else 0.0
|
| 182 |
+
self._batch.append({
|
| 183 |
+
"case": case,
|
| 184 |
+
"enriched": enriched,
|
| 185 |
+
"signals": sig,
|
| 186 |
+
"obs_str": build_observation(enriched, sig),
|
| 187 |
+
})
|
| 188 |
+
|
| 189 |
+
# 4. Build unified observation
|
| 190 |
+
obs_str = self._build_observation()
|
| 191 |
+
|
| 192 |
+
return CACEObservation(
|
| 193 |
+
observation=obs_str,
|
| 194 |
+
case_id=f"BATCH-{self._episode_id[:8]}",
|
| 195 |
+
language=self._batch[0]["enriched"].get("language", "Unknown"),
|
| 196 |
+
region=self._batch[0]["enriched"].get("region", "Unknown"),
|
| 197 |
+
complexity=self._batch[0]["case"].get("complexity", "medium"),
|
| 198 |
+
culture_flag=self._batch[0]["case"].get("culture_flag", False),
|
| 199 |
+
batch_posts=self._batch_summaries(),
|
| 200 |
+
network_step=0,
|
| 201 |
+
done=False,
|
| 202 |
+
reward=None,
|
| 203 |
+
)
|
| 204 |
+
|
| 205 |
+
# ── step ──────────────────────────────────────────────────────────────────
|
| 206 |
+
|
| 207 |
+
def step(
|
| 208 |
+
self,
|
| 209 |
+
action: CACEAction,
|
| 210 |
+
timeout_s: Optional[float] = None,
|
| 211 |
+
**kwargs,
|
| 212 |
+
) -> CACEObservation:
|
| 213 |
+
"""
|
| 214 |
+
Apply moderation decisions.
|
| 215 |
+
|
| 216 |
+
If action.selected_indices provided:
|
| 217 |
+
→ review those REVIEW_BUDGET posts, compute per-post 3-track + spread reward
|
| 218 |
+
Else (single-case fallback):
|
| 219 |
+
→ apply decision to first post only, compute 3-track reward
|
| 220 |
+
"""
|
| 221 |
+
if not self._batch:
|
| 222 |
+
raise RuntimeError("Call reset() before step().")
|
| 223 |
+
|
| 224 |
+
self._step_count += 1
|
| 225 |
+
indices = action.selected_indices
|
| 226 |
+
|
| 227 |
+
if indices:
|
| 228 |
+
reward, breakdown = self._step_batch(action.action_str, indices)
|
| 229 |
+
else:
|
| 230 |
+
reward, breakdown = self._step_single(action.action_str)
|
| 231 |
+
|
| 232 |
+
self._rewards.append(reward)
|
| 233 |
+
if breakdown.get("correct"):
|
| 234 |
+
self._correct += 1
|
| 235 |
+
|
| 236 |
+
primary_case = self._batch[0]["case"]
|
| 237 |
+
return CACEObservation(
|
| 238 |
+
observation=self._build_observation(),
|
| 239 |
+
case_id=f"BATCH-{self._episode_id[:8]}",
|
| 240 |
+
language=self._batch[0]["enriched"].get("language", "Unknown"),
|
| 241 |
+
region=self._batch[0]["enriched"].get("region", "Unknown"),
|
| 242 |
+
complexity=primary_case.get("complexity", "medium"),
|
| 243 |
+
culture_flag=primary_case.get("culture_flag", False),
|
| 244 |
+
mode="batch" if indices else "single",
|
| 245 |
+
done=True,
|
| 246 |
+
reward=reward,
|
| 247 |
+
reward_breakdown={
|
| 248 |
+
**breakdown,
|
| 249 |
+
"ground_truth": primary_case.get("board_outcome", "?"),
|
| 250 |
+
"case_id": primary_case.get("id", "?"),
|
| 251 |
+
},
|
| 252 |
+
)
|
| 253 |
+
|
| 254 |
+
def _step_single(self, decision: str) -> tuple[float, dict]:
|
| 255 |
+
"""Single-case decision (first post in batch). No spread bonus."""
|
| 256 |
+
item = self._batch[0]
|
| 257 |
+
case = item["case"]
|
| 258 |
+
r, bd = compute_reward(
|
| 259 |
+
decision=decision,
|
| 260 |
+
ground_truth=case["board_outcome"],
|
| 261 |
+
complexity=case["complexity"],
|
| 262 |
+
culture_flag=case.get("culture_flag", False),
|
| 263 |
+
share_velocity=0.0,
|
| 264 |
+
network_reach=0.0,
|
| 265 |
+
)
|
| 266 |
+
return r, bd
|
| 267 |
+
|
| 268 |
+
def _step_batch(self, decision: str, indices: List[int]) -> tuple[float, dict]:
|
| 269 |
+
"""Batch review: compute reward per selected post, return average."""
|
| 270 |
+
selected = [self._batch[i] for i in indices if i < len(self._batch)]
|
| 271 |
+
total, breakdowns = 0.0, []
|
| 272 |
+
|
| 273 |
+
for item in selected[:REVIEW_BUDGET]:
|
| 274 |
+
case = item["case"]
|
| 275 |
+
sig = item["signals"]
|
| 276 |
+
r, bd = compute_reward(
|
| 277 |
+
decision=decision,
|
| 278 |
+
ground_truth=case["board_outcome"],
|
| 279 |
+
complexity=case["complexity"],
|
| 280 |
+
culture_flag=case.get("culture_flag", False),
|
| 281 |
+
share_velocity=sig["share_velocity"],
|
| 282 |
+
network_reach=sig["network_reach"],
|
| 283 |
+
)
|
| 284 |
+
total += r
|
| 285 |
+
breakdowns.append(bd)
|
| 286 |
+
|
| 287 |
+
avg = total / max(1, len(breakdowns))
|
| 288 |
+
correct_count = sum(1 for bd in breakdowns if bd["correct"])
|
| 289 |
+
return avg, {
|
| 290 |
+
"avg_reward": round(avg, 4),
|
| 291 |
+
"correct": correct_count == len(breakdowns),
|
| 292 |
+
"correct_count": correct_count,
|
| 293 |
+
"total_reviewed": len(breakdowns),
|
| 294 |
+
"per_post": breakdowns,
|
| 295 |
+
}
|
| 296 |
+
|
| 297 |
+
# ── Observation builder ───────────────────────────────────────────────────
|
| 298 |
+
|
| 299 |
+
def _build_observation(self) -> str:
|
| 300 |
+
"""
|
| 301 |
+
Unified observation: network batch summary + primary case enrichment.
|
| 302 |
+
Agent sees both the spread signals (for prioritisation) and the full
|
| 303 |
+
deliberation context (for the moderation decision).
|
| 304 |
+
"""
|
| 305 |
+
# Part 1: Network batch summary (for prioritisation)
|
| 306 |
+
lines = [
|
| 307 |
+
"═══ CULTURAL CONTEXT ARBITRATION ENVIRONMENT ═══",
|
| 308 |
+
f"Episode: {self._episode_id[:8]} | Posts: {BATCH_SIZE} | Review budget: {REVIEW_BUDGET}",
|
| 309 |
+
"",
|
| 310 |
+
"── NETWORK BATCH (select your review queue) ──",
|
| 311 |
+
]
|
| 312 |
+
for i, item in enumerate(self._batch):
|
| 313 |
+
s = item["signals"]
|
| 314 |
+
c = item["case"]
|
| 315 |
+
tag = "⚠️ " if s["harm_probability"] > 0.5 else " "
|
| 316 |
+
lines.append(
|
| 317 |
+
f"[{i:02d}] {tag}{c['id']} | {item['enriched'].get('language','?')} | "
|
| 318 |
+
f"{item['enriched'].get('region','?')} | "
|
| 319 |
+
f"velocity={s['share_velocity']:.2f} reach={s['network_reach']:.2f} "
|
| 320 |
+
f"pos={s['network_position']}"
|
| 321 |
+
)
|
| 322 |
+
lines.append(f" {c['post_text'][:90]}...")
|
| 323 |
+
|
| 324 |
+
# Part 2: Primary case full deliberation (for decision)
|
| 325 |
+
primary = self._batch[0]
|
| 326 |
+
lines += [
|
| 327 |
+
"",
|
| 328 |
+
"── PRIMARY CASE (full deliberation) ──",
|
| 329 |
+
build_observation(primary["enriched"], primary["signals"]),
|
| 330 |
+
"",
|
| 331 |
+
"── YOUR TASK ──",
|
| 332 |
+
f"1. SELECT {REVIEW_BUDGET} indices to review (comma-separated): e.g. 0,3,5,7,9,11,14,17",
|
| 333 |
+
"2. DECIDE for each selected post:",
|
| 334 |
+
" ALLOW | REMOVE | ALLOW_WITH_LABEL | ESCALATE | RESTRICT_DISTRIBUTION",
|
| 335 |
+
"",
|
| 336 |
+
"Format: INDICES: 0,3,5,... | DECISION: ALLOW",
|
| 337 |
+
]
|
| 338 |
+
return "\n".join(lines)
|
| 339 |
+
|
| 340 |
+
def _batch_summaries(self) -> List[dict]:
|
| 341 |
+
return [
|
| 342 |
+
{
|
| 343 |
+
"index": i,
|
| 344 |
+
"case_id": item["case"]["id"],
|
| 345 |
+
"post_preview": item["case"]["post_text"][:100],
|
| 346 |
+
"language": item["enriched"].get("language", "Unknown"),
|
| 347 |
+
"region": item["enriched"].get("region", "Unknown"),
|
| 348 |
+
"share_velocity": item["signals"]["share_velocity"],
|
| 349 |
+
"network_reach": item["signals"]["network_reach"],
|
| 350 |
+
"harm_probability":item["signals"]["harm_probability"],
|
| 351 |
+
"network_position":item["signals"]["network_position"],
|
| 352 |
+
"ground_truth": item["case"]["board_outcome"],
|
| 353 |
+
}
|
| 354 |
+
for i, item in enumerate(self._batch)
|
| 355 |
+
]
|
| 356 |
+
|
| 357 |
+
# ── state property ────────────────────────────────────────────────────────
|
| 358 |
+
|
| 359 |
+
@property
|
| 360 |
+
def state(self) -> CACEState:
|
| 361 |
+
primary = self._batch[0] if self._batch else {}
|
| 362 |
+
avg_50 = (
|
| 363 |
+
sum(self._rewards[-50:]) / min(50, len(self._rewards))
|
| 364 |
+
if self._rewards else 0.0
|
| 365 |
+
)
|
| 366 |
+
return CACEState(
|
| 367 |
+
episode_id=self._episode_id,
|
| 368 |
+
step_count=self._step_count,
|
| 369 |
+
case_id=(primary.get("case") or {}).get("id", ""),
|
| 370 |
+
post_text=(primary.get("case") or {}).get("post_text", "")[:200],
|
| 371 |
+
language=(primary.get("enriched") or {}).get("language", "Unknown"),
|
| 372 |
+
region=(primary.get("enriched") or {}).get("region", "Unknown"),
|
| 373 |
+
policy_clause=(primary.get("enriched") or {}).get("policy_clause", "Unknown"),
|
| 374 |
+
cultural_brief=(primary.get("enriched") or {}).get("cultural_brief", "")[:150],
|
| 375 |
+
challenge_brief=(primary.get("enriched") or {}).get("challenge_brief", "")[:150],
|
| 376 |
+
policy_anchor=(primary.get("enriched") or {}).get("policy_anchor", "")[:150],
|
| 377 |
+
ground_truth=(primary.get("case") or {}).get("board_outcome", ""),
|
| 378 |
+
complexity=(primary.get("case") or {}).get("complexity", "medium"),
|
| 379 |
+
mode="unified",
|
| 380 |
+
total_episodes=self._total_episodes,
|
| 381 |
+
correct_decisions=self._correct,
|
| 382 |
+
accuracy=round(self._correct / max(1, self._total_episodes), 4),
|
| 383 |
+
avg_reward_last_50=round(avg_50, 4),
|
| 384 |
+
network_nodes=self._graph.number_of_nodes() if self._graph else None,
|
| 385 |
+
network_edges=self._graph.number_of_edges() if self._graph else None,
|
| 386 |
+
posts_in_batch=len(self._batch),
|
| 387 |
+
posts_selected=REVIEW_BUDGET,
|
| 388 |
+
)
|
| 389 |
+
|
| 390 |
+
|
| 391 |
+
# ── FastAPI app ───────────────────────────────────────────────────────────────
|
| 392 |
+
|
| 393 |
+
# Use singleton — OpenEnv creates new instance per request by default
|
| 394 |
+
# which breaks stateful environments. We use a single shared instance.
|
| 395 |
+
_ENV_INSTANCE = CACEEnvironment()
|
| 396 |
+
|
| 397 |
+
def env_factory():
|
| 398 |
+
return _ENV_INSTANCE
|
| 399 |
+
|
| 400 |
+
app = create_fastapi_app(
|
| 401 |
+
env=env_factory,
|
| 402 |
+
action_cls=CACEAction,
|
| 403 |
+
observation_cls=CACEObservation,
|
| 404 |
+
max_concurrent_envs=1, # single instance = single concurrent session
|
| 405 |
+
)
|
| 406 |
+
|
| 407 |
+
if __name__ == "__main__":
|
| 408 |
+
import uvicorn
|
| 409 |
+
uvicorn.run(app, host="0.0.0.0", port=7860)
|
data/all_cases.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
data/master_dataset.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
data/pipeline_cache.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
openenv.yaml
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: cace_env
|
| 2 |
+
version: 0.2.0
|
| 3 |
+
description: >
|
| 4 |
+
Cultural Context Arbitration Environment — trains LLMs to make culturally-aware
|
| 5 |
+
content moderation decisions using Meta's Oversight Board rulings as a verifiable
|
| 6 |
+
reward oracle. Three-track reward model. Real social network spread simulation.
|
| 7 |
+
No LLM judge. Fully deterministic reward.
|
| 8 |
+
|
| 9 |
+
client:
|
| 10 |
+
class_name: CACEEnvClient
|
| 11 |
+
module: cace_env.client
|
| 12 |
+
|
| 13 |
+
action:
|
| 14 |
+
class_name: CACEAction
|
| 15 |
+
module: cace_env.models
|
| 16 |
+
|
| 17 |
+
observation:
|
| 18 |
+
class_name: CACEObservation
|
| 19 |
+
module: cace_env.models
|
| 20 |
+
|
| 21 |
+
default_image: cace-env:latest
|
| 22 |
+
spec_version: 1
|
| 23 |
+
|
| 24 |
+
tags:
|
| 25 |
+
- reinforcement-learning
|
| 26 |
+
- content-moderation
|
| 27 |
+
- multi-agent
|
| 28 |
+
- cultural-context
|
| 29 |
+
- oversight-board
|
| 30 |
+
- grpo
|
| 31 |
+
- rlvr
|
pyproject.toml
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[build-system]
|
| 2 |
+
requires = ["hatchling"]
|
| 3 |
+
build-backend = "hatchling.build"
|
| 4 |
+
|
| 5 |
+
[project]
|
| 6 |
+
name = "cace-env"
|
| 7 |
+
version = "0.2.0"
|
| 8 |
+
description = "Cultural Context Arbitration Environment — OpenEnv RL environment"
|
| 9 |
+
requires-python = ">=3.11"
|
| 10 |
+
|
| 11 |
+
dependencies = [
|
| 12 |
+
"openenv-core>=0.1.0",
|
| 13 |
+
"fastapi>=0.111.0",
|
| 14 |
+
"uvicorn>=0.30.0",
|
| 15 |
+
"pydantic>=2.0.0",
|
| 16 |
+
"python-dotenv>=1.0.0",
|
| 17 |
+
"requests>=2.31.0",
|
| 18 |
+
"groq>=0.9.0",
|
| 19 |
+
"openai>=1.0.0",
|
| 20 |
+
]
|
| 21 |
+
|
| 22 |
+
[project.optional-dependencies]
|
| 23 |
+
network = [
|
| 24 |
+
"networkx>=3.3",
|
| 25 |
+
]
|
| 26 |
+
all = [
|
| 27 |
+
"networkx>=3.3",
|
| 28 |
+
]
|
| 29 |
+
|
| 30 |
+
[tool.hatch.build.targets.wheel]
|
| 31 |
+
packages = ["cace_env"]
|