Files
pad/scripts/decision-eval/eval2.py
T
xarmian 7512f5500b docs(decision): README decision-provider section, and the day-73 eval scripts with their recorded baseline (TASK-3123) (#1593)
* docs(decision): README decision-provider section, and the day-73 eval scripts with their recorded baseline (TASK-3123)

Housekeeping for PLAN-3114 tranche 1; no behaviour change.

- README: a Decision provider (optional) section covering what uses it,
  that answers are asynchronous, configuration precedence (config file,
  then the admin setting, then the environment; PAD_DECISION_PROVIDER=none
  as the operator off-switch), and exactly what leaves the box and where.
- scripts/decision-eval/eval.py and eval2.py: the day-73 scripts, as run
  (standard library, raw systemone API, key read from TYPESAFE_API_KEY). A
  read and a pattern scan found no key or token literal.
- scripts/decision-eval/README.md: what each script measures, how to run
  it, how to regenerate the corpora, and the day-73 baseline. A, B and D
  were recomputed from the saved result files with the scripts' own
  summary logic and match PLAN-3114 (D AUC 0.939; the B >=0.8 bucket
  86.8%). C comes from PLAN-3114's trail. eval.py --dry-run over the saved
  corpora queues the same 360 items the day-73 run did.
- scripts/decision-eval/.gitignore: the corpora are private workspace
  exports and the results embed their content, so neither can be
  committed; neither was.

The CHANGELOG line is queued as DOC-3242 (draft highlights for the
release after v0.15.0, per PLAYB-1160), since releases take their
changelog from goreleaser and a highlights DOC.

Claude-Session: https://claude.ai/code/session_016PXRzXhokr61CLbbu3Txj9

* docs(decision): the README section states what off, the triggers, server info and disabling actually do (TASK-3123, codex round 1)

Codex fact-checked every claim against the code, and four were too broad.
Off is not "every surface unchanged": the decisions list is empty and
playbook match answers 404. Jobs are also queued by restore and by comment
edit and delete (verified at items.go:614/3207/3437/5459 and
comments.go:133/196/416), and a closed item's job is dropped at the tick.
pad server info cannot see the admin setting. Disabling stops new calls
without a restart, but an in-flight request may finish. The scripts README
now says eval2.py runs at the top level.

Claude-Session: https://claude.ai/code/session_016PXRzXhokr61CLbbu3Txj9
2026-09-26 22:09:21 -04:00

125 lines
7.2 KiB
Python

#!/usr/bin/env python3
"""Eval 2: objective questions.
C. Executable convention — CONVE-4 (conventional commit format) as a Noul over
real commit subjects; ground truth by regex; plus mutated negatives.
D. Decision detector — "waiting on a decision only a human can make" as a Noul
over docapp human-tasks (positives) vs tasks (negatives).
"""
import json, os, random, re, sys, time, urllib.request, urllib.error
from concurrent.futures import ThreadPoolExecutor, as_completed
HERE = os.path.dirname(os.path.abspath(__file__))
API = "https://api.typesafe.ai/v1/systemone"
KEY = os.environ["TYPESAFE_API_KEY"]
MODEL = "jev-1.13.0"
CC_RE = re.compile(r'^(feat|fix|docs|chore|refactor|test|ci|build|perf|style|revert)(\([^)]+\))?!?: \S')
CONVE4 = "Use conventional commit format for all commit messages: feat:, fix:, docs:, refactor:, test:, chore:. Include scope when relevant, e.g. feat(api): add user endpoint"
def call(state, questions, retries=5):
body = json.dumps({"model": MODEL, "state": state, "questions": questions}).encode()
req = urllib.request.Request(API, data=body, method="POST", headers={"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"})
delay = 1.0
for attempt in range(retries):
try:
with urllib.request.urlopen(req, timeout=60) as r:
return json.load(r)
except urllib.error.HTTPError as e:
if e.code in (429, 529) and attempt < retries - 1:
time.sleep(delay); delay *= 2; continue
raise RuntimeError(f"HTTP {e.code}: {e.read().decode(errors='replace')[:200]}")
raise RuntimeError("retries exhausted")
def run(jobs, workers=8):
out = []
with ThreadPoolExecutor(workers) as ex:
futs = {ex.submit(call, st, q): meta for meta, st, q in jobs}
for f in as_completed(futs):
meta = futs[f]
try:
out.append((meta, f.result()))
except Exception as e:
print(" error", meta.get("id"), e, file=sys.stderr)
return out
def auc(pos, neg):
# probability a random positive scores above a random negative
wins = sum((p > n) + 0.5 * (p == n) for p in pos for n in neg)
return wins / (len(pos) * len(neg))
def report(name, rows, thresholds=(0.5, 0.7, 0.9)):
pos = [p for ok, p in rows if ok]; neg = [p for ok, p in rows if not ok]
print(f"\n{name}: n={len(rows)} (pos={len(pos)} neg={len(neg)}) AUC={auc(pos, neg):.3f} mean p: pos={sum(pos)/len(pos):.2f} neg={sum(neg)/len(neg):.2f}")
for t in thresholds:
tp = sum(p >= t for p in pos); fp = sum(p >= t for p in neg)
fn = len(pos) - tp; tn = len(neg) - fp
prec = tp / max(1, tp + fp); rec = tp / max(1, len(pos))
print(f" threshold {t:.1f}: precision={prec:.1%} recall={rec:.1%} (tp={tp} fp={fp} fn={fn} tn={tn})")
random.seed(73)
# ---- C: conventional commits ----
subjects = [s.strip() for s in open(os.path.join(HERE, "commits.txt")) if s.strip()]
real = subjects[:200]
def mutate(s):
m = CC_RE.match(s)
if not m: return None
rest = s[m.end() - 1:].strip()
kind = random.choice(["drop-type", "space-colon", "caps-type", "no-colon", "bad-type"])
if kind == "drop-type": return rest[0].upper() + rest[1:]
if kind == "space-colon": return s.replace(": ", " : ", 1)
if kind == "caps-type": return s[0].upper() + s[1:]
if kind == "no-colon": return s.replace(": ", " ", 1)
if kind == "bad-type": return "update" + s[s.index(":"):]
mutants = [m for m in (mutate(s) for s in subjects[200:320]) if m][:60]
q_cc = {"follows": {"type": "noul",
"instructions": "The commit message subject line in `state.commit_subject` follows this convention: " + CONVE4,
"criteria": {"true": "the subject starts with one of the listed types (optionally with a (scope) and !), immediately followed by a colon and a space, then a description",
"false": "the subject is missing the type prefix, uses a type not in the list, or the type/colon/space shape is wrong"}}}
jobs = [({"id": f"real{i}", "truth": bool(CC_RE.match(s)), "subject": s}, {"commit_subject": s}, q_cc) for i, s in enumerate(real)]
jobs += [({"id": f"mut{i}", "truth": bool(CC_RE.match(s)), "subject": s}, {"commit_subject": s}, q_cc) for i, s in enumerate(mutants)]
t0 = time.time(); res = run(jobs); dt = time.time() - t0
rows = [(m["truth"], r["answers"]["follows"]["noul"]) for m, r in res]
report(f"C. CONVE-4 conventional-commit Noul ({len(res)} calls in {dt:.0f}s)", rows)
bad = sorted(((abs(m["truth"] - r["answers"]["follows"]["noul"]), m["truth"], r["answers"]["follows"]["noul"], m["subject"]) for m, r in res), reverse=True)[:6]
print(" worst disagreements (truth, p, subject):")
for _, t, p, s in bad: print(f" {t!s:5} {p:.2f} {s[:90]}")
# ---- D: decision detector ----
def load(name):
items = json.load(open(os.path.join(HERE, name)))
out = []
for it in items:
f = it.get("fields") or {}
if isinstance(f, str):
try: f = json.loads(f)
except Exception: f = {}
out.append({"ref": it["ref"], "title": it["title"], "body": (it.get("content") or "")[:5000], "status": f.get("status")})
return out
ht = load("human-tasks.json")
tasks = load("tasks.json"); random.shuffle(tasks); tasks = tasks[:70]
q_dec = {"needs_human_decision": {"type": "noul",
"instructions": "This work item is waiting on a judgment, approval, credential, or decision that only a human owner can supply, rather than on engineering work an AI agent could do unattended.",
"criteria": {"true": "the next step requires a human's decision, approval, sign-off, credentials, money, an external relationship, or hardware the agent cannot reach",
"false": "the next step is implementable by an engineer or AI agent from the description alone"}},
"blocked": {"type": "noul", "instructions": "The item's text says the work is currently blocked on something outside the item itself.",
"criteria": {"true": "an explicit dependency, wait, or blocker is named", "false": "no blocker is stated; the work can start"}}}
jobs = [({"id": it["ref"], "truth": True}, {"title": it["title"], "body": it["body"]}, q_dec) for it in ht]
jobs += [({"id": it["ref"], "truth": False}, {"title": it["title"], "body": it["body"]}, q_dec) for it in tasks]
t0 = time.time(); res = run(jobs); dt = time.time() - t0
rows = [(m["truth"], r["answers"]["needs_human_decision"]["noul"]) for m, r in res]
report(f"D. decision-wearing-a-task-costume Noul ({len(res)} calls in {dt:.0f}s)", rows)
print(" lowest-scoring human-tasks (misses):")
for m, r in sorted(((m, r) for m, r in res if m["truth"]), key=lambda x: x[1]["answers"]["needs_human_decision"]["noul"])[:5]:
print(f" {m['id']} p={r['answers']['needs_human_decision']['noul']:.2f}")
print(" highest-scoring tasks (false alarms):")
for m, r in sorted(((m, r) for m, r in res if not m["truth"]), key=lambda x: -x[1]["answers"]["needs_human_decision"]["noul"])[:5]:
print(f" {m['id']} p={r['answers']['needs_human_decision']['noul']:.2f}")
json.dump([(m, r) for m, r in res], open(os.path.join(HERE, "results-decision.json"), "w"))