mirror of
https://github.com/theclaymethod/unslop.git
synced 2026-09-19 01:45:09 +08:00
6acd56cda4
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
274 lines
9.1 KiB
Python
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:]))
|