sts2-bot/jev.py
0xrsydn b5b5485f8a feat(jev): structured question shapes, Score primitive, per-call answer record
The documented API accepts an object or array wherever a string is accepted:
`instructions`, every Choice option description, every Score level, and the
Noul `criteria.true` / `criteria.false` entries. The client sent bare strings
everywhere and never used `criteria` on a Noul at all.

Add the builders for those shapes:

  * `ask()`        structured instructions: question, focus, inspect, compare
  * `entry()`      a description with what it covers, what it is not for, examples
  * `noul_criteria()` contrastive true/false criteria
  * `score()`      the ordered-level primitive, documented but not implemented
  * `gate_choice()` an explicit floor per Choice call site

A threshold tuned on a Noul is never reused on a Choice: the two answer different
questions and are not on a comparable scale, so each gate states its own pair
(`CHOICE_TOP_MIN`, `CHOICE_MARGIN_MIN`).

Also add `answer_record()` and the `JEV_TRACE` writer, so a decision can carry
the answers that produced it. Without a trace there are no labels for any model
decision, which blocks every question about decision quality.
2026-09-22 06:06:06 +07:00

584 lines
21 KiB
Python

#!/usr/bin/env python3
"""
jev.py -- TypeSafe System One (Jev) client for the STS2 bot.
Jev is a calibrated classifier, not an agent. It picks ONE option from a
set WE define. It cannot plan, cannot call tools, and cannot do arithmetic.
Constraints baked into this client (all measured, see DESIGN.md):
* Never route an arithmetic comparison through Jev. It bucketed a max
damage of 18 correctly but answered "lethal vs 19 HP" as 0.79 yes,
which is wrong. Compute numbers in facts.py and pass conclusions in.
* A wrong answer can still carry high confidence (0.79 in that case).
Confidence gates are a safety net, never a proof.
* Question count is nearly free: 1 question = 0.73s, 3 questions on a
full state = 0.90s. Batch every question for a state into ONE call.
* Because answers to questions in one call are independent, never make a
question depend on another question's answer. Use a second call.
Question shapes follow the documented "advanced: structure" rules: the
`instructions` of every primitive, each Choice option description, each Score
level description and the Noul `criteria.true` / `criteria.false` entries all
accept a string, an object, or an array. Build them with `ask()` and `entry()`:
noul(ask("Would `offered.card0` make this deck stronger?",
focus="Judge deck fit, not raw power.",
inspect="`offered.card0`, `deck`"),
true=entry("It adds damage, block or scaling this deck lacks",
examples=["A 2-cost attack that scales with Strength"]),
false=entry("It is off-plan, redundant, or too slow to matter",
not_for="A card that is merely different"))
choice(ask("Which card best improves this deck, or is skipping better?"),
{f"card{i['index']}": entry(f"{i['name']}: {i['description']}",
not_for="...") for i in cards}
| {"skip": entry("Take nothing; keep the deck lean")})
A threshold tuned on a Noul is never reused on a Choice: state each gate's
floor and margin at the call site (`gate_choice`).
The API key is read from the sops-nix secrets path and is never logged.
"""
from __future__ import annotations
import http.client
import json
import os
import time
import urllib.error
import urllib.request
from dataclasses import dataclass, field
from typing import Any
API_URL = "https://api.typesafe.ai/v1/systemone"
DEFAULT_MODEL = "jev-latest"
ENV_NAMES = ("TYPESAFEAI_API_KEY", "TYPESAFE_API_KEY")
DEFAULT_KEY_PATHS = (
"~/.config/secrets/global-env/TYPESAFEAI_API_KEY",
"~/.config/secrets/global-env/TYPESAFE_API_KEY",
)
RETRYABLE_STATUS = {429, 500, 502, 503, 504}
# When set, every request and its parsed answers are appended as one JSON
# line. State is NOT traced (it can be huge and combat states are already
# dumped by run.py); questions and answers ARE the decision process.
TRACE_PATH = os.environ.get("JEV_TRACE")
# A Noul answer carries no confidence field, so it is gated on its distance
# from 0.5. 0.15 means act when noul >= 0.65 or <= 0.35. A wrong call on a
# low-stakes binary (end the turn? use a potion?) is recoverable in this game.
NOUL_MARGIN = 0.15
# Choice gates are stated per call site, because a threshold tuned on a Noul
# must not be carried over to a Choice: the two answer different questions and
# are not on a comparable scale. These are the DEFAULTS for a low-stakes Choice
# (a card play). Irreversible decisions (events) pass their own stricter pair.
CHOICE_TOP_MIN = 0.45
CHOICE_MARGIN_MIN = 0.20
class JevError(RuntimeError):
"""Raised for transport, auth, and protocol failures."""
# --------------------------------------------------------------------------
# Question builders
# --------------------------------------------------------------------------
def entry(what: Any = None, *, not_for: Any = None, examples: Any = None,
signals: Any = None, **more: Any) -> Any:
"""
One structured description entry, per the documented shapes.
`instructions`, Choice option descriptions, Score level descriptions and
Noul `criteria.true` / `criteria.false` all accept a string, an object, or
an array. Use the object form when a description needs several kinds of
guidance: what it covers, what belongs elsewhere, and examples.
Returns the bare string when only `what` is given, so a simple description
stays simple. The documented field names are used consistently across
entries so the model can compare them directly.
"""
fields = {"what": what, "not_for": not_for, "examples": examples,
"signals": signals, **more}
present = {k: v for k, v in fields.items() if v is not None}
if not present:
return None
if set(present) == {"what"} and isinstance(present["what"], str):
return present["what"]
return present
def ask(question: Any, *, focus: Any = None, inspect: Any = None,
compare: Any = None, **more: Any) -> Any:
"""
Structured instructions: the question plus named guidance fields.
`focus` says what to judge, `inspect` / `compare` point at the exact state
paths the answer depends on (backticked dot-and-index paths). Use it when a
bare sentence would blur several instructions together; a short,
unambiguous question stays a string.
"""
fields = {"focus": focus, "inspect": inspect, "compare": compare, **more}
present = {k: v for k, v in fields.items() if v is not None}
if not present:
return question
return {"question": question, **present}
def noul_criteria(true: Any = None, false: Any = None) -> dict | None:
"""
Contrastive Noul criteria: what a yes means, what a no means.
The instruction and the criteria must ask for the same thing — a Noul whose
`true` maps to "no" is a documented failure mode.
"""
criteria = {k: v for k, v in (("true", true), ("false", false)) if v is not None}
return criteria or None
def noul(instructions: Any, criteria: dict | None = None, *,
true: Any = None, false: Any = None) -> dict:
"""
Yes/no question. Returns only P(yes); there is no confidence field.
Pass `true=` / `false=` for the contrastive criteria, or `criteria=` for a
prebuilt mapping.
"""
q: dict[str, Any] = {"type": "noul", "instructions": instructions}
built = criteria if criteria is not None else noul_criteria(true, false)
if built is not None:
q["criteria"] = built
return q
def choice(instructions: Any, criteria: dict) -> dict:
"""
Pick one option from a map of option -> description (max 255).
Each description is an `entry()`. Give the model the full list rather than
a shortlist, and add an explicit "none of the above"-style option whenever
the set might not cover every input.
"""
return {"type": "choice", "instructions": instructions, "criteria": criteria}
def score(instructions: Any, criteria: list) -> dict:
"""
Position on an ordered list of levels, each an `entry()`.
Use a Score when the answer is a position on a spectrum. The returned
`score` is a fractional position on the level scale, not an index.
"""
return {"type": "score", "instructions": instructions, "criteria": criteria}
# --------------------------------------------------------------------------
# Typed answers
# --------------------------------------------------------------------------
@dataclass(frozen=True)
class NoulAnswer:
noul: float
@property
def yes(self) -> bool:
return self.noul >= 0.5
@property
def kind(self) -> str:
return "noul"
@dataclass(frozen=True)
class ChoiceAnswer:
choice: str
probabilities: dict = field(default_factory=dict)
confidence: float = 0.0
@property
def kind(self) -> str:
return "choice"
def runner_up(self) -> tuple[str, float] | None:
others = [(k, v) for k, v in self.probabilities.items() if k != self.choice]
return max(others, key=lambda kv: kv[1]) if others else None
@dataclass(frozen=True)
class ScoreAnswer:
score: float
legend: dict = field(default_factory=dict)
probabilities: dict = field(default_factory=dict)
confidence: float = 0.0
@property
def kind(self) -> str:
return "score"
@property
def level(self) -> int:
"""Nearest level index. `score` is a FRACTIONAL position on the scale."""
return int(round(self.score))
def level_entry(self) -> Any:
"""The legend entry for the nearest level, keyed by index as a string."""
return self.legend.get(str(self.level))
Answer = NoulAnswer | ChoiceAnswer | ScoreAnswer
@dataclass
class JevResponse:
answers: dict[str, Answer]
model: str
input_tokens: int
output_tokens: int
latency_s: float
def __getitem__(self, qid: str) -> Answer:
return self.answers[qid]
def get(self, qid: str) -> Answer | None:
return self.answers.get(qid)
def summary(self) -> str:
lines = [f"model={self.model} {self.latency_s:.2f}s in={self.input_tokens} out={self.output_tokens}"]
for qid, a in self.answers.items():
if isinstance(a, NoulAnswer):
lines.append(f" {qid:24s} noul={a.noul:.2f} yes={a.yes}")
elif isinstance(a, ChoiceAnswer):
lines.append(f" {qid:24s} choice={a.choice!r} conf={a.confidence:.2f}")
else:
lines.append(f" {qid:24s} score={a.score:.2f} conf={a.confidence:.2f}")
return "\n".join(lines)
# --------------------------------------------------------------------------
# Client
# --------------------------------------------------------------------------
def load_key(key_path: str | None = None) -> str:
"""Resolve the API key. Environment first, then the sops-nix path."""
if key_path:
path = os.path.expanduser(key_path)
if not os.path.exists(path):
raise JevError(f"key file not found: {path}")
with open(path, "r", encoding="utf-8") as fh:
key = fh.read().strip()
if not key:
raise JevError(f"key file is empty: {path}")
return key
for name in ENV_NAMES:
value = os.environ.get(name)
if value and value.strip():
return value.strip()
for candidate in DEFAULT_KEY_PATHS:
path = os.path.expanduser(candidate)
if os.path.exists(path):
with open(path, "r", encoding="utf-8") as fh:
key = fh.read().strip()
if key:
return key
raise JevError(
"no TypeSafe API key found. Looked at env "
+ ", ".join(ENV_NAMES)
+ " and paths "
+ ", ".join(DEFAULT_KEY_PATHS)
)
class JevClient:
"""One client, many batched calls. The key never leaves this object."""
def __init__(
self,
model: str = DEFAULT_MODEL,
timeout: float = 45.0,
retries: int = 2,
key_path: str | None = None,
key: str | None = None,
) -> None:
self._key = key or load_key(key_path)
self.model = model
self.timeout = timeout
self.retries = retries
def __repr__(self) -> str: # never leak the key
return f"JevClient(model={self.model!r}, timeout={self.timeout}, key=REDACTED)"
# -- core call ---------------------------------------------------------
def ask(
self,
state: Any,
questions: dict[str, dict],
model: str | None = None,
) -> JevResponse:
if not questions:
raise JevError("no questions supplied")
if len(questions) > 255:
raise JevError(f"too many questions: {len(questions)} (max 255)")
payload = {
"model": model or self.model,
"state": state,
"questions": questions,
}
body = json.dumps(payload).encode("utf-8")
started = time.monotonic()
last_error: Exception | None = None
for attempt in range(self.retries + 1):
request = urllib.request.Request(
API_URL,
data=body,
method="POST",
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {self._key}",
"User-Agent": "sts2-bot/0.1",
},
)
try:
with urllib.request.urlopen(request, timeout=self.timeout) as response:
raw = response.read()
break
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", "replace")
last_error = JevError(f"HTTP {exc.code}: {detail[:400]}")
if exc.code in RETRYABLE_STATUS and attempt < self.retries:
retry_after = exc.headers.get("retry-after")
delay = float(retry_after) if retry_after else 1.5 * (2 ** attempt)
time.sleep(delay)
continue
raise last_error from exc
except (urllib.error.URLError, TimeoutError) as exc:
last_error = JevError(f"transport failure: {exc}")
if attempt < self.retries:
time.sleep(1.5 * (2 ** attempt))
continue
raise last_error from exc
except (http.client.HTTPException, OSError) as exc:
# RemoteDisconnected is an HTTPException, NOT a URLError, so it
# escaped the handler above and killed a run mid-fight.
last_error = JevError(f"transport failure: {type(exc).__name__}: {exc}")
if attempt < self.retries:
time.sleep(1.5 * (2 ** attempt))
continue
raise last_error from exc
else:
raise last_error or JevError("request failed")
elapsed = time.monotonic() - started
try:
data = json.loads(raw)
except json.JSONDecodeError as exc:
raise JevError(f"non-JSON response: {raw[:200]!r}") from exc
response = self._parse(data, elapsed)
_trace({
"model": response.model,
"latency_s": round(elapsed, 3),
"input_tokens": response.input_tokens,
"output_tokens": response.output_tokens,
"questions": questions,
"answers": {qid: answer_record(a) for qid, a in response.answers.items()},
})
return response
# -- parsing -----------------------------------------------------------
@staticmethod
def _parse(data: dict, elapsed: float) -> JevResponse:
answers: dict[str, Answer] = {}
for qid, item in (data.get("answers") or {}).items():
kind = item.get("type")
if kind == "noul":
answers[qid] = NoulAnswer(noul=float(item.get("noul", 0.0)))
elif kind == "choice":
answers[qid] = ChoiceAnswer(
choice=item.get("choice"),
probabilities=item.get("probabilities") or {},
confidence=float(item.get("confidence", 0.0)),
)
elif kind == "score":
answers[qid] = ScoreAnswer(
score=float(item.get("score", 0.0)),
legend=item.get("legend") or {},
probabilities=item.get("probabilities") or {},
confidence=float(item.get("confidence", 0.0)),
)
else:
raise JevError(f"unknown answer type {kind!r} for question {qid!r}")
usage = data.get("usage") or {}
return JevResponse(
answers=answers,
model=data.get("model", "?"),
input_tokens=int(usage.get("input_tokens", 0)),
output_tokens=int(usage.get("output_tokens", 0)),
latency_s=elapsed,
)
# --------------------------------------------------------------------------
# Trace log (one JSON line per call, path from $JEV_TRACE)
# --------------------------------------------------------------------------
def answer_record(a: Answer) -> dict:
"""Machine-readable form of one answer, for a decision log.
Carries the gate outcome for every primitive, so a gate can be analysed
offline from the log alone instead of being recomputed from the value.
"""
if isinstance(a, NoulAnswer):
return {"kind": "noul", "noul": a.noul, "yes": a.yes, "gated": gate(a)}
if isinstance(a, ChoiceAnswer):
return {
"kind": "choice",
"choice": a.choice,
"probabilities": a.probabilities,
"confidence": a.confidence,
"margin": round(margin(a), 3),
"gated": gate(a),
}
return {
"kind": "score",
"score": a.score,
"confidence": a.confidence,
"probabilities": a.probabilities,
"gated": gate(a),
}
def _trace(record: dict) -> None:
if not TRACE_PATH:
return
try:
line = json.dumps({"ts": time.strftime("%H:%M:%S"), **record},
sort_keys=True, default=str)
with open(TRACE_PATH, "a", encoding="utf-8") as fh:
fh.write(line + "\n")
except OSError:
pass # tracing must never break a run
# --------------------------------------------------------------------------
# Confidence gate
# --------------------------------------------------------------------------
def gate_choice(answer: ChoiceAnswer, top_min: float, margin_min: float) -> bool:
"""
Gate a Choice with thresholds stated for THIS decision.
A threshold tuned on a Noul must not be carried over to a Choice: the two
answer different questions and are not on a comparable scale. So each call
site names the floor and the margin that decision actually needs.
The margin is the scale-free signal; `confidence` is peakedness and falls as
the option count rises, so it is never the primary gate.
"""
top = answer.probabilities.get(answer.choice, 0.0)
return top >= top_min and margin(answer) >= margin_min
def gate(answer: Answer, threshold: float = 0.6) -> bool:
"""
True when the answer is safe to act on automatically.
Do NOT gate a Choice on `confidence` alone. Confidence measures how peaked
the distribution is, so it falls as the option count rises. Measured case:
5 cards, Jev picks Bash at 0.61 with Defend at 0.29 -> confidence 0.50.
That is a clear plurality, yet a fixed 0.55 floor would reject it.
Gate on the margin over the runner-up instead, which is scale-free, plus a
floor on the top probability itself.
Noul has no confidence field, so it is gated on distance from 0.5.
"""
if isinstance(answer, NoulAnswer):
return abs(answer.noul - 0.5) >= NOUL_MARGIN
if isinstance(answer, ChoiceAnswer):
return gate_choice(answer, CHOICE_TOP_MIN, CHOICE_MARGIN_MIN)
return answer.confidence >= threshold
def margin(answer: ChoiceAnswer) -> float:
"""Top probability minus runner-up. Scale-free, unlike confidence."""
top = answer.probabilities.get(answer.choice, 0.0)
runner = max(
(v for k, v in answer.probabilities.items() if k != answer.choice),
default=0.0,
)
return top - runner
# --------------------------------------------------------------------------
# Smoke test
# --------------------------------------------------------------------------
def _selftest() -> int:
client = JevClient()
print(f"[ok] key loaded from secrets store (client={client!r})")
state = {
"combat": {
"energy": 3,
"player": {"hp": 42, "max_hp": 80, "block": 0},
"enemies": [
{"id": "JAW_WORM_0", "hp": 12, "intent": "attacking for 11"},
{"id": "CULTIST_1", "hp": 30, "intent": "buffing strength"},
],
"facts": {"lethal_available": True}, # computed in facts.py, not by Jev
"hand": [
{"name": "Strike", "cost": 1, "text": "Deal 6 damage."},
{"name": "Bash", "cost": 2, "text": "Deal 8 damage. Apply 2 Vulnerable."},
{"name": "Defend", "cost": 1, "text": "Gain 5 Block."},
{"name": "Cleave", "cost": 1, "text": "Deal 8 damage to ALL enemies."},
],
}
}
response = client.ask(
state,
{
"should_prioritize_damage": noul(
"Given the intents in `combat.enemies`, is dealing damage this turn "
"better than gaining block?"
),
"best_play": choice(
"Which single card from `combat.hand` best advances winning this fight?",
{
"Strike": "Deal 6 damage to one enemy.",
"Bash": "Deal 8 damage and apply 2 Vulnerable to one enemy.",
"Defend": "Gain 5 Block.",
"Cleave": "Deal 8 damage to every enemy.",
},
),
"target_priority": choice(
"Which enemy should a single-target attack hit first?",
{
"JAW_WORM_0": "12 HP, attacking for 11 this turn.",
"CULTIST_1": "30 HP, buffing strength this turn.",
},
),
},
)
print(response.summary())
play = response["best_play"]
print(f"[gate] best_play auto-act={'yes' if gate(play) else 'no'}")
return 0
if __name__ == "__main__":
raise SystemExit(_selftest())