cace-env / cace_env /server.py
hsr99's picture
Upload cace_env/server.py with huggingface_hub
2bd59b6 verified
Raw
History Blame Contribute Delete
17.3 kB
"""
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
# ── Network (optional — graceful fallback if networkx not installed) ──────────
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")) # posts per episode
REVIEW_BUDGET = int(os.environ.get("REVIEW_BUDGET", "8")) # posts agent must review
NETWORK_STEPS = int(os.environ.get("NETWORK_STEPS", "3")) # IC cascade steps
# ── Network helpers ───────────────────────────────────────────────────────────
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:
# Synthetic spread signals when networkx unavailable
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
# ── Environment ───────────────────────────────────────────────────────────────
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()
# Episode state
self._episode_id: str = ""
self._batch: List[dict] = [] # [{case, enriched, signals, obs_str}]
self._step_count: int = 0
# Metrics
self._total_episodes: int = 0
self._correct: int = 0
self._rewards: List[float] = []
# ── reset ─────────────────────────────────────────────────────────────────
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
# 1. Sample batch
cases = self._dataset.sample_batch(BATCH_SIZE)
# 2. Network spread signals
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)
# 3. Enrich all cases (from pipeline cache when available — fast)
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"]
)
# Ensure post_text is always in enriched state for build_observation
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),
})
# 4. Build unified observation
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,
)
# ── step ──────────────────────────────────────────────────────────────────
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"])
# Average the three tracks so they show in training logs
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,
}
# ── Observation builder ───────────────────────────────────────────────────
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).
"""
# Part 1: Network batch summary (for prioritisation)
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]}...")
# Part 2: Primary case full deliberation (for decision)
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)
]
# ── state property ────────────────────────────────────────────────────────
@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,
)
# ── FastAPI app ───────────────────────────────────────────────────────────────
# Use singleton — OpenEnv creates new instance per request by default
# which breaks stateful environments. We use a single shared instance.
_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, # single instance = single concurrent session
)
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=7860)