bpHigh's picture
Upload via Autoresearch export_data
287ca83 verified
Raw History Blame Contribute Delete
5.96 kB
"""Decisions, questions and the ``/v1/systemone`` wire format.
A *decision* is a state (text, JSON or a list) plus optional images and one or more typed
questions. Each question has an ordered list of labels:
* ``choice``: labels are the option names; ``definitions`` holds each option's criterion.
* ``score``: labels are the level descriptions, index = level (2 to 10 levels).
* ``noul``: labels are always ``["no", "yes"]``; ``definitions`` holds the
``[false, true]`` criteria when given.
Requests carry ``state``, ``questions`` and optionally ``images`` (``data:`` URLs); responses
carry one answer per question with ``probabilities`` (choice, score) or ``noul`` (the yes
probability).
"""
from __future__ import annotations
import math
from dataclasses import dataclass, field
from typing import Any
QUESTION_TYPES = ("choice", "score", "noul")
NOUL_LABELS = ["no", "yes"]
MAX_CHOICE_OPTIONS = 255
MIN_SCORE_LEVELS, MAX_SCORE_LEVELS = 2, 10
MAX_IMAGES = 8
class SchemaError(ValueError):
"""A request that does not follow the schema."""
def normalise(values: list[float]) -> list[float]:
total = float(sum(values))
return [float(v) / total for v in values]
@dataclass
class Question:
type: str
instructions: str
labels: list[str]
definitions: list[str | None] | None = None
def validate(self) -> None:
if self.type not in QUESTION_TYPES:
raise SchemaError(f"unknown question type {self.type!r}")
if not isinstance(self.instructions, str):
raise SchemaError("instructions must be a string")
n = len(self.labels)
if self.type == "noul":
if self.labels != NOUL_LABELS:
raise SchemaError(f"noul labels must be {NOUL_LABELS}, got {self.labels}")
elif self.type == "choice":
if not 2 <= n <= MAX_CHOICE_OPTIONS:
raise SchemaError(f"choice needs 2-{MAX_CHOICE_OPTIONS} options, got {n}")
if len(set(self.labels)) != n:
raise SchemaError("choice labels must be unique")
elif not MIN_SCORE_LEVELS <= n <= MAX_SCORE_LEVELS:
raise SchemaError(f"score needs {MIN_SCORE_LEVELS}-{MAX_SCORE_LEVELS} levels, got {n}")
if any(not isinstance(label, str) or not label for label in self.labels):
raise SchemaError("labels must be non-empty strings")
if self.definitions is not None and len(self.definitions) != n:
raise SchemaError("definitions must align with labels")
@classmethod
def from_wire(cls, q: dict[str, Any]) -> "Question":
qtype = q.get("type")
instructions = q.get("instructions", "") or ""
criteria = q.get("criteria")
if qtype == "choice":
if isinstance(criteria, dict):
labels = [str(k) for k in criteria]
definitions = [None if v is None else str(v) for v in criteria.values()]
elif isinstance(criteria, list):
labels, definitions = [str(c) for c in criteria], None
else:
raise SchemaError("choice question needs criteria")
question = cls("choice", instructions, labels, definitions)
elif qtype == "score":
if not isinstance(criteria, list):
raise SchemaError("score question needs a list of levels")
question = cls("score", instructions, [str(c) for c in criteria], None)
elif qtype == "noul":
definitions = None
if isinstance(criteria, dict):
definitions = [criteria.get("false"), criteria.get("true")]
question = cls("noul", instructions, list(NOUL_LABELS), definitions)
else:
raise SchemaError(f"unknown question type {qtype!r}")
question.validate()
return question
@dataclass
class Decision:
"""One state with its typed questions."""
id: str
state: Any
questions: dict[str, Question]
images: list[dict[str, Any]] = field(default_factory=list)
def validate(self) -> None:
if not isinstance(self.state, (str, dict, list)):
raise SchemaError("state must be a string, object or list")
if not self.questions:
raise SchemaError("a decision needs at least one question")
for name, q in self.questions.items():
if not name:
raise SchemaError("question names must be non-empty")
q.validate()
if len(self.images) > MAX_IMAGES:
raise SchemaError(f"at most {MAX_IMAGES} images per request")
def choice_confidence(probs: list[float]) -> float:
"""(p_max - 1/n) / (1 - 1/n): 0 for a uniform distribution, 1 for a certain one."""
n = len(probs)
return (max(probs) - 1.0 / n) / (1.0 - 1.0 / n)
def score_confidence(probs: list[float]) -> float:
"""1 - (probability-weighted distance from the peak level) / (same for a uniform distribution)."""
n = len(probs)
peak = max(range(n), key=lambda i: probs[i])
spread = sum(p * abs(i - peak) for i, p in enumerate(probs))
uniform = sum(abs(i - peak) for i in range(n)) / n
return 1.0 - spread / uniform if uniform else 1.0
def make_answer(question: Question, probs: list[float]) -> dict[str, Any]:
"""Format a distribution over ``question.labels`` as a ``/v1/systemone`` answer."""
probs = normalise(probs)
if question.type == "noul":
return {"type": "noul", "noul": probs[1]}
if question.type == "choice":
best = max(range(len(probs)), key=lambda i: probs[i])
return {"type": "choice", "choice": question.labels[best],
"probabilities": dict(zip(question.labels, probs)),
"confidence": choice_confidence(probs)}
expected = sum(i * p for i, p in enumerate(probs))
return {"type": "score", "score": expected, "confidence": score_confidence(probs),
"probabilities": {str(i): p for i, p in enumerate(probs)}}