Sunnycodes3114 commited on
Commit
7e24fa5
·
verified ·
1 Parent(s): 0a7dc76

Upload folder using huggingface_hub

Browse files
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: Cace Env
3
- emoji: 🐨
4
- colorFrom: blue
5
- colorTo: red
6
  sdk: docker
7
- pinned: false
 
 
 
 
 
 
 
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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"]