Files
theclaymethod__unslop/scripts/calibrate_score.py
Clayton Kim 6acd56cda4 Apply WP15 adversarial review fixes (D1-D7)
Eval-first per CLAUDE.md: extended/added CAL-01, CAL-04, CAL-09..11 to pin
each behavior change before touching product code.

D1: em_dash restricted to the paired-dash<->paired-comma path only. A lone
joiner dash has no comma-pair equivalent that preserves sentence count
(the old lone-dash->period path silently split one sentence into two);
lone-dash-only passages now decline with exit 3. base_em_dash.txt fixture
rewritten to use paired dashes on both sentences. CAL-01 now also asserts
sentence count is unchanged between A and B for em_dash.

D2: contraction rewrites now skip matches sitting inside a capitalized
multi-word span (e.g. "Venue Can't Stop"), so a proper noun that happens to
reuse a contractable word is left untouched instead of becoming "Venue
Cannot Stop". Separately, _verify_constraints_preserved now does a
whole-occurrence (token-boundary) comparison instead of substring
containment, since "Venue Can" (a proper noun clipped at an apostrophe by
extract_constraints) is a substring of the corrupted "Venue Cannot" too.
New CAL-09 + evals/fixtures/calibrate/proper_noun_contraction.txt.

D3: generate_pair now runs banned_phrase_scan on both variants and
annotates the output with a_flags/b_flags (category lists, empty when
clean) instead of declining flagged variants. references/calibrate.md gets
a "Voice overrides defaults" section: a user's consistent preference for a
flagged pole is recorded with its flags, surfaced once, and marked "user-
preference overrides register guard" on the card. New CAL-10 +
staccato_flagged.txt / contractions_clean.txt fixtures.

D4: connectives plain pole now emits "But " (no comma), avoiding "So,"/
"Also," forms that read as filler_opener.

D5: calibrate_score.py reports preferred: null (status "tied") when a
dimension's top two tallies are exactly equal, instead of silently picking
whichever label sorts last.

D6: aggregate() dedups preferences by pair_id, keeping the latest row by
ts, so a replayed round counts once and the user's latest choice wins. New
dedup_by_pair_id() + CAL-11.

D7: references/calibrate.md instructs randomizing A/B display order per
round (seeded, reproducible) and recording the mapping so choices resolve
to the correct pole.

preferences_canned.jsonl extended with a staccato tie case and a
connectives pair_id replay case; CAL-04 assertions extended accordingly.
CAL-07's tiebreak premise (connectives lowest-n) still holds since
connectives ends at n=1 post-dedup, still the fixture's minimum.

Full adversarial suite: 334 pass, 1 documented xfail, 0 regressions. All
16 blocking gates verified green; behavioral-tune/rubric-judge (non-
blocking) skipped -- calibrate has no shared-benchmark skill rows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6CYksdLbXbTAxcAQjvHz5
2026-07-06 13:31:12 -07:00

274 lines
9.1 KiB
Python

#!/usr/bin/env python3
"""
Aggregate a teach-calibration preferences JSONL into per-dimension confidence,
surface conflicts against a measured stylometric profile, and pick the next
dimension to play.
Preferences JSONL rows (one per game round):
{"pair_id": "...", "dimension": "contractions", "choice": "a"|"b"|"neither",
"ts": "...", "a_label": "contracted", "b_label": "expanded"}
`a_label`/`b_label` are optional. When present (calibrate_pairs.py's
transform_applied names the B pole; A sits at the other pole in the same
dimension's POLES pair) they let this script report a semantic preferred
DIRECTION ("expanded", "short", "formal", ...) instead of the bare literal
"a"/"b" tally, which is what --profile conflict detection needs. Rows without
labels still count toward n and the confidence interval; they just can't
resolve to a named pole (direction is reported as "a" or "b").
Usage:
python3 calibrate_score.py --preferences prefs.jsonl
python3 calibrate_score.py --preferences prefs.jsonl --profile profile.json
python3 calibrate_score.py --preferences prefs.jsonl --next
"""
from __future__ import annotations
import argparse
import json
import math
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE))
from calibrate_pairs import DIMENSIONS, POLES # noqa: E402
MIN_K = 5
Z = 1.96
# Dimension -> (profile_key, pole_meaning) used only for --profile conflict
# detection. `low_pole`/`high_pole` say which named pole a LOW vs HIGH measured
# value corresponds to, so a confident stated preference for the opposite pole
# of what the measured value implies is flagged.
_PROFILE_LINKS: dict[str, dict] = {
"contractions": {
"profile_key": "contraction_rate",
"low_pole": "expanded",
"high_pole": "contracted",
"low_threshold": 0.05,
"high_threshold": 0.20,
},
"sentence_length": {
"profile_key": "avg_sentence_length",
"low_pole": "short",
"high_pole": "long",
"low_threshold": 12.0,
"high_threshold": 20.0,
},
"staccato": {
"profile_key": "avg_sentence_length",
"low_pole": "staccato",
"high_pole": "flowing",
"low_threshold": 12.0,
"high_threshold": 20.0,
},
"em_dash": {
"profile_key": "em_dash_rate",
"low_pole": "plain",
"high_pole": "dashed",
"low_threshold": 0.02,
"high_threshold": 0.15,
},
"connectives": {
"profile_key": "formal_connective_rate",
"low_pole": "plain",
"high_pole": "formal",
"low_threshold": 0.10,
"high_threshold": 0.40,
},
}
CONFIDENCE_STOP = 0.7
K_STOP = 9
def wilson_lower_bound(successes: int, n: int, z: float = Z) -> float:
if n == 0:
return 0.0
phat = successes / n
denom = 1 + z * z / n
center = phat + z * z / (2 * n)
margin = z * math.sqrt((phat * (1 - phat) + z * z / (4 * n)) / n)
return round(max(0.0, (center - margin) / denom), 3)
def load_preferences(path: Path) -> list[dict]:
rows = []
for line in path.read_text().splitlines():
line = line.strip()
if line:
rows.append(json.loads(line))
return rows
def dedup_by_pair_id(rows: list[dict]) -> list[dict]:
"""Keep one row per `pair_id`: the LATEST by `ts` (ISO 8601, so a plain
string comparison orders correctly). A game round replayed twice (e.g. the
agent crashed mid-write and re-ran the round) must count once toward `n`
and resolve to whatever the user most recently chose, not double-count or
let an earlier write win. Rows without a `pair_id` can't be deduped
against anything, so each is kept as-is.
"""
by_pair_id: dict[str, dict] = {}
passthrough: list[dict] = []
for row in rows:
pid = row.get("pair_id")
if not pid:
passthrough.append(row)
continue
existing = by_pair_id.get(pid)
if existing is None or row.get("ts", "") >= existing.get("ts", ""):
by_pair_id[pid] = row
return list(by_pair_id.values()) + passthrough
def aggregate(rows: list[dict]) -> dict[str, dict]:
rows = dedup_by_pair_id(rows)
result = {dim: {"n": 0, "tally": {}, "neither": 0} for dim in DIMENSIONS}
for row in rows:
dim = row.get("dimension")
if dim not in result:
continue
result[dim]["n"] += 1
choice = row.get("choice")
if choice == "neither":
result[dim]["neither"] += 1
continue
if choice not in ("a", "b"):
continue
label = row.get(f"{choice}_label") or choice
result[dim]["tally"][label] = result[dim]["tally"].get(label, 0) + 1
dimensions = {}
for dim, data in result.items():
n = data["n"]
decisive = sum(data["tally"].values())
if n < MIN_K or decisive == 0:
dimensions[dim] = {
"n": n,
"status": "insufficient",
"preferred": None,
"confidence": 0.0,
}
continue
max_count = max(data["tally"].values())
top_labels = [label for label, count in data["tally"].items() if count == max_count]
if len(top_labels) > 1:
# A genuine tie between the top two (or more) tallies: there is no
# lean to report, so `preferred` must be null rather than
# silently picking whichever label happens to sort last.
dimensions[dim] = {
"n": n,
"status": "tied",
"preferred": None,
"confidence": 0.0,
}
continue
preferred_label = top_labels[0]
confidence = wilson_lower_bound(max_count, decisive)
dimensions[dim] = {
"n": n,
"status": "confident",
"preferred": preferred_label,
"confidence": confidence,
}
return dimensions
def detect_conflicts(dimensions: dict[str, dict], profile: dict) -> list[dict]:
conflicts = []
for dim, data in dimensions.items():
if data["status"] != "confident" or data["confidence"] < CONFIDENCE_STOP:
continue
link = _PROFILE_LINKS.get(dim)
if not link or link["profile_key"] not in profile:
continue
measured = profile[link["profile_key"]]
preferred = data["preferred"]
conflict_pole = None
if preferred == link["low_pole"] and measured >= link["high_threshold"]:
conflict_pole = link["high_pole"]
elif preferred == link["high_pole"] and measured <= link["low_threshold"]:
conflict_pole = link["low_pole"]
if conflict_pole is None:
continue
conflicts.append({
"dimension": dim,
"preferred": preferred,
"preferred_confidence": data["confidence"],
"preferred_provenance": "stated-preference",
"measured_key": link["profile_key"],
"measured_value": measured,
"measured_provenance": "measured-from-samples",
"message": (
f"Stated preference for '{preferred}' ({dim}) contradicts "
f"{link['profile_key']}={measured} measured from samples, "
f"which points toward '{conflict_pole}'."
),
})
return conflicts
def next_dimension(dimensions: dict[str, dict]) -> dict:
def sort_key(dim: str) -> tuple:
data = dimensions[dim]
confidence = data["confidence"] if data["status"] == "confident" else 0.0
return (data["n"], confidence, DIMENSIONS.index(dim))
ordered = sorted(DIMENSIONS, key=sort_key)
chosen = ordered[0]
data = dimensions[chosen]
if data["n"] == min(dimensions[d]["n"] for d in DIMENSIONS):
reason = "fewest_observations"
else:
reason = "lowest_confidence"
return {
"next_dimension": chosen,
"reason": reason,
"n": data["n"],
"confidence": data["confidence"],
}
def parse_args(argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--preferences", required=True)
parser.add_argument("--profile")
parser.add_argument("--next", action="store_true")
return parser.parse_args(argv)
def main(argv: list[str]) -> int:
args = parse_args(argv)
try:
rows = load_preferences(Path(args.preferences))
except OSError as e:
print(json.dumps({"error": f"could not read preferences file: {e}"}))
return 2
dimensions = aggregate(rows)
if args.next:
print(json.dumps(next_dimension(dimensions), indent=2, sort_keys=True))
return 0
output = {"dimensions": dimensions}
if args.profile:
try:
profile = json.loads(Path(args.profile).read_text())
except OSError as e:
print(json.dumps({"error": f"could not read profile file: {e}"}))
return 2
output["conflicts"] = detect_conflicts(dimensions, profile)
print(json.dumps(output, indent=2, sort_keys=True))
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))