mirror of
https://github.com/wassname/moral-maps.git
synced 2026-10-07 11:25:55 +08:00
1146 lines
58 KiB
Python
1146 lines
58 KiB
Python
#!/usr/bin/env python3
|
|
"""wvs-original-choice-pilot-v1: original WVS response format (single choice; one
|
|
choose-up-to-five child-quality list) for the same five Gemini Flash releases. -- PI[gpt-5.6-terra]
|
|
|
|
Reconstructs the instrument from the saved Anthropic/llm_global_opinions WVS rows (see the
|
|
preregistration in docs/RESEARCH_JOURNAL.md). The child-quality list is a GlobalOpinionQA-compatible
|
|
APPROXIMATION of the WVS battery, not an exact reconstruction: the saved source has 10 per-quality
|
|
rows and no Religious faith row, while the standard WVS battery lists 11 qualities including
|
|
Religious faith. Omitting it removes one competitor from the choose-up-to-five budget, so top-five
|
|
selection rates for the remaining (plotted) qualities are biased upward relative to the true
|
|
instrument. Non-substantive outcomes ("Don't know", "No answer", empty list) are recorded as
|
|
explicit cannot_answer and reported separately; they are never treated as neutral or dropped.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import ast
|
|
import asyncio
|
|
import fcntl
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import re
|
|
from datetime import UTC, datetime, timezone
|
|
from decimal import Decimal
|
|
from pathlib import Path
|
|
|
|
import httpx
|
|
import numpy as np
|
|
from datasets import load_dataset
|
|
|
|
from moralmaps.iw_axes import X_AXIS, Y_AXIS, SKIP, positiveness, resolve_items
|
|
from moralmaps.read_api import openrouter_request_with_metadata_once
|
|
from openrouter_wrapper.retry import is_retryable_error
|
|
from wvs_gemini_flash_rubric_pilot import (
|
|
ENDPOINT_CATALOG,
|
|
MAX_ATTEMPTS,
|
|
MODEL_CATALOG,
|
|
MINIMUM_REASONING,
|
|
MODELS,
|
|
PROVIDER,
|
|
TRANSPORT_TIMEOUT,
|
|
atomic_json,
|
|
reservation_id,
|
|
standard_endpoint,
|
|
validate_route,
|
|
)
|
|
from wvs_score_all_options_refresh import reserve, settle_external_reservation
|
|
|
|
EVAL_VERSION = "wvs-original-choice-pilot-v1"
|
|
N_SAMPLES = 24
|
|
MAX_TOKENS = 1024
|
|
REQUEST_TIMEOUT = 400
|
|
STAGE_CAP_USD = Decimal("5")
|
|
PAID_OPTIN_ENV = "WVS_PAID_CALLS_AUTHORIZED"
|
|
|
|
|
|
class PaidCallsNotAuthorized(RuntimeError):
|
|
"""Raised when a paid request is attempted without the explicit opt-in.
|
|
|
|
Root cause of the 2026-09-19 unplanned gemini-3.8 panel: the test monkeypatched
|
|
`moralmaps.read_api.openrouter_request_with_metadata_once`, but this module had already bound
|
|
its own name via `from ... import` at import time, so the patch never took effect and the real
|
|
API was called. The opt-in guard is a second, independent line of defense: even a mis-patched
|
|
or unpatched request path refuses to execute unless the run was explicitly authorized."""
|
|
|
|
|
|
def paid_calls_authorized() -> bool:
|
|
return os.environ.get(PAID_OPTIN_ENV) == "1"
|
|
OUT = Path("slop/research/wvs/20260918_original_choice_pilot")
|
|
MANIFEST = OUT / "manifest.json"
|
|
RESULTS = OUT / "results.json"
|
|
STATE = OUT / "budget.json"
|
|
LOCK = OUT / "budget.lock"
|
|
REQUEST_ATTEMPTS = OUT / "request_attempts.jsonl"
|
|
GLOBAL_RESERVATION = "pilot/original-choice"
|
|
SMOKE_RESERVATION = "pilot/original-choice-smoke"
|
|
NONSUBSTANTIVE = ("Don't know", "No answer")
|
|
POSTHOC_MISSING = "Other missing; Multiple answers Mail (EVS)"
|
|
PANEL_QUALITIES = ("Obedience", "Independence", "Determination, perseverance", "Imagination")
|
|
CHILD_STEM = ("Here is a list of qualities that children can be encouraged to learn at home. "
|
|
"Which, if any, do you consider to be especially important? Please choose up to five.")
|
|
ORDINARY_SUFFIXES = ("Religion", "God", "Abortion", "Homosexuality", "dealing with people?",
|
|
"Signing a petition", "Attending peaceful demonstrations", "Joining in boycotts")
|
|
|
|
|
|
def ordinary_choice_schema(offered: list[str]) -> dict:
|
|
return {"type": "json_schema", "json_schema": {"name": "wvs_original_choice", "strict": True,
|
|
"schema": {"type": "object", "properties": {"selected": {
|
|
"type": "string", "enum": offered}},
|
|
"required": ["selected"], "additionalProperties": False}}}
|
|
|
|
|
|
def child_choice_schema(qualities: list[str]) -> dict:
|
|
return {"type": "json_schema", "json_schema": {"name": "wvs_child_qualities", "strict": True,
|
|
"schema": {"type": "object", "properties": {"selected": {
|
|
"type": "array", "items": {"type": "string", "enum": qualities},
|
|
"minItems": 0, "maxItems": 5, "uniqueItems": True}},
|
|
"required": ["selected"], "additionalProperties": False}}}
|
|
|
|
|
|
def build_items() -> tuple[list[dict], list[dict]]:
|
|
"""9 original questions: 8 ordinary single-choice + 1 child-quality choose-up-to-five list.
|
|
Ordinary: {id, question, offered, substantive, is_list: False}. List: one item with
|
|
{id: 'ChildQualities', qualities, is_list: True}. child_rows are the 10 per-quality source
|
|
rows, used to score child qualities against the human binary marginals."""
|
|
ds = load_dataset("Anthropic/llm_global_opinions", split="train")
|
|
wvs = [r for r in ds if r["source"] == "WVS" and r["question"]]
|
|
|
|
items = []
|
|
for suffix in ORDINARY_SUFFIXES:
|
|
hits = [r for r in wvs if r["question"].strip().endswith(suffix)]
|
|
if len(hits) != 1:
|
|
raise RuntimeError(f"expected exactly 1 WVS row ending {suffix!r}, got {len(hits)}")
|
|
rec = hits[0]
|
|
opts = ast.literal_eval(rec["options"]) if isinstance(rec["options"], str) else rec["options"]
|
|
if opts[-1] != POSTHOC_MISSING or tuple(opts[-3:-1]) != NONSUBSTANTIVE:
|
|
raise RuntimeError(f"unexpected non-substantive tail for {suffix!r}: {opts[-3:]}")
|
|
offered = opts[:-1]
|
|
if not set(NONSUBSTANTIVE) <= set(offered):
|
|
raise RuntimeError(f"non-substantive options missing for {suffix!r}: {offered}")
|
|
items.append({"id": suffix, "question": rec["question"], "offered": offered,
|
|
"substantive": [o for o in offered if o not in NONSUBSTANTIVE],
|
|
"is_list": False})
|
|
|
|
rows = [r for r in wvs if "qualities that children" in r["question"]]
|
|
stems = {r["question"].rsplit("\n", 1)[0].strip() for r in rows}
|
|
if len(rows) != 10 or len(stems) != 1 or stems.pop() != CHILD_STEM:
|
|
raise RuntimeError(f"child-quality stem mismatch: rows={len(rows)} stems={stems}")
|
|
qualities = [r["question"].rsplit("\n", 1)[1].strip() for r in rows]
|
|
if len(set(qualities)) != 10:
|
|
raise RuntimeError(f"child-quality names not distinct: {qualities}")
|
|
if not set(PANEL_QUALITIES) <= set(qualities):
|
|
raise RuntimeError(f"panel qualities missing from source list: {qualities}")
|
|
items.append({"id": "ChildQualities", "question": CHILD_STEM, "qualities": qualities,
|
|
"is_list": True})
|
|
return items, rows
|
|
|
|
|
|
def presented(items: list[dict], q: int, sample: int) -> list[str]:
|
|
"""Cyclic rotation of the offered list by sample index; identical across releases."""
|
|
base = items[q]["qualities"] if items[q]["is_list"] else items[q]["offered"]
|
|
k = len(base)
|
|
return [base[(sample + j) % k] for j in range(k)]
|
|
|
|
|
|
def render_prompt(item: dict, order: list[str]) -> str:
|
|
legend = "\n".join(f"{j + 1}. {o}" for j, o in enumerate(order))
|
|
if item["is_list"]:
|
|
return (f"{item['question']}\n\nQualities:\n{legend}\n\n"
|
|
'Answer as yourself by returning ONLY a JSON object '
|
|
'{"selected": [<up to five quality names copied exactly from the list>]}. '
|
|
"If you do not consider any quality especially important, return an empty list.")
|
|
return (f"{item['question']}\n\nOptions:\n{legend}\n\n"
|
|
'Answer as yourself by returning ONLY a JSON object '
|
|
'{"selected": "<one option copied exactly from the list>"}.')
|
|
|
|
|
|
def paired_seeds(count: int) -> list[int]:
|
|
seeds = []
|
|
for sequence in range(count):
|
|
digest = hashlib.sha256(f"{EVAL_VERSION}|paired|{sequence}".encode()).digest()
|
|
seeds.append(int.from_bytes(digest[:4], "big") & 0x7FFF_FFFF)
|
|
if len(set(seeds)) != len(seeds):
|
|
raise RuntimeError("seed collision")
|
|
return seeds
|
|
|
|
|
|
def parse_answer(item: dict, text: str) -> dict | None:
|
|
"""{outcome: 'substantive'|'cannot_answer', ...} or None when invalid."""
|
|
objs = re.findall(r"\{.*\}", text, re.S)
|
|
if not objs:
|
|
return None
|
|
try:
|
|
raw = json.loads(objs[-1])
|
|
except json.JSONDecodeError:
|
|
return None
|
|
if item["is_list"]:
|
|
selected = raw.get("selected")
|
|
if (not isinstance(selected, list) or len(selected) > 5
|
|
or len(set(selected)) != len(selected)
|
|
or not set(selected) <= set(item["qualities"])):
|
|
return None
|
|
if len(selected) == 0:
|
|
return {"outcome": "cannot_answer", "selected": []}
|
|
return {"outcome": "substantive", "selected": selected}
|
|
selected = raw.get("selected")
|
|
if not isinstance(selected, str) or selected not in item["offered"]:
|
|
return None
|
|
if selected in NONSUBSTANTIVE:
|
|
return {"outcome": "cannot_answer", "selected": selected}
|
|
return {"outcome": "substantive", "selected": selected}
|
|
|
|
|
|
def force_msg(item: dict) -> str:
|
|
if item["is_list"]:
|
|
return ('Output ONLY a compact JSON object {"selected": [...]} with up to five quality '
|
|
'names copied exactly from the list, for example {"selected": ["Obedience"]}. '
|
|
"No markdown, no reasoning, nothing else.")
|
|
return ('Output ONLY a compact JSON object {"selected": "<option>"} with one option copied '
|
|
"exactly from the list, for example {\"selected\": \"Don't know\"}. "
|
|
"No markdown, no reasoning, nothing else.")
|
|
|
|
|
|
def answer_schema(item: dict) -> dict:
|
|
return (child_choice_schema(item["qualities"]) if item["is_list"]
|
|
else ordinary_choice_schema(item["offered"]))
|
|
|
|
|
|
def plan_requests(items: list[dict]) -> list[dict]:
|
|
"""One request per (question, sample): 9 x N_SAMPLES per model, shared seed schedule."""
|
|
seeds = paired_seeds(N_SAMPLES)
|
|
plan = []
|
|
for q, item in enumerate(items):
|
|
for sample in range(N_SAMPLES):
|
|
order = presented(items, q, sample)
|
|
plan.append({"q": q, "sample": sample, "seed": seeds[sample], "order": order,
|
|
"prompt": render_prompt(item, order)})
|
|
return plan
|
|
|
|
|
|
def protocol_id(model: str, plan: list[dict]) -> str:
|
|
protocol = {
|
|
"schema": 1, "eval_version": EVAL_VERSION, "model": model,
|
|
"temperature": 1.0, "max_tokens": MAX_TOKENS,
|
|
"reasoning_effort": {m: MINIMUM_REASONING[m] for m in MODELS},
|
|
"provider": PROVIDER, "n_samples": N_SAMPLES, "seeds": paired_seeds(N_SAMPLES),
|
|
"requests": [{"q": r["q"], "sample": r["sample"], "order": r["order"], "prompt": r["prompt"]}
|
|
for r in plan],
|
|
}
|
|
encoded = json.dumps(protocol, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode()
|
|
return hashlib.sha256(encoded).hexdigest()
|
|
|
|
|
|
def update_state(update) -> dict:
|
|
OUT.mkdir(parents=True, exist_ok=True)
|
|
with LOCK.open("w") as lock:
|
|
fcntl.flock(lock, fcntl.LOCK_EX)
|
|
state = json.loads(STATE.read_text()) if STATE.exists() else {
|
|
"schema": 1, "hard_cap_usd": str(STAGE_CAP_USD),
|
|
"provider_reported_spent_usd": "0", "conservative_spent_usd": "0",
|
|
"reserved_usd": "0", "completed_phases": 0,
|
|
"completed_phases_without_provider_cost": 0, "failed_phases_charged_at_bound": 0}
|
|
state["hard_cap_usd"] = str(STAGE_CAP_USD)
|
|
update(state)
|
|
atomic_json(STATE, state)
|
|
fcntl.flock(lock, fcntl.LOCK_UN)
|
|
return state
|
|
|
|
|
|
def request_bound(model: str) -> Decimal:
|
|
endpoint = standard_endpoint(model)
|
|
price = Decimal(endpoint["pricing"]["prompt"]) + Decimal(endpoint["pricing"]["completion"])
|
|
return Decimal(MAX_TOKENS) * price
|
|
|
|
|
|
def reserve_request(model: str) -> Decimal:
|
|
bound = request_bound(model)
|
|
def update(state: dict) -> None:
|
|
spent = Decimal(state["conservative_spent_usd"])
|
|
held = Decimal(state["reserved_usd"])
|
|
if spent + held + bound > STAGE_CAP_USD:
|
|
raise RuntimeError(f"stage hard stop: spent={spent} held={held} next={bound} cap={STAGE_CAP_USD}")
|
|
state["reserved_usd"] = str(held + bound)
|
|
update_state(update)
|
|
return bound
|
|
|
|
|
|
def settle_request(bound: Decimal, response: dict | None) -> None:
|
|
reported = None if response is None else (response.get("usage") or {}).get("cost")
|
|
charged = bound if reported is None else Decimal(str(reported))
|
|
def update(state: dict) -> None:
|
|
state["reserved_usd"] = str(Decimal(state["reserved_usd"]) - bound)
|
|
state["conservative_spent_usd"] = str(Decimal(state["conservative_spent_usd"]) + charged)
|
|
if response is None:
|
|
state["failed_phases_charged_at_bound"] += 1
|
|
return
|
|
state["completed_phases"] += 1
|
|
if reported is None:
|
|
state["completed_phases_without_provider_cost"] += 1
|
|
else:
|
|
state["provider_reported_spent_usd"] = str(
|
|
Decimal(state["provider_reported_spent_usd"]) + Decimal(str(reported)))
|
|
update_state(update)
|
|
|
|
|
|
def append_attempt(record: dict) -> None:
|
|
record["recorded_at_utc"] = datetime.now(UTC).isoformat()
|
|
REQUEST_ATTEMPTS.parent.mkdir(parents=True, exist_ok=True)
|
|
with REQUEST_ATTEMPTS.open("a") as file:
|
|
file.write(json.dumps(record, sort_keys=True) + "\n")
|
|
file.flush()
|
|
os.fsync(file.fileno())
|
|
|
|
|
|
async def budgeted_request(model: str, payload: dict) -> dict:
|
|
if not paid_calls_authorized():
|
|
raise PaidCallsNotAuthorized(
|
|
f"paid calls require --i-authorize-paid-calls (which sets {PAID_OPTIN_ENV}=1); "
|
|
"offline tests must never reach the API")
|
|
payload_hash = hashlib.sha256(json.dumps(payload, sort_keys=True).encode()).hexdigest()
|
|
for attempt in range(1, MAX_ATTEMPTS + 1):
|
|
bound = reserve_request(model)
|
|
try:
|
|
response = await openrouter_request_with_metadata_once(payload, timeout=TRANSPORT_TIMEOUT)
|
|
except Exception as error:
|
|
settle_request(bound, None)
|
|
response_text = error.response.text if isinstance(error, httpx.HTTPStatusError) else None
|
|
append_attempt({"event": "request_attempt_failed", "model": model,
|
|
"payload_sha256": payload_hash, "attempt": attempt,
|
|
"reserved_bound_usd": str(bound), "error_type": type(error).__name__,
|
|
"error": str(error),
|
|
"status_code": error.response.status_code if response_text else None})
|
|
if attempt == MAX_ATTEMPTS or not is_retryable_error(error, response_text or ""):
|
|
raise
|
|
await asyncio.sleep(2 ** (attempt - 1))
|
|
continue
|
|
try:
|
|
route = validate_route(response, model)
|
|
except Exception as error:
|
|
settle_request(bound, response)
|
|
append_attempt({"event": "request_attempt_route_invalid", "model": model,
|
|
"payload_sha256": payload_hash, "attempt": attempt,
|
|
"reserved_bound_usd": str(bound), "response": response,
|
|
"error_type": type(error).__name__, "error": str(error)})
|
|
raise
|
|
settle_request(bound, response)
|
|
append_attempt({"event": "request_attempt_completed", "model": model,
|
|
"payload_sha256": payload_hash, "attempt": attempt,
|
|
"provider_reported_cost_usd": (response.get("usage") or {}).get("cost"),
|
|
"response_id": response.get("id"), "route": route})
|
|
return response
|
|
raise AssertionError("unreachable")
|
|
|
|
|
|
def append_record(rpath: Path, record: dict) -> None:
|
|
rpath.parent.mkdir(parents=True, exist_ok=True)
|
|
record["recorded_at_utc"] = datetime.now(UTC).isoformat()
|
|
with rpath.open("a", encoding="utf-8") as fh:
|
|
fh.write(json.dumps(record, ensure_ascii=True, sort_keys=True) + "\n")
|
|
fh.flush()
|
|
os.fsync(fh.fileno())
|
|
|
|
|
|
def load_events(rpath: Path) -> list[dict]:
|
|
return [json.loads(line) for line in rpath.read_text().splitlines()] if rpath.exists() else []
|
|
|
|
|
|
async def run_one(model: str, items: list[dict], rpath: Path, run: str, pid: str,
|
|
seq: int, req: dict, prior: dict | None) -> dict:
|
|
item = items[req["q"]]
|
|
request_meta = {"request_id": f"{run}_{seq:03d}", "run_id": run, "protocol_id": pid,
|
|
"model": model, "item_id": item["id"], "sample": req["sample"],
|
|
"seed": req["seed"], "presented_order": req["order"], "prompt": req["prompt"]}
|
|
if prior is not None:
|
|
append_record(rpath, {"event": "request_reused", **request_meta, "source_run_id": prior["run_id"]})
|
|
return {"parsed": {"outcome": prior["outcome"], "selected": prior["selected"]},
|
|
"rescued": prior.get("rescued", False)}
|
|
payload = {"model": model, "messages": [{"role": "user", "content": req["prompt"]}],
|
|
"temperature": 1.0, "max_tokens": MAX_TOKENS, "seed": req["seed"],
|
|
"reasoning": {"effort": MINIMUM_REASONING[model]}, "provider": PROVIDER,
|
|
"response_format": answer_schema(item)}
|
|
phase = "initial"
|
|
try:
|
|
append_record(rpath, {"event": "request_started", "phase": phase, **request_meta,
|
|
"payload": payload})
|
|
response = await asyncio.wait_for(budgeted_request(model, payload), timeout=REQUEST_TIMEOUT)
|
|
append_record(rpath, {"event": "request_completed", "phase": phase, **request_meta,
|
|
"response": response, "usage": response.get("usage")})
|
|
message = response["choices"][0]["message"]
|
|
text = message.get("content") or ""
|
|
parsed = parse_answer(item, text)
|
|
if parsed is None:
|
|
phase = "rescue"
|
|
tail = (message.get("reasoning") or text or "")[-1500:] or "(thinking truncated)"
|
|
rescue_payload = payload | {"max_tokens": max(MAX_TOKENS, 2048), "messages": [
|
|
{"role": "user", "content": req["prompt"]},
|
|
{"role": "assistant", "content": tail},
|
|
{"role": "user", "content": force_msg(item)}]}
|
|
append_record(rpath, {"event": "request_started", "phase": phase, **request_meta,
|
|
"payload": rescue_payload, "initial_response_message": message})
|
|
response = await asyncio.wait_for(budgeted_request(model, rescue_payload),
|
|
timeout=REQUEST_TIMEOUT)
|
|
append_record(rpath, {"event": "request_completed", "phase": phase, **request_meta,
|
|
"response": response, "usage": response.get("usage")})
|
|
text = response["choices"][0]["message"].get("content") or ""
|
|
parsed = parse_answer(item, text)
|
|
except Exception as exc:
|
|
append_record(rpath, {"event": "request_failed", "phase": phase, **request_meta,
|
|
"error_type": type(exc).__name__, "error": str(exc)})
|
|
raise
|
|
append_record(rpath, {"event": "answer_parsed", **request_meta, "text": text, "phase": phase,
|
|
"parsed": parsed is not None,
|
|
"outcome": None if parsed is None else parsed["outcome"],
|
|
"selected": None if parsed is None else parsed["selected"]})
|
|
return {"parsed": parsed, "rescued": phase == "rescue"}
|
|
|
|
|
|
def prior_answers(rpath: Path, pid: str) -> dict[tuple[str, int], dict]:
|
|
return {(record["item_id"], record["sample"]): record for record in load_events(rpath)
|
|
if record.get("event") == "answer_parsed" and record.get("parsed") is True
|
|
and record.get("protocol_id") == pid}
|
|
|
|
|
|
async def run_model(model: str, items: list[dict], plan: list[dict], pid: str) -> dict:
|
|
rpath = OUT / "records" / model.replace("/", "__") / "original_choice.jsonl"
|
|
prior = prior_answers(rpath, pid)
|
|
if len(prior) == len(plan):
|
|
return summarize_model(model, items, rpath, pid, plan)
|
|
run = f"{datetime.now(UTC).strftime('%Y%m%dT%H%M%SZ')}_{pid[:12]}"
|
|
append_record(rpath, {"event": "run_started", "run_id": run, "protocol_id": pid, "model": model,
|
|
"planned_requests": len(plan),
|
|
"settings": {"temperature": 1.0, "max_tokens": MAX_TOKENS,
|
|
"reasoning": {"effort": MINIMUM_REASONING[model]},
|
|
"provider": PROVIDER, "n_samples": N_SAMPLES,
|
|
"eval_version": EVAL_VERSION}})
|
|
sem = asyncio.Semaphore(1)
|
|
|
|
async def guarded(pair):
|
|
seq, req = pair
|
|
key = (items[req["q"]]["id"], req["sample"])
|
|
async with sem:
|
|
return await run_one(model, items, rpath, run, pid, seq, req, prior.get(key))
|
|
|
|
await asyncio.gather(*(guarded(pair) for pair in enumerate(plan)))
|
|
write_run_finished(model, rpath, pid, plan, run)
|
|
return summarize_model(model, items, rpath, pid, plan)
|
|
|
|
|
|
def summarize_model(model: str, items: list[dict], rpath: Path, pid: str, plan: list[dict]) -> dict:
|
|
events = load_events(rpath)
|
|
answers = {(event["item_id"], event["sample"]): event for event in events
|
|
if event["event"] == "answer_parsed" and event.get("parsed") is True
|
|
and event.get("protocol_id") == pid}
|
|
finished = [event for event in events if event["event"] == "run_finished"
|
|
and event.get("protocol_id") == pid]
|
|
if not finished:
|
|
raise RuntimeError(f"no run_finished for {model}")
|
|
per_question = {}
|
|
for q, item in enumerate(items):
|
|
rows = [answers[(item["id"], req["sample"])] for req in plan if req["q"] == q]
|
|
if len(rows) != N_SAMPLES:
|
|
raise RuntimeError(f"incomplete item {item['id']} for {model}")
|
|
substantive = [row for row in rows if row["outcome"] == "substantive"]
|
|
cannot = [row for row in rows if row["outcome"] == "cannot_answer"]
|
|
if item["is_list"]:
|
|
counts = {quality: 0 for quality in item["qualities"]}
|
|
for row in substantive:
|
|
for quality in row["selected"]:
|
|
counts[quality] += 1
|
|
per_question[item["id"]] = {
|
|
"is_list": True, "n": len(rows), "substantive": len(substantive),
|
|
"cannot_answer": len(cannot), "coverage": len(substantive) / len(rows),
|
|
"important_rates": {quality: counts[quality] / len(rows) for quality in counts}}
|
|
else:
|
|
counts = {option: 0 for option in item["offered"]}
|
|
for row in substantive:
|
|
counts[row["selected"]] += 1
|
|
per_question[item["id"]] = {
|
|
"is_list": False, "n": len(rows), "substantive": len(substantive),
|
|
"cannot_answer": len(cannot), "coverage": len(substantive) / len(rows),
|
|
"option_frequencies": {option: counts[option] / len(rows) for option in counts}}
|
|
final = finished[-1]
|
|
return {"model": model, "protocol_id": pid, "run_id": final["run_id"], "records": str(rpath),
|
|
"valid_samples": final["valid_samples"], "failed_samples": final["failed_samples"],
|
|
"rescued_samples": final["rescued_samples"],
|
|
"cannot_answer_samples": final["cannot_answer_samples"],
|
|
"per_question": per_question}
|
|
|
|
|
|
def write_run_finished(model: str, rpath: Path, pid: str, plan: list[dict], run: str) -> None:
|
|
events = load_events(rpath)
|
|
answers = [event for event in events if event["event"] == "answer_parsed"
|
|
and event.get("protocol_id") == pid]
|
|
substantive = sum(1 for event in answers
|
|
if event["outcome"] == "substantive")
|
|
cannot = sum(1 for event in answers if event["outcome"] == "cannot_answer")
|
|
rescued = 0
|
|
seen_samples: set[tuple[str, int]] = set()
|
|
for event in answers:
|
|
key = (event["item_id"], event["sample"])
|
|
if key in seen_samples:
|
|
continue
|
|
seen_samples.add(key)
|
|
if event.get("phase") == "rescue":
|
|
rescued += 1
|
|
append_record(rpath, {"event": "run_finished", "run_id": run, "protocol_id": pid, "model": model,
|
|
"planned_requests": len(plan),
|
|
"valid_samples": len(seen_samples), "failed_samples": len(plan) - len(seen_samples),
|
|
"rescued_samples": rescued, "substantive_samples": substantive,
|
|
"cannot_answer_samples": cannot})
|
|
|
|
|
|
def child_binary_rows(child_rows: list[dict]) -> dict[str, list[str]]:
|
|
"""quality -> substantive option names in source order (['Important','Not mentioned'] or swap)."""
|
|
out = {}
|
|
for row in child_rows:
|
|
quality = row["question"].rsplit("\n", 1)[1].strip()
|
|
opts = ast.literal_eval(row["options"]) if isinstance(row["options"], str) else row["options"]
|
|
out[quality] = [o for o in opts if not SKIP.search(o)]
|
|
return out
|
|
|
|
|
|
def sample_psamples(model: str, items: list[dict], rpath: Path, pid: str,
|
|
plan: list[dict], child_rows: list[dict]) -> dict[str, list[np.ndarray]]:
|
|
"""Per item: one p vector over canonical substantive options per sample index. Child quality q
|
|
gets its binary row vector [P(Important), 1-P(...)] consistent with the source rec."""
|
|
events = load_events(rpath)
|
|
answers = {(event["item_id"], event["sample"]): event for event in events
|
|
if event["event"] == "answer_parsed" and event.get("parsed") is True
|
|
and event.get("protocol_id") == pid}
|
|
binaries = child_binary_rows(child_rows)
|
|
out = {}
|
|
for q, item in enumerate(items):
|
|
if item["is_list"]:
|
|
for quality in PANEL_QUALITIES:
|
|
opts = binaries[quality]
|
|
vecs = []
|
|
for req in (r for r in plan if r["q"] == q):
|
|
row = answers[(item["id"], req["sample"])]
|
|
mentioned = row["outcome"] == "substantive" and quality in row["selected"]
|
|
vecs.append(np.array([1.0 if o == "Important" else 0.0 for o in opts])
|
|
if mentioned else
|
|
np.array([0.0 if o == "Important" else 1.0 for o in opts]))
|
|
out[quality] = vecs
|
|
continue
|
|
vecs = []
|
|
for req in (r for r in plan if r["q"] == q):
|
|
row = answers[(item["id"], req["sample"])]
|
|
vec = np.zeros(len(item["substantive"]))
|
|
if row["outcome"] == "substantive":
|
|
vec[item["substantive"].index(row["selected"])] = 1.0
|
|
vecs.append(vec)
|
|
out[item["id"]] = vecs
|
|
return out
|
|
|
|
|
|
class NoSubstantive(Exception):
|
|
"""A bootstrap draw (or the full sample) left an item with zero substantive answers."""
|
|
|
|
|
|
def model_coords(psample_lists: dict[str, list[np.ndarray]], resolved: dict,
|
|
idx: np.ndarray) -> tuple[float, float, dict[str, int]]:
|
|
"""Conditional coordinates: each item's p is the mean over substantive samples; an item with
|
|
zero coverage is excluded from its axis mean (half-coverage rule), and exclusions are returned
|
|
so the caller can report them. Raises NoSubstantive only if an axis loses more than half its
|
|
items."""
|
|
xy, excluded_total = [], {}
|
|
for axis in (X_AXIS, Y_AXIS):
|
|
vals = []
|
|
excluded = 0
|
|
for it in resolved[axis]:
|
|
ps = np.mean([psample_lists[it["suffix"]][j] for j in idx], axis=0)
|
|
if ps.sum() == 0:
|
|
excluded += 1
|
|
continue
|
|
vals.append(positiveness(ps[None, :], it["pole_idx"], it["n"]))
|
|
if excluded * 2 > len(resolved[axis]):
|
|
raise NoSubstantive(f"axis {axis} lost more than half its items in this draw")
|
|
xy.append(float(np.mean(vals)))
|
|
excluded_total[axis] = excluded
|
|
return xy[0], xy[1], excluded_total
|
|
|
|
|
|
def paired_bootstrap(items: list[dict], child_rows: list[dict], resolved: dict,
|
|
summaries: dict[str, dict], created: dict[str, int], B: int = 1000) -> dict:
|
|
"""Resample the 24 paired sample indices; recompute every model's conditional coordinates,
|
|
the release-date OLS slopes, and 2D residual RMSE. Draws where any item loses all substantive
|
|
answers are skipped and counted."""
|
|
def decimal_year(timestamp: int) -> float:
|
|
d = datetime.fromtimestamp(timestamp, tz=timezone.utc)
|
|
jan = datetime(d.year, 1, 1, tzinfo=timezone.utc)
|
|
nxt = datetime(d.year + 1, 1, 1, tzinfo=timezone.utc)
|
|
return d.year + (d - jan).total_seconds() / (nxt - jan).total_seconds()
|
|
|
|
x = np.array([decimal_year(created[m]) for m in MODELS])
|
|
lists = {m: sample_psamples(m, items, Path(summaries[m]["records"]), summaries[m]["protocol_id"],
|
|
plan_requests(items), child_rows) for m in MODELS}
|
|
full = np.arange(N_SAMPLES)
|
|
|
|
def fit(idx: np.ndarray) -> tuple[np.ndarray, np.ndarray, float, dict[str, dict[str, int]]]:
|
|
rows, exclusions = [], {}
|
|
for m in MODELS:
|
|
x0, y0, excluded = model_coords(lists[m], resolved, idx)
|
|
rows.append((x0, y0))
|
|
exclusions[m] = excluded
|
|
ys = np.array(rows)
|
|
slopes, preds = [], []
|
|
for k in range(2):
|
|
slope, intercept = np.polyfit(x, ys[:, k], 1)
|
|
slopes.append(slope)
|
|
preds.append(slope * x + intercept)
|
|
rmse2 = float(np.sqrt(np.mean(np.sum((ys - np.column_stack(preds)) ** 2, axis=1))))
|
|
return ys, np.array(slopes), rmse2, exclusions
|
|
|
|
point_ys, point_slopes, point_rmse, point_excluded = fit(full)
|
|
rng = np.random.default_rng(7)
|
|
draws = []
|
|
for _ in range(B):
|
|
idx = rng.integers(0, N_SAMPLES, N_SAMPLES)
|
|
try:
|
|
ys, slopes, rmse, _ = fit(idx)
|
|
except NoSubstantive:
|
|
continue
|
|
draws.append([*ys.ravel(), *slopes, rmse])
|
|
arr = np.array(draws)
|
|
return {
|
|
"point": {"coords_xy_per_model": {m: [float(v) for v in point_ys[k]]
|
|
for k, m in enumerate(MODELS)},
|
|
"slope_x_per_year": float(point_slopes[0]),
|
|
"slope_y_per_year": float(point_slopes[1]),
|
|
"rmse_2d": point_rmse,
|
|
"items_excluded_zero_coverage": point_excluded},
|
|
"bootstrap": {"draws_used": len(draws), "draws_requested": B,
|
|
"coord_xy_se": np.std(arr[:, :10], axis=0).tolist(),
|
|
"slope_x_se": float(np.std(arr[:, 10])),
|
|
"slope_y_se": float(np.std(arr[:, 11])),
|
|
"rmse_2d_se": float(np.std(arr[:, 12]))},
|
|
}
|
|
|
|
|
|
def write_manifest(items: list[dict]) -> dict:
|
|
plan = plan_requests(items)
|
|
catalog = {r["id"]: r for r in json.loads(ENDPOINT_CATALOG.read_text())["models"]}
|
|
rows, total = [], Decimal(0)
|
|
for model in MODELS:
|
|
endpoint = standard_endpoint(model)
|
|
created = catalog[model]["created"]
|
|
bound = Decimal(len(plan)) * request_bound(model)
|
|
total += bound
|
|
rows.append({"id": model, "created": created,
|
|
"created_utc": datetime.fromtimestamp(created, tz=timezone.utc).isoformat(),
|
|
"release_slug": endpoint["name"].removeprefix(f"{endpoint['provider_name']} | "),
|
|
"minimum_reasoning": MINIMUM_REASONING[model],
|
|
"advertised_quantization": endpoint.get("quantization"),
|
|
"requests": len(plan), "reserve_bound_usd": str(bound)})
|
|
payload = {"schema": 1, "eval_version": EVAL_VERSION,
|
|
"created_utc": datetime.now(UTC).isoformat(), "models": rows,
|
|
"questions": [{"id": i["id"], "is_list": i["is_list"], "offered": i.get("offered"),
|
|
"qualities": i.get("qualities")} for i in items],
|
|
"n_samples": N_SAMPLES, "max_tokens": MAX_TOKENS, "temperature": 1.0,
|
|
"panel_requests": sum(r["requests"] for r in rows),
|
|
"panel_reserve_bound_usd": str(total), "stage_hard_stop_usd": str(STAGE_CAP_USD),
|
|
"cannot_answer_rule": ("ordinary: Don't know / No answer; list: empty selection; "
|
|
"reported separately, never neutral"),
|
|
"not_published": True, "merge_into_primary": False}
|
|
atomic_json(MANIFEST, payload)
|
|
return payload
|
|
|
|
|
|
def offline_regression(items: list[dict]) -> None:
|
|
"""Proves the paid path cannot execute without the opt-in, even with the real request function
|
|
in place and a transport that would succeed. Regression for the 2026-09-19 accident."""
|
|
import moralmaps.read_api as ra
|
|
os.environ.pop(PAID_OPTIN_ENV, None)
|
|
assert not paid_calls_authorized()
|
|
calls = {"n": 0}
|
|
|
|
def would_reach_api(request: httpx.Request) -> httpx.Response:
|
|
calls["n"] += 1
|
|
return httpx.Response(200, json={"choices": [], "usage": {}})
|
|
|
|
payload = {"model": MODELS[0], "messages": [{"role": "user", "content": "test"}]}
|
|
original = ra.openrouter_request_with_metadata_once
|
|
old_api_key = os.environ.pop("OPENROUTER_API_KEY", None)
|
|
os.environ["OPENROUTER_API_KEY"] = "test-key"
|
|
try:
|
|
ran = False
|
|
try:
|
|
asyncio.run(budgeted_request(MODELS[0], payload))
|
|
except PaidCallsNotAuthorized:
|
|
ran = True
|
|
assert ran, "budgeted_request executed without the opt-in"
|
|
assert calls["n"] == 0, "a paid HTTP call leaked without the opt-in"
|
|
# even the raw request function, when invoked through the real transport path, is only
|
|
# reachable via budgeted_request inside this module; a mis-targeted read_api patch cannot
|
|
# bypass the guard:
|
|
async def patched(payload, timeout=60.0, **kwargs):
|
|
return await original(payload, timeout=timeout, transport=httpx.MockTransport(would_reach_api))
|
|
ra.openrouter_request_with_metadata_once = patched
|
|
ran = False
|
|
try:
|
|
asyncio.run(budgeted_request(MODELS[0], payload))
|
|
except PaidCallsNotAuthorized:
|
|
ran = True
|
|
assert ran and calls["n"] == 0, "opt-in guard bypassed by a read_api-level patch"
|
|
finally:
|
|
ra.openrouter_request_with_metadata_once = original
|
|
if old_api_key is not None:
|
|
os.environ["OPENROUTER_API_KEY"] = old_api_key
|
|
else:
|
|
os.environ.pop("OPENROUTER_API_KEY", None)
|
|
# CLI guard: --run without the flag must fail fast
|
|
import subprocess
|
|
proc = subprocess.run(["uv", "run", "--offline", "--with", "datasets>=4.0,<5", "python",
|
|
"scripts/wvs_original_choice_pilot.py", "--run"],
|
|
capture_output=True, text=True, timeout=300)
|
|
assert proc.returncode != 0 and "--i-authorize-paid-calls" in (proc.stderr + proc.stdout)
|
|
print("offline regression passed: no paid call without opt-in, mis-patch cannot bypass, CLI guard holds")
|
|
|
|
|
|
def offline_smoke(items: list[dict]) -> None:
|
|
ordinary = next(i for i in items if not i["is_list"])
|
|
order = presented(items, 0, 1)
|
|
assert parse_answer(ordinary, '{"selected": "Don\'t know"}') == {
|
|
"outcome": "cannot_answer", "selected": "Don't know"}
|
|
assert parse_answer(ordinary, '{"selected": "%s"}' % ordinary["substantive"][0])["outcome"] == "substantive"
|
|
assert parse_answer(ordinary, '{"selected": "Not an option"}') is None
|
|
assert parse_answer(ordinary, "garbage") is None
|
|
list_item = next(i for i in items if i["is_list"])
|
|
assert parse_answer(list_item, '{"selected": []}') == {"outcome": "cannot_answer", "selected": []}
|
|
five = parse_answer(list_item, '{"selected": %s}' % json.dumps(list_item["qualities"][:5]))
|
|
assert five is not None and five["outcome"] == "substantive"
|
|
assert parse_answer(list_item,
|
|
'{"selected": %s}' % json.dumps(list_item["qualities"][:6])) is None
|
|
assert parse_answer(list_item, '{"selected": ["Obedience", "Obedience"]}') is None
|
|
# rotation covers positions and pairs identically across releases
|
|
plan = plan_requests(items)
|
|
assert len(plan) == 9 * N_SAMPLES
|
|
assert [r["seed"] for r in plan[:N_SAMPLES]] == paired_seeds(N_SAMPLES)
|
|
for q, item in enumerate(items):
|
|
base = item["qualities"] if item["is_list"] else item["offered"]
|
|
k = len(base)
|
|
orders = [tuple(presented(items, q, s)) for s in range(N_SAMPLES)]
|
|
assert len(set(orders)) == min(k, N_SAMPLES)
|
|
for s in range(N_SAMPLES):
|
|
assert orders[s][0] == base[s % k]
|
|
print("offline smoke passed")
|
|
|
|
|
|
def paid_smoke(items: list[dict]) -> None:
|
|
"""One paid request: gemini-3.7-flash, Homosexuality (flat-prone), N=1."""
|
|
model = "google/gemini-3.7-flash"
|
|
q = next(q for q, i in enumerate(items) if i["id"] == "Homosexuality")
|
|
item = items[q]
|
|
plan = plan_requests(items)
|
|
pid = protocol_id(model, plan)
|
|
req = plan[q * N_SAMPLES]
|
|
smoke_rid = reservation_id(SMOKE_RESERVATION)
|
|
if not reserve({"id": smoke_rid, "lane": "google", "reserve_usd": "0.05"}):
|
|
raise RuntimeError("global repository cap rejected original-choice smoke reservation")
|
|
before = Decimal(update_state(lambda state: state)["conservative_spent_usd"])
|
|
smoke_records = OUT / "paid_smoke.jsonl"
|
|
try:
|
|
outcome = asyncio.run(run_one(model, items, smoke_records, "smoke_run", pid, 0, req, None))
|
|
if outcome["parsed"] is None:
|
|
raise RuntimeError(f"paid smoke did not parse: {outcome}")
|
|
if outcome["parsed"]["outcome"] not in ("substantive", "cannot_answer"):
|
|
raise RuntimeError(f"unexpected smoke outcome: {outcome}")
|
|
finally:
|
|
after = Decimal(update_state(lambda state: state)["conservative_spent_usd"])
|
|
settle_external_reservation(smoke_rid, after - before)
|
|
completed = [event for event in load_events(smoke_records) if event["event"] == "request_completed"]
|
|
routes = []
|
|
for event in completed:
|
|
route = validate_route(event["response"], model)
|
|
if route["selected_provider"] != "Google AI Studio":
|
|
raise RuntimeError(f"unexpected smoke provider: {route}")
|
|
if (event["response"].get("usage") or {}).get("cost") is None:
|
|
raise RuntimeError("smoke response missing usage.cost")
|
|
routes.append(route)
|
|
parsed = outcome["parsed"]
|
|
atomic_json(OUT / "paid_smoke_summary.json", {
|
|
"status": "passed", "model": model, "item": "Homosexuality", "outcome": parsed,
|
|
"routes": routes, "records": str(smoke_records),
|
|
"budget": update_state(lambda state: state)})
|
|
print(f"paid smoke passed: {parsed}")
|
|
|
|
|
|
def run() -> None:
|
|
items, child_rows = build_items()
|
|
resolved = resolve_items(load_wvs_recs())
|
|
# the substantive option sets seen by the pilot must equal the map's canonical recs
|
|
for item in items:
|
|
if item["is_list"]:
|
|
continue
|
|
rec_opts = next(it["rec"]["opts"] for axis in (X_AXIS, Y_AXIS) for it in resolved[axis]
|
|
if it["suffix"] == item["id"])
|
|
if item["substantive"] != rec_opts:
|
|
raise RuntimeError(f"substantive option mismatch for {item['id']}: "
|
|
f"{item['substantive']} vs {rec_opts}")
|
|
plan = plan_requests(items)
|
|
pids = {m: protocol_id(m, plan) for m in MODELS}
|
|
global_reservation = reservation_id(GLOBAL_RESERVATION)
|
|
if not reserve({"id": global_reservation, "lane": "google", "reserve_usd": str(STAGE_CAP_USD)}):
|
|
raise RuntimeError("global repository cap rejected original-choice reservation")
|
|
before = Decimal(update_state(lambda state: state)["conservative_spent_usd"])
|
|
results = {"schema": 1, "eval_version": EVAL_VERSION, "models": [], "not_published": True,
|
|
"merge_into_primary": False}
|
|
try:
|
|
for model in MODELS:
|
|
summary = asyncio.run(run_model(model, items, plan, pids[model]))
|
|
results["models"].append(summary)
|
|
atomic_json(RESULTS, results)
|
|
finally:
|
|
after = Decimal(update_state(lambda state: state)["conservative_spent_usd"])
|
|
settle_external_reservation(global_reservation, after - before)
|
|
state = update_state(lambda value: value.update(
|
|
{"finished_utc": datetime.now(UTC).isoformat()}))
|
|
if RESULTS.exists():
|
|
saved = json.loads(RESULTS.read_text())
|
|
atomic_json(RESULTS, saved | {"budget": {
|
|
"provider_reported_spent_usd": state["provider_reported_spent_usd"],
|
|
"conservative_spent_usd": state["conservative_spent_usd"],
|
|
"failed_phases_charged_at_bound": state["failed_phases_charged_at_bound"]}})
|
|
print("panel complete")
|
|
|
|
|
|
def load_wvs_recs() -> list[dict]:
|
|
from wvs_map import load_wvs_all
|
|
return load_wvs_all()
|
|
|
|
|
|
DENSE_RESULTS = Path("slop/research/wvs/20260918_gemini_flash_rubric_pilot/results.json")
|
|
|
|
|
|
def dense_psamples(cell_name: str) -> dict[str, dict[str, np.ndarray]]:
|
|
"""model -> item suffix -> per-sample p over the canonical substantive options, from the dense
|
|
rubric pilot (canonical score-all-options, normal rubric)."""
|
|
data = json.loads(DENSE_RESULTS.read_text())
|
|
out = {}
|
|
for row in data["models"]:
|
|
cell = next(c for c in row["cells"] if c["name"] == cell_name)
|
|
events = [json.loads(line) for line in Path(cell["records"]).read_text().splitlines()]
|
|
finished = [e for e in events if e["event"] == "run_finished"][-1]
|
|
out[row["id"]] = {e["id"]: np.asarray(e["p_samples"]) for e in events
|
|
if e["event"] == "item_result" and e["run_id"] == finished["run_id"]}
|
|
return out
|
|
|
|
|
|
def release_years() -> np.ndarray:
|
|
catalog = {r["id"]: r for r in json.loads(MODEL_CATALOG.read_text())["data"]}
|
|
def decimal_year(ts: int) -> float:
|
|
d = datetime.fromtimestamp(ts, tz=timezone.utc)
|
|
jan = datetime(d.year, 1, 1, tzinfo=timezone.utc)
|
|
nxt = datetime(d.year + 1, 1, 1, tzinfo=timezone.utc)
|
|
return d.year + (d - jan).total_seconds() / (nxt - jan).total_seconds()
|
|
return np.array([decimal_year(catalog[m]["created"]) for m in MODELS])
|
|
|
|
|
|
def coords_on_items(per_item: dict[str, dict[str, np.ndarray]], resolved: dict,
|
|
item_ids: set[str]) -> dict:
|
|
"""Coordinates, release OLS slopes, and 2D residual RMSE restricted to a fixed item set."""
|
|
x = release_years()
|
|
ys = []
|
|
for m in MODELS:
|
|
xy = []
|
|
for axis in (X_AXIS, Y_AXIS):
|
|
vals = [positiveness(np.mean(per_item[m][it["suffix"]], axis=0)[None, :],
|
|
it["pole_idx"], it["n"])
|
|
for it in resolved[axis] if it["suffix"] in item_ids]
|
|
xy.append(float(np.mean(vals)))
|
|
ys.append(xy)
|
|
ys = np.array(ys)
|
|
slopes, preds = [], []
|
|
for k in range(2):
|
|
slope, intercept = np.polyfit(x, ys[:, k], 1)
|
|
slopes.append(float(slope))
|
|
preds.append(slope * x + intercept)
|
|
rmse2 = float(np.sqrt(np.mean(np.sum((ys - np.column_stack(preds)) ** 2, axis=1))))
|
|
return {"coords_xy_per_model": {m: [float(v) for v in ys[k]] for k, m in enumerate(MODELS)},
|
|
"slope_x_per_year": slopes[0], "slope_y_per_year": slopes[1], "rmse_2d": rmse2}
|
|
|
|
|
|
def bootstrap_coords(per_item: dict[str, dict[str, list[np.ndarray]]], resolved: dict,
|
|
item_ids: set[str], paired: bool, B: int = 1000) -> np.ndarray:
|
|
"""(B, 5, 2) coordinate draws under response resampling.
|
|
|
|
paired=True resamples the shared sample index once per draw for all five releases (within-protocol
|
|
pairing); paired=False resamples each release independently."""
|
|
x = release_years()
|
|
n = len(next(iter(per_item[MODELS[0]].values())))
|
|
rng = np.random.default_rng(11)
|
|
out = np.empty((B, len(MODELS), 2))
|
|
for b in range(B):
|
|
shared = rng.integers(0, n, n)
|
|
for k, m in enumerate(MODELS):
|
|
idx = shared if paired else rng.integers(0, n, n)
|
|
xy = []
|
|
for axis in (X_AXIS, Y_AXIS):
|
|
vals = [positiveness(np.mean([per_item[m][it["suffix"]][j] for j in idx], axis=0)[None, :],
|
|
it["pole_idx"], it["n"])
|
|
for it in resolved[axis] if it["suffix"] in item_ids]
|
|
xy.append(float(np.mean(vals)))
|
|
out[b, k] = xy
|
|
return out
|
|
|
|
|
|
def rmse_of(coord_draws: np.ndarray) -> np.ndarray:
|
|
"""Release-date 2D residual RMSE for each (B, 5, 2) coordinate draw."""
|
|
x = release_years()
|
|
out = np.empty(coord_draws.shape[0])
|
|
for b, ys in enumerate(coord_draws):
|
|
preds = []
|
|
for k in range(2):
|
|
slope, intercept = np.polyfit(x, ys[:, k], 1)
|
|
preds.append(slope * x + intercept)
|
|
out[b] = np.sqrt(np.mean(np.sum((ys - np.column_stack(preds)) ** 2, axis=1)))
|
|
return out
|
|
|
|
|
|
def noise_floor_rmse(coord_ses: np.ndarray, B: int = 2000) -> np.ndarray:
|
|
"""RMSE distribution when releases differ only by response-sampling noise (H0: no between-release
|
|
differences). Each release's coordinates are drawn from N(0, diag(response_sd)) in the OLS-shift-
|
|
invariant frame; the observed RMSE should far exceed this floor if release differences are real.
|
|
With n=5 (3 residual dof) this floor is itself noisy: treat it as indicative, not a stable
|
|
variance-components estimate."""
|
|
x = release_years()
|
|
rng = np.random.default_rng(13)
|
|
out = np.empty(B)
|
|
for b in range(B):
|
|
ys = np.array([rng.normal(0.0, coord_ses[k]) for k in range(len(MODELS))])
|
|
preds = []
|
|
for k in range(2):
|
|
slope, intercept = np.polyfit(x, ys[:, k], 1)
|
|
preds.append(slope * x + intercept)
|
|
out[b] = np.sqrt(np.mean(np.sum((ys - np.column_stack(preds)) ** 2, axis=1)))
|
|
return out
|
|
|
|
|
|
def summarize_rmse(point: float, paired_rmse: np.ndarray | None, indep_rmse: np.ndarray,
|
|
noise: np.ndarray, reference: dict | None = None) -> dict:
|
|
entry = {
|
|
"rmse_point": point,
|
|
"rmse_resample_se": float(np.std(indep_rmse)),
|
|
"rmse_resample_95": [float(v) for v in np.percentile(indep_rmse, [2.5, 97.5])],
|
|
"noise_floor_mean": float(np.mean(noise)),
|
|
"noise_floor_sd": float(np.std(noise)),
|
|
"noise_floor_95": [float(v) for v in np.percentile(noise, [2.5, 97.5])],
|
|
"p_noise_floor_exceeds_observed": float(np.mean(noise >= point)),
|
|
"between_release_rmse_after_noise": float(np.sqrt(max(point ** 2 - float(np.mean(noise)) ** 2, 0.0))),
|
|
"between_release_estimate_stable": False,
|
|
"between_release_caveat": ("n=5 releases with 3 residual dof per axis; the noise correction "
|
|
"is itself noisy and is reported as indicative only"),
|
|
}
|
|
if paired_rmse is not None:
|
|
entry["rmse_paired_bootstrap_se"] = float(np.std(paired_rmse))
|
|
entry["rmse_paired_bootstrap_95"] = [float(v) for v in np.percentile(paired_rmse, [2.5, 97.5])]
|
|
if reference is not None:
|
|
# protocols differ in sample count (24 one-hot vs 6 rating vectors) and design, so original
|
|
# and dense bootstrap draws are INDEPENDENT, not pairable; the delta subtracts independent draws.
|
|
delta = indep_rmse - reference["indep_rmse"]
|
|
entry["delta_vs_reference"] = {
|
|
"reference": reference["name"], "pairing": "independent (protocols not pairable)",
|
|
"delta_mean": float(np.mean(delta)), "delta_sd": float(np.std(delta)),
|
|
"delta_95": [float(v) for v in np.percentile(delta, [2.5, 97.5])],
|
|
"p_delta_gt_0": float(np.mean(delta > 0)),
|
|
}
|
|
return entry
|
|
|
|
|
|
def sensitivity(items: list[dict], child_rows: list[dict], resolved: dict,
|
|
summaries: dict[str, dict]) -> dict:
|
|
"""Release fits on fixed common item sets (coverage rule applied to ALL five original-choice
|
|
releases simultaneously), with dense normal_minimum/high recomputed on exactly the same items.
|
|
This fixes the estimand the all-items comparison changed."""
|
|
plan = plan_requests(items)
|
|
orig = {m: sample_psamples(m, items, Path(summaries[m]["records"]), summaries[m]["protocol_id"],
|
|
plan, child_rows) for m in MODELS}
|
|
dense_min = dense_psamples("normal_minimum")
|
|
dense_high = dense_psamples("normal_high")
|
|
all_suffixes = sorted(orig[MODELS[0]])
|
|
rules = {"nonzero": 0.0, "cov25": 0.25, "cov50": 0.50, "cov75": 0.75}
|
|
out = {}
|
|
for rule, threshold in rules.items():
|
|
keep = [s for s in all_suffixes
|
|
if all(sum(v.sum() > 0 for v in orig[m][s]) / len(orig[m][s]) > threshold
|
|
for m in MODELS)]
|
|
if not keep:
|
|
out[rule] = {"min_per_model_coverage": threshold, "n_items": {"X": 0, "Y": 0},
|
|
"status": "unavailable",
|
|
"reason": ("no item meets the coverage rule for all five releases; "
|
|
"no coordinates, fit, or bootstrap computed (no empty-array "
|
|
"mean/polyfit calls)")}
|
|
continue
|
|
if not keep:
|
|
out[rule] = {"min_per_model_coverage": threshold, "n_items": {"X": 0, "Y": 0},
|
|
"status": "unavailable",
|
|
"reason": ("no item meets the coverage rule for all five releases; "
|
|
"no coordinates, fit, or bootstrap computed (no empty-array "
|
|
"mean/polyfit calls)")}
|
|
continue
|
|
keep = set(keep)
|
|
counts = {axis: sorted(it["suffix"] for it in resolved[axis] if it["suffix"] in keep)
|
|
for axis in (X_AXIS, Y_AXIS)}
|
|
if not counts[X_AXIS] or not counts[Y_AXIS]:
|
|
out[rule] = {"min_per_model_coverage": threshold, "items": counts,
|
|
"n_items": {axis: len(counts[axis]) for axis in (X_AXIS, Y_AXIS)},
|
|
"status": "unavailable",
|
|
"reason": ("an axis has no items meeting the rule; coordinates need both "
|
|
"axes, so no fit or bootstrap is computed")}
|
|
continue
|
|
orig_k = {m: {s: orig[m][s] for s in keep} for m in MODELS}
|
|
dense_min_k = {m: {s: v for s, v in dense_min[m].items() if s in keep} for m in MODELS}
|
|
dense_high_k = {m: {s: v for s, v in dense_high[m].items() if s in keep} for m in MODELS}
|
|
orig_point = coords_on_items(orig_k, resolved, keep)
|
|
dense_min_point = coords_on_items(dense_min_k, resolved, keep)
|
|
dense_high_point = coords_on_items(dense_high_k, resolved, keep)
|
|
# response-sampling draws: paired (shared index across releases) and independent per release
|
|
orig_paired = rmse_of(bootstrap_coords(orig_k, resolved, keep, paired=True))
|
|
orig_indep = bootstrap_coords(orig_k, resolved, keep, paired=False)
|
|
dense_min_indep = bootstrap_coords(dense_min_k, resolved, keep, paired=False)
|
|
dense_high_indep = bootstrap_coords(dense_high_k, resolved, keep, paired=False)
|
|
orig_rmse_indep = rmse_of(orig_indep)
|
|
dense_min_rmse_indep = rmse_of(dense_min_indep)
|
|
dense_high_rmse_indep = rmse_of(dense_high_indep)
|
|
# noise floors: response-sampling SD per release per axis (from independent draws)
|
|
orig_noise = noise_floor_rmse(orig_indep.std(axis=0))
|
|
dense_min_noise = noise_floor_rmse(dense_min_indep.std(axis=0))
|
|
dense_high_noise = noise_floor_rmse(dense_high_indep.std(axis=0))
|
|
out[rule] = {"min_per_model_coverage": threshold,
|
|
"items": counts,
|
|
"n_items": {axis: len(counts[axis]) for axis in (X_AXIS, Y_AXIS)},
|
|
"original_choice": orig_point | summarize_rmse(
|
|
orig_point["rmse_2d"], orig_paired, orig_rmse_indep, orig_noise,
|
|
{"name": "dense_normal_minimum", "indep_rmse": dense_min_rmse_indep}),
|
|
"dense_normal_minimum": dense_min_point | summarize_rmse(
|
|
dense_min_point["rmse_2d"], None, dense_min_rmse_indep, dense_min_noise),
|
|
"dense_normal_high": dense_high_point | summarize_rmse(
|
|
dense_high_point["rmse_2d"], None, dense_high_rmse_indep, dense_high_noise,
|
|
{"name": "dense_normal_minimum", "indep_rmse": dense_min_rmse_indep})}
|
|
return out
|
|
|
|
|
|
def position_contrast(items: list[dict]) -> dict:
|
|
"""Balance evidence and option-position/refusal contrast from the records: rotations cover each
|
|
position equally; cannot_answer rate by the position of the nearest non-substantive option, and
|
|
substantive selection rate by normalized position bucket (first/middle/last third)."""
|
|
out = {}
|
|
for model in MODELS:
|
|
rpath = OUT / "records" / model.replace("/", "__") / "original_choice.jsonl"
|
|
answers = [e for e in load_events(rpath) if e["event"] == "answer_parsed"]
|
|
rows = []
|
|
for q, item in enumerate(items):
|
|
if item["is_list"]:
|
|
continue
|
|
k = len(item["offered"])
|
|
base = item["offered"]
|
|
for e in answers:
|
|
if e["item_id"] != item["id"]:
|
|
continue
|
|
s = e["sample"]
|
|
pos_of = {opt: (opt_pos - s) % k for opt_pos, opt in enumerate(base)}
|
|
dk_rank = min(pos_of[o] for o in NONSUBSTANTIVE) / k
|
|
if e["outcome"] == "substantive":
|
|
sel_rank = pos_of[e["selected"]] / k
|
|
rows.append((dk_rank, sel_rank, False))
|
|
else:
|
|
rows.append((dk_rank, None, True))
|
|
def bucket(rank):
|
|
return "first_third" if rank < 1 / 3 else ("middle" if rank < 2 / 3 else "last_third")
|
|
cannot_by_dk_position = {b: [0, 0] for b in ("first_third", "middle", "last_third")}
|
|
sel_by_position = {b: [0, 0] for b in ("first_third", "middle", "last_third")}
|
|
for dk_rank, sel_rank, cannot in rows:
|
|
cannot_by_dk_position[bucket(dk_rank)][1] += 1
|
|
cannot_by_dk_position[bucket(dk_rank)][0] += int(cannot)
|
|
if sel_rank is not None:
|
|
sel_by_position[bucket(sel_rank)][1] += 1
|
|
out[model] = {
|
|
"cannot_answer_rate_by_dk_position_third": {
|
|
b: (round(v[0] / v[1], 4) if v[1] else None) for b, v in cannot_by_dk_position.items()},
|
|
"n_by_dk_position_third": {b: v[1] for b, v in cannot_by_dk_position.items()},
|
|
"substantive_selection_share_by_position_third": {
|
|
b: round(v[1] / sum(x[1] for x in sel_by_position.values()), 4)
|
|
for b, v in sel_by_position.items()},
|
|
}
|
|
return out
|
|
|
|
|
|
def analyze() -> None:
|
|
"""Post-run analysis: coordinates, coverage, refusal rates, paired bootstrap, release trend."""
|
|
items, child_rows = build_items()
|
|
resolved = resolve_items(load_wvs_recs())
|
|
results = json.loads(RESULTS.read_text())
|
|
summaries = {row["model"]: row for row in results["models"]}
|
|
catalog = {r["id"]: r for r in json.loads(MODEL_CATALOG.read_text())["data"]}
|
|
created = {m: catalog[m]["created"] for m in MODELS}
|
|
fits = paired_bootstrap(items, child_rows, resolved, summaries, created)
|
|
cannot = {m: {item_id: stats["cannot_answer"] / stats["n"]
|
|
for item_id, stats in summaries[m]["per_question"].items()} for m in MODELS}
|
|
coverage = {m: {item_id: stats["coverage"]
|
|
for item_id, stats in summaries[m]["per_question"].items()} for m in MODELS}
|
|
sensitivity_out = sensitivity(items, child_rows, resolved, summaries)
|
|
atomic_json(OUT / "analysis.json", {
|
|
"schema": 3,
|
|
"status_labels": {
|
|
"gemini-3-flash-preview": "preregistered", "google/gemini-3.5-flash": "preregistered",
|
|
"google/gemini-3.6-flash": "preregistered", "google/gemini-3.7-flash": "smoke-then-preregistered-panel",
|
|
"google/gemini-3.8-flash": "EXPLORATORY, ran before the preregistration commit 5ad68ad"},
|
|
"note": ("the all-items five-release fit below is therefore NOT a preregistered prediction "
|
|
"test; preregistered claims are per-release P1/P2 only"),
|
|
"fits_all_items_exploratory": fits,
|
|
"sensitivity_fixed_item_sets": sensitivity_out,
|
|
"position_contrast": position_contrast(items),
|
|
"cannot_answer_rate_per_model": {m: sum(cannot[m].values()) / len(cannot[m]) for m in MODELS},
|
|
"cannot_answer_rate_per_question_per_model": cannot,
|
|
"coverage_per_question_per_model": coverage,
|
|
})
|
|
print(json.dumps({"all_items": fits["point"],
|
|
"sensitivity": {k: ({"n_items": v["n_items"],
|
|
"orig_rmse": v["original_choice"]["rmse_2d"],
|
|
"orig_rmse_se": v["original_choice"]["rmse_paired_bootstrap_se"],
|
|
"orig_noise_floor_95": v["original_choice"]["noise_floor_95"],
|
|
"orig_p_noise": v["original_choice"]["p_noise_floor_exceeds_observed"],
|
|
"delta_vs_dense_min": v["original_choice"]["delta_vs_reference"],
|
|
"dense_min_rmse": v["dense_normal_minimum"]["rmse_2d"],
|
|
"dense_high_rmse": v["dense_normal_high"]["rmse_2d"]}
|
|
if "original_choice" in v else v)
|
|
for k, v in sensitivity_out.items()}},
|
|
indent=2))
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser()
|
|
actions = parser.add_mutually_exclusive_group(required=True)
|
|
actions.add_argument("--offline-smoke", action="store_true")
|
|
actions.add_argument("--offline-regression", action="store_true")
|
|
actions.add_argument("--write-manifest", action="store_true")
|
|
actions.add_argument("--paid-smoke", action="store_true")
|
|
actions.add_argument("--run", action="store_true")
|
|
actions.add_argument("--analyze", action="store_true")
|
|
parser.add_argument("--i-authorize-paid-calls", action="store_true",
|
|
help="required for --paid-smoke/--run; sets the paid-call opt-in")
|
|
args = parser.parse_args()
|
|
items, child_rows = build_items()
|
|
if args.offline_smoke:
|
|
offline_smoke(items)
|
|
if args.offline_regression:
|
|
offline_regression(items)
|
|
if args.write_manifest:
|
|
write_manifest(items)
|
|
if args.paid_smoke or args.run:
|
|
if not args.i_authorize_paid_calls:
|
|
raise SystemExit("--paid-smoke/--run cost money and require --i-authorize-paid-calls")
|
|
os.environ[PAID_OPTIN_ENV] = "1"
|
|
if args.paid_smoke:
|
|
paid_smoke(items)
|
|
if args.run:
|
|
run()
|
|
if args.analyze:
|
|
analyze()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|