| """ |
| cace_env/server.py |
| CACEEnvironment — single unified environment. |
| |
| One episode flow handles everything: |
| reset() → seed posts on network → IC spread → enrich all → agent picks + decides |
| step() → three-track reward + spread bonus |
| |
| No mode switching. No V1/V2 branching. One clean class. |
| """ |
|
|
| import os, uuid, random |
| from typing import Optional, List |
|
|
| from openenv.core import Environment, create_fastapi_app |
|
|
| from cace_env.models import CACEAction, CACEObservation, CACEState |
| from cace_env.dataset import CaseDataset, ACTION_MAP |
| from cace_env.pipeline import enrich, build_observation |
| from cace_env.reward import compute_reward |
|
|
| |
| try: |
| import networkx as nx |
| _HAS_NX = True |
| except ImportError: |
| _HAS_NX = False |
|
|
| DATASET_PATH = os.environ.get("DATASET_PATH", "data/all_cases.json") |
| BATCH_SIZE = int(os.environ.get("BATCH_SIZE", "20")) |
| REVIEW_BUDGET = int(os.environ.get("REVIEW_BUDGET", "8")) |
| NETWORK_STEPS = int(os.environ.get("NETWORK_STEPS", "3")) |
|
|
|
|
| |
|
|
| def _build_graph() -> Optional[object]: |
| """ |
| Watts-Strogatz small-world graph approximating SNAP Facebook ego topology. |
| ~1000 nodes, avg degree 6, rewiring 0.1. |
| Falls back to None if networkx not available. |
| """ |
| if not _HAS_NX: |
| return None |
| return nx.watts_strogatz_graph(n=1000, k=6, p=0.1, seed=42) |
|
|
|
|
| def _ic_spread(G, seed_nodes: List[int], steps: int) -> List[dict]: |
| """ |
| Independent Cascade spread simulation. |
| Returns per-seed spread metrics: share_velocity, network_reach, position. |
| Falls back to synthetic signals if G is None. |
| """ |
| if G is None or not _HAS_NX: |
| |
| signals = [] |
| for i in range(len(seed_nodes)): |
| v = random.uniform(0.05, 0.6) |
| signals.append({ |
| "share_velocity": round(v, 3), |
| "network_reach": round(v * 0.5, 3), |
| "network_position": random.choice(["hub", "bridge", "edge"]), |
| }) |
| return signals |
|
|
| results = [] |
| avg_deg = sum(dict(G.degree()).values()) / G.number_of_nodes() |
|
|
| for seed in seed_nodes: |
| active, newly = {seed}, {seed} |
| for _ in range(steps): |
| nxt = set() |
| for node in newly: |
| p = 1.0 / max(1, G.degree(node)) |
| nxt |= {nb for nb in G.neighbors(node) |
| if nb not in active and random.random() < p} |
| active |= nxt |
| newly = nxt |
|
|
| reach = len(active) / G.number_of_nodes() |
| velocity = min(1.0, reach * 2.0) |
| deg = G.degree(seed) |
| pos = "hub" if deg > 2*avg_deg else "bridge" if deg > avg_deg else "edge" |
| results.append({ |
| "share_velocity": round(velocity, 3), |
| "network_reach": round(reach, 3), |
| "network_position": pos, |
| }) |
| return results |
|
|
|
|
| |
|
|
| class CACEEnvironment(Environment[CACEAction, CACEObservation, CACEState]): |
| """ |
| Cultural Context Arbitration Environment — unified V1+V2. |
| |
| Episode flow (always the same): |
| |
| reset() |
| 1. Sample BATCH_SIZE posts from dataset |
| 2. Seed on social graph, run IC spread (3 steps) |
| 3. Attach spread signals to each post |
| 4. Pre-enrich all posts via 4 frozen agents (uses cache for speed) |
| 5. Return batch observation — agent sees ALL posts with signals |
| |
| step(action) |
| action.selected_indices: which REVIEW_BUDGET posts to review (V2 prioritisation) |
| action.action_int: moderation decision (same for all selected posts) |
| → compute 3-track reward + spread bonus per post → return avg reward |
| |
| If action.selected_indices is None (simple single-case use): |
| → treat action.action_int as decision for the first (primary) case |
| → compute 3-track reward without spread bonus |
| """ |
|
|
| SUPPORTS_CONCURRENT_SESSIONS = True |
|
|
| def __init__(self): |
| super().__init__() |
| self._dataset = CaseDataset(DATASET_PATH) |
| self._graph = _build_graph() |
|
|
| |
| self._episode_id: str = "" |
| self._batch: List[dict] = [] |
| self._step_count: int = 0 |
|
|
| |
| self._total_episodes: int = 0 |
| self._correct: int = 0 |
| self._rewards: List[float] = [] |
|
|
| |
|
|
| def reset( |
| self, |
| seed: Optional[int] = None, |
| episode_id: Optional[str] = None, |
| **kwargs, |
| ) -> CACEObservation: |
| """ |
| Start a new episode. |
| |
| 1. Sample BATCH_SIZE cases. |
| 2. Run IC spread on social graph. |
| 3. Enrich all cases via 4-agent pipeline (from cache when possible). |
| 4. Return batch observation. |
| """ |
| if seed is not None: |
| random.seed(seed) |
|
|
| self._episode_id = episode_id or str(uuid.uuid4()) |
| self._step_count = 0 |
| self._total_episodes += 1 |
|
|
| |
| cases = self._dataset.sample_batch(BATCH_SIZE) |
|
|
| |
| seed_nodes = random.sample( |
| list(self._graph.nodes()) if self._graph else list(range(BATCH_SIZE)), |
| min(BATCH_SIZE, 1000 if self._graph else BATCH_SIZE) |
| )[:BATCH_SIZE] |
| signals = _ic_spread(self._graph, seed_nodes, NETWORK_STEPS) |
|
|
| |
| self._batch = [] |
| for i, case in enumerate(cases): |
| cached = self._dataset.get_cache(case["id"]) |
| enriched = enrich( |
| case["post_text"], |
| cache={case["id"]: cached}, |
| case_id=case["id"] |
| ) |
| |
| enriched["post_text"] = case["post_text"] |
| sig = signals[i] if i < len(signals) else { |
| "share_velocity": 0.1, "network_reach": 0.05, "network_position": "edge" |
| } |
| sig["harm_probability"] = 1.0 if case["board_outcome"] == "REMOVE" else 0.0 |
| self._batch.append({ |
| "case": case, |
| "enriched": enriched, |
| "signals": sig, |
| "obs_str": build_observation(enriched, sig), |
| }) |
|
|
| |
| obs_str = self._build_observation() |
|
|
| return CACEObservation( |
| observation=obs_str, |
| case_id=f"BATCH-{self._episode_id[:8]}", |
| language=self._batch[0]["enriched"].get("language", "Unknown"), |
| region=self._batch[0]["enriched"].get("region", "Unknown"), |
| complexity=self._batch[0]["case"].get("complexity", "medium"), |
| culture_flag=self._batch[0]["case"].get("culture_flag", False), |
| batch_posts=self._batch_summaries(), |
| network_step=0, |
| done=False, |
| reward=None, |
| ) |
|
|
| |
|
|
| def step( |
| self, |
| action: CACEAction, |
| timeout_s: Optional[float] = None, |
| **kwargs, |
| ) -> CACEObservation: |
| """ |
| Apply moderation decisions. |
| |
| If action.selected_indices provided: |
| → review those REVIEW_BUDGET posts, compute per-post 3-track + spread reward |
| Else (single-case fallback): |
| → apply decision to first post only, compute 3-track reward |
| """ |
| if not self._batch: |
| raise RuntimeError("Call reset() before step().") |
|
|
| self._step_count += 1 |
| indices = action.selected_indices |
|
|
| if indices: |
| reward, breakdown = self._step_batch(action.action_str, indices) |
| else: |
| reward, breakdown = self._step_single(action.action_str) |
|
|
| self._rewards.append(reward) |
| if breakdown.get("correct"): |
| self._correct += 1 |
|
|
| primary_case = self._batch[0]["case"] |
| return CACEObservation( |
| observation=self._build_observation(), |
| case_id=f"BATCH-{self._episode_id[:8]}", |
| language=self._batch[0]["enriched"].get("language", "Unknown"), |
| region=self._batch[0]["enriched"].get("region", "Unknown"), |
| complexity=primary_case.get("complexity", "medium"), |
| culture_flag=primary_case.get("culture_flag", False), |
| mode="batch" if indices else "single", |
| done=True, |
| reward=reward, |
| reward_breakdown={ |
| **breakdown, |
| "ground_truth": primary_case.get("board_outcome", "?"), |
| "case_id": primary_case.get("id", "?"), |
| }, |
| ) |
|
|
| def _step_single(self, decision: str) -> tuple[float, dict]: |
| """Single-case decision (first post in batch). No spread bonus.""" |
| item = self._batch[0] |
| case = item["case"] |
| r, bd = compute_reward( |
| decision=decision, |
| ground_truth=case["board_outcome"], |
| complexity=case["complexity"], |
| culture_flag=case.get("culture_flag", False), |
| share_velocity=0.0, |
| network_reach=0.0, |
| ) |
| return r, bd |
|
|
| def _step_batch(self, decision: str, indices: List[int]) -> tuple[float, dict]: |
| """Batch review: compute reward per selected post, return average.""" |
| selected = [self._batch[i] for i in indices if i < len(self._batch)] |
| total, breakdowns = 0.0, [] |
|
|
| for item in selected[:REVIEW_BUDGET]: |
| case = item["case"] |
| sig = item["signals"] |
| r, bd = compute_reward( |
| decision=decision, |
| ground_truth=case["board_outcome"], |
| complexity=case["complexity"], |
| culture_flag=case.get("culture_flag", False), |
| share_velocity=sig["share_velocity"], |
| network_reach=sig["network_reach"], |
| ) |
| total += r |
| breakdowns.append(bd) |
|
|
| avg = total / max(1, len(breakdowns)) |
| correct_count = sum(1 for bd in breakdowns if bd["correct"]) |
| |
| avg_t1 = sum(bd.get("track1_cultural", 0) for bd in breakdowns) / max(1, len(breakdowns)) |
| avg_t2 = sum(bd.get("track2_harm", 0) for bd in breakdowns) / max(1, len(breakdowns)) |
| avg_t3 = sum(bd.get("track3_policy", 0) for bd in breakdowns) / max(1, len(breakdowns)) |
| return avg, { |
| "avg_reward": round(avg, 4), |
| "correct": correct_count == len(breakdowns), |
| "correct_count": correct_count, |
| "total_reviewed": len(breakdowns), |
| "track1_cultural": round(avg_t1, 2), |
| "track2_harm": round(avg_t2, 2), |
| "track3_policy": round(avg_t3, 2), |
| "per_post": breakdowns, |
| } |
|
|
| |
|
|
| def _build_observation(self) -> str: |
| """ |
| Unified observation: network batch summary + primary case enrichment. |
| Agent sees both the spread signals (for prioritisation) and the full |
| deliberation context (for the moderation decision). |
| """ |
| |
| lines = [ |
| "═══ CULTURAL CONTEXT ARBITRATION ENVIRONMENT ═══", |
| f"Episode: {self._episode_id[:8]} | Posts: {BATCH_SIZE} | Review budget: {REVIEW_BUDGET}", |
| "", |
| "── NETWORK BATCH (select your review queue) ──", |
| ] |
| for i, item in enumerate(self._batch): |
| s = item["signals"] |
| c = item["case"] |
| tag = "⚠️ " if s["harm_probability"] > 0.5 else " " |
| lines.append( |
| f"[{i:02d}] {tag}{c['id']} | {item['enriched'].get('language','?')} | " |
| f"{item['enriched'].get('region','?')} | " |
| f"velocity={s['share_velocity']:.2f} reach={s['network_reach']:.2f} " |
| f"pos={s['network_position']}" |
| ) |
| lines.append(f" {c['post_text'][:90]}...") |
|
|
| |
| primary = self._batch[0] |
| lines += [ |
| "", |
| "── PRIMARY CASE (full deliberation) ──", |
| build_observation(primary["enriched"], primary["signals"]), |
| "", |
| "── YOUR TASK ──", |
| f"1. SELECT {REVIEW_BUDGET} indices to review (comma-separated): e.g. 0,3,5,7,9,11,14,17", |
| "2. DECIDE for each selected post:", |
| " ALLOW | REMOVE | ALLOW_WITH_LABEL | ESCALATE | RESTRICT_DISTRIBUTION", |
| "", |
| "Format: INDICES: 0,3,5,... | DECISION: ALLOW", |
| ] |
| return "\n".join(lines) |
|
|
| def _batch_summaries(self) -> List[dict]: |
| return [ |
| { |
| "index": i, |
| "case_id": item["case"]["id"], |
| "post_preview": item["case"]["post_text"][:100], |
| "language": item["enriched"].get("language", "Unknown"), |
| "region": item["enriched"].get("region", "Unknown"), |
| "share_velocity": item["signals"]["share_velocity"], |
| "network_reach": item["signals"]["network_reach"], |
| "harm_probability":item["signals"]["harm_probability"], |
| "network_position":item["signals"]["network_position"], |
| "ground_truth": item["case"]["board_outcome"], |
| } |
| for i, item in enumerate(self._batch) |
| ] |
|
|
| |
|
|
| @property |
| def state(self) -> CACEState: |
| primary = self._batch[0] if self._batch else {} |
| avg_50 = ( |
| sum(self._rewards[-50:]) / min(50, len(self._rewards)) |
| if self._rewards else 0.0 |
| ) |
| return CACEState( |
| episode_id=self._episode_id, |
| step_count=self._step_count, |
| case_id=(primary.get("case") or {}).get("id", ""), |
| post_text=(primary.get("case") or {}).get("post_text", "")[:200], |
| language=(primary.get("enriched") or {}).get("language", "Unknown"), |
| region=(primary.get("enriched") or {}).get("region", "Unknown"), |
| policy_clause=(primary.get("enriched") or {}).get("policy_clause", "Unknown"), |
| cultural_brief=(primary.get("enriched") or {}).get("cultural_brief", "")[:150], |
| challenge_brief=(primary.get("enriched") or {}).get("challenge_brief", "")[:150], |
| policy_anchor=(primary.get("enriched") or {}).get("policy_anchor", "")[:150], |
| ground_truth=(primary.get("case") or {}).get("board_outcome", ""), |
| complexity=(primary.get("case") or {}).get("complexity", "medium"), |
| mode="unified", |
| total_episodes=self._total_episodes, |
| correct_decisions=self._correct, |
| accuracy=round(self._correct / max(1, self._total_episodes), 4), |
| avg_reward_last_50=round(avg_50, 4), |
| network_nodes=self._graph.number_of_nodes() if self._graph else None, |
| network_edges=self._graph.number_of_edges() if self._graph else None, |
| posts_in_batch=len(self._batch), |
| posts_selected=REVIEW_BUDGET, |
| ) |
|
|
|
|
| |
|
|
| |
| |
| _ENV_INSTANCE = CACEEnvironment() |
|
|
| def env_factory(): |
| return _ENV_INSTANCE |
|
|
| app = create_fastapi_app( |
| env=env_factory, |
| action_cls=CACEAction, |
| observation_cls=CACEObservation, |
| max_concurrent_envs=1, |
| ) |
|
|
| if __name__ == "__main__": |
| import uvicorn |
| uvicorn.run(app, host="0.0.0.0", port=7860) |