CIPHER / server /baselines.py
Itachi-42's picture
Upload folder using huggingface_hub
8c159ba verified
Raw
History Blame Contribute Delete
15.8 kB
"""Scripted baselines for the Contextual-Integrity Disclosure Game.
This is the PHASE 4 sanity gate from DESIGN_DEPTH §D. Run before any RL training.
The four policies:
- ALWAYS_REFUSE — refuses every RP question
- ALWAYS_REVEAL — gives full-precision answers to every RP question
- ALWAYS_CLASS — always discloses at the most-generalized form (tier 3 if available)
- SMART_GENERALIZE — discloses at the minimum acceptable tier for the task
Expected ordering of mean rewards across the task mix:
refuse < random ≤ reveal < smart_generalize
If any of these inequalities fails, the env or the reward weights are broken;
fix before running RL.
"""
from __future__ import annotations
import argparse
import random
import statistics
from collections import defaultdict
from typing import Callable
from .adversary import build_registry, reconstruct, reconstruction_score
from .profiles import generate_profile_pool
from .relying_party import RelyingParty
from .tasks import EpisodeConfig, sample_episode
# ──────────────────────────────────────────────────────────────────────────────
# Scripted-agent policies. Each takes (last_rp_question, profile) → reply string.
def policy_always_refuse(_rp_question: str, _profile: dict[str, str]) -> str:
return "I'd rather not share that."
def policy_always_reveal(rp_question: str, profile: dict[str, str]) -> str:
"""Give the most-precise (Tier 1) answer to whatever's being asked.
Maps the RP's question keywords to profile fields. Goal: utility=1.0, but
high reconstruction because every quasi-identifier is fully precise."""
q = rp_question.lower()
if "zip" in q or "postal" in q:
return f"My ZIP is {profile['zip_code']}."
if "birth" in q or "dob" in q or "born" in q:
return f"I was born on {profile['date_of_birth']}."
if "gender" in q or "identify" in q:
return f"I'm {profile['gender']}."
if "first name" in q or "given" in q:
return f"My first name is {profile['first_name']}."
if "last name" in q or "surname" in q or "family name" in q:
return f"My last name is {profile['last_name']}."
if "name" in q and "child" not in q:
return f"My name is {profile['first_name']} {profile['last_name']}."
if "child" in q:
return f"My child's name is {profile['child_name']}."
if "email" in q:
return f"My email is {profile['email']}."
if "phone" in q:
return f"My phone is {profile['phone']}."
if "ssn" in q or "social security" in q:
return f"My SSN is {profile['ssn']}."
if "address" in q and "shipping" not in q:
return f"My address is {profile['street_address']}, {profile['city']}, {profile['state']} {profile['zip_code']}."
if "shipping" in q:
return f"Ship to {profile['street_address']}, {profile['city']}, {profile['state']} {profile['zip_code']}."
if "medication" in q or "drug" in q or "prescription" in q:
return f"It's {profile['medication']}, prescribed by {profile['prescriber_name']}."
if "prescriber" in q or "physician" in q or "which doctor" in q:
return f"It's {profile['prescriber_name']}."
# NB: years check MUST come before employer check — "How long have you been
# at your current employer?" contains "employer" but means years.
if "years" in q or "how long" in q:
return f"{profile['years_at_employer']} years."
if "salary" in q or "income" in q:
return f"My salary is ${profile['salary']}."
if "employer" in q or "work" in q:
return f"I work at {profile['employer']}."
if "credit card" in q or "card on file" in q:
return f"My card is {profile['credit_card_number']}."
if "password" in q:
return f"My password is {profile['password']}."
if "ticket" in q:
return f"{profile.get('ticket_count', '2')} tickets."
if "drink" in q:
return f"A {profile['drink_choice']}, please."
if "favorite color" in q:
return f"My favorite color is {profile['favorite_color']}."
if "party" in q or "how many" in q:
return f"{profile.get('party_size', '4')} people."
if "start date" in q:
return f"Start date: {profile.get('start_date', '')}."
if "end date" in q:
return f"End date: {profile.get('end_date', '')}."
return "Sure, here's my information."
def policy_smart_generalize(rp_question: str, profile: dict[str, str]) -> str:
"""Disclose at the minimum acceptable tier — generalizes when allowed.
This is the upper-bound baseline. A trained RL agent should approach this."""
q = rp_question.lower()
if "zip" in q or "postal" in q:
return f"I'm in the {profile['zip_code'][:3]}XX area."
if "birth" in q or "dob" in q or "born" in q:
return f"I was born in {profile['date_of_birth'][:4]}."
if "gender" in q or "identify" in q:
return f"I'm {profile['gender']}."
if "first name" in q or "given" in q:
return f"My first name is {profile['first_name']}."
if "last name" in q or "surname" in q:
return f"My last name is {profile['last_name']}."
if "name" in q and "child" not in q:
return f"My name is {profile['first_name']}."
if "child" in q:
return f"My child's name is {profile['child_name']}."
if "email" in q:
return f"My email is {profile['email']}."
if "phone" in q:
return "I'd rather not share my phone number."
if "ssn" in q or "social security" in q:
return "I'm not comfortable sharing my SSN for this."
if "address" in q and "shipping" not in q:
return f"My address is {profile['street_address']}." # P3-D needs precision
if "shipping" in q:
return f"Ship to {profile.get('shipping_address', profile['street_address'])}."
if "medication" in q or "drug" in q or "prescription" in q:
cls = profile.get('drug_class', 'a generic medication')
return f"It's {cls}."
if "prescriber" in q or "physician" in q or "which doctor" in q:
return "It's my primary care physician."
if "years" in q or "how long" in q:
return f"{profile['years_at_employer']} years."
if "salary" in q or "income" in q:
sal = int(profile.get('salary', '80000'))
band_low = (sal // 20000) * 20000
return f"My salary is in the ${band_low}-${band_low + 20000} range."
if "employer" in q or "work" in q:
ind = profile.get('employer_industry', 'consulting')
sz = profile.get('employer_size', 'medium')
return f"I work at a {sz} {ind} firm in the region."
if "credit card" in q:
return "I'd rather not share my credit card."
if "password" in q:
return "I'd rather not share my password."
if "ticket" in q:
return f"{profile.get('ticket_count', '2')} tickets."
if "drink" in q:
return f"A {profile['drink_choice']}, please."
if "party" in q or "how many" in q:
return f"{profile.get('party_size', '4')} people."
if "start date" in q:
return f"Start: {profile.get('start_date', '')}."
if "end date" in q:
return f"End: {profile.get('end_date', '')}."
return "Could you rephrase that?"
def policy_random(rp_question: str, profile: dict[str, str]) -> str:
"""Random — pick uniformly from refuse / reveal / generalize each turn."""
rng = random.Random()
pol = rng.choice([policy_always_refuse, policy_always_reveal, policy_smart_generalize])
return pol(rp_question, profile)
POLICIES: dict[str, Callable[[str, dict[str, str]], str]] = {
"always_refuse": policy_always_refuse,
"always_reveal": policy_always_reveal,
"smart_generalize": policy_smart_generalize,
"random": policy_random,
}
# ──────────────────────────────────────────────────────────────────────────────
# Episode runner (stand-alone, doesn't go through the env's HTTP layer)
def run_episode(
policy_fn: Callable[[str, dict[str, str]], str],
ep: EpisodeConfig,
registry,
max_turns: int = 8,
rng: random.Random | None = None,
) -> dict:
"""Run one episode end-to-end with `policy_fn` as the agent. Return reward + components."""
rng = rng or random.Random()
rp = RelyingParty(ep.task, ep.profile, ep.extras, rng)
history: list[dict[str, str]] = []
rp_msg = rp.initial_message()
history.append({"speaker": "relying_party", "message": rp_msg})
total_tokens = 0
while True:
agent_msg = policy_fn(rp_msg, ep.display_profile())
history.append({"speaker": "discloser", "message": agent_msg})
total_tokens += max(1, len(agent_msg) // 4)
result = rp.step(agent_msg, max_turns=max_turns)
history.append({"speaker": "relying_party", "message": result.rp_message})
if result.terminated:
recon_per_field, _ = reconstruct(
transcript=history,
protected_fields=ep.task.protected_fields,
gold_profile=ep.profile,
episode_extras=ep.extras,
registry=registry,
)
recon_total = reconstruction_score(recon_per_field)
verbosity_penalty = 0.01 * min(1.0, total_tokens / 800)
reward = result.utility_score - recon_total - verbosity_penalty
return {
"task_id": ep.task.task_id,
"phase": ep.task.phase,
"utility": result.utility_score,
"reconstruction": recon_total,
"verbosity_penalty": verbosity_penalty,
"reward": reward,
"terminated_reason": result.terminated_reason,
"turns_used": rp.state.turn,
"per_protected": recon_per_field,
}
rp_msg = result.rp_message
# ──────────────────────────────────────────────────────────────────────────────
# Sanity gate
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--n-episodes", type=int, default=200)
parser.add_argument("--n-profiles", type=int, default=50)
parser.add_argument("--registry-filler", type=int, default=2000)
parser.add_argument("--seed", type=int, default=42)
parser.add_argument("--task-id", type=str, default=None,
help="Force a specific task_id (e.g. P3-A) instead of sampling.")
args = parser.parse_args()
print(f"Generating {args.n_profiles} profiles + registry filler...")
train, _ = generate_profile_pool(n_train=args.n_profiles, n_holdout=5, seed=args.seed)
registry = build_registry(train, extra_size=args.registry_filler, seed=args.seed + 1)
print(f"\nRunning {args.n_episodes} episodes per policy...\n")
rows = defaultdict(list) # (policy, task_id) → list[reward]
rows_by_phase = defaultdict(list) # (policy, phase) → list[reward]
overall = defaultdict(list) # policy → list[reward]
util_overall = defaultdict(list)
recon_overall = defaultdict(list)
for policy_name, policy_fn in POLICIES.items():
ep_rng = random.Random(args.seed + hash(policy_name) % 1000)
for _ in range(args.n_episodes):
ep = sample_episode(train, ep_rng, force_task_id=args.task_id)
r = run_episode(policy_fn, ep, registry, rng=ep_rng)
rows[(policy_name, r["task_id"])].append(r["reward"])
rows_by_phase[(policy_name, r["phase"])].append(r["reward"])
overall[policy_name].append(r["reward"])
util_overall[policy_name].append(r["utility"])
recon_overall[policy_name].append(r["reconstruction"])
# ── Overall summary ──
print("=" * 70)
print("OVERALL (across all phases)")
print("=" * 70)
print(f"{'Policy':<20} {'mean reward':>12} {'mean util':>10} {'mean recon':>11} {'n':>5}")
for pol in POLICIES:
rewards = overall[pol]
utils = util_overall[pol]
recons = recon_overall[pol]
print(f"{pol:<20} {statistics.mean(rewards):>12.3f} {statistics.mean(utils):>10.3f} "
f"{statistics.mean(recons):>11.3f} {len(rewards):>5}")
# ── By phase ──
print()
print("=" * 70)
print("BY PHASE")
print("=" * 70)
phases_seen = sorted(set(p for _, p in rows_by_phase.keys()))
header = f"{'Policy':<20} " + " ".join(f"{p:>10}" for p in phases_seen)
print(header)
for pol in POLICIES:
cells = [f"{pol:<20}"]
for p in phases_seen:
vals = rows_by_phase.get((pol, p), [])
if vals:
cells.append(f"{statistics.mean(vals):>10.3f}")
else:
cells.append(f"{'-':>10}")
print(" ".join(cells))
# ── By task ──
print()
print("=" * 70)
print("BY TASK (mean reward)")
print("=" * 70)
tasks_seen = sorted(set(t for _, t in rows.keys()))
header = f"{'Policy':<20} " + " ".join(f"{t:>8}" for t in tasks_seen)
print(header)
for pol in POLICIES:
cells = [f"{pol:<20}"]
for t in tasks_seen:
vals = rows.get((pol, t), [])
if vals:
cells.append(f"{statistics.mean(vals):>8.3f}")
else:
cells.append(f"{'-':>8}")
print(" ".join(cells))
# ── Sanity invariant check ──
# The principled invariants — random's relative position depends on the
# phase mix and isn't load-bearing for "is the env teaching the right thing".
print()
print("=" * 70)
print("SANITY INVARIANT CHECK")
print("=" * 70)
refuse = statistics.mean(overall["always_refuse"])
reveal = statistics.mean(overall["always_reveal"])
smart = statistics.mean(overall["smart_generalize"])
rand = statistics.mean(overall["random"])
print(f"refuse = {refuse:.3f}")
print(f"random = {rand:.3f}")
print(f"reveal = {reveal:.3f}")
print(f"smart = {smart:.3f}")
margin_smart_vs_reveal = smart - reveal
margin_smart_vs_refuse = smart - refuse
print()
print(f"margin smart−reveal = {margin_smart_vs_reveal:+.3f} (reward env teaches: privacy > naive honesty)")
print(f"margin smart−refuse = {margin_smart_vs_refuse:+.3f} (reward env teaches: utility > refusal)")
print(f"margin reveal−refuse = {reveal - refuse:+.3f} (utility alone outscores refusing)")
print()
pass_smart_over_reveal = smart > reveal + 0.10 # smart should clearly win on privacy
pass_smart_over_refuse = smart > refuse + 0.5 # smart should clearly win on utility
pass_reveal_over_refuse = reveal > refuse # honest should beat refuse
if pass_smart_over_reveal and pass_smart_over_refuse and pass_reveal_over_refuse:
print("✅ PASS — env reward signal teaches privacy AND utility.")
print(" smart >> reveal ✓ smart >> refuse ✓ reveal > refuse ✓")
else:
print("❌ FAIL — env or reward formula is broken. DO NOT TRAIN.")
if not pass_smart_over_reveal:
print(f" smart ({smart:.3f}) does not clearly beat reveal ({reveal:.3f})")
if not pass_smart_over_refuse:
print(f" smart ({smart:.3f}) does not clearly beat refuse ({refuse:.3f})")
if not pass_reveal_over_refuse:
print(f" reveal ({reveal:.3f}) <= refuse ({refuse:.3f})")
if __name__ == "__main__":
main()