File size: 7,222 Bytes
0a87665
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8a2b0d3
 
 
 
 
 
 
 
 
 
 
 
 
 
0a87665
 
 
 
8a2b0d3
0a87665
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20ddc13
 
0a87665
 
 
 
8a2b0d3
 
 
 
 
 
0a87665
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
"""Backend-agnostic decision engine: request in, Jev-compatible response out.

A backend only has to do two things: return logits for each field's answer
symbols given a rendered prompt, and (for reasoning) continue a prompt until
``</think>``. Everything else, calibration, reasoning modes and the response
shape, lives here so every backend behaves the same.
"""

from __future__ import annotations

import math
import re
import time
from collections.abc import Mapping, Sequence
from typing import Any, Protocol

from .format import Compiled, UNKNOWN, compile_request, confidence, render

REASONING_MODES = {"off", "on", "auto"}


class Backend(Protocol):
    name: str

    def field_logits(self, text: str, compiled: Compiled) -> list[list[float]]: ...

    def continue_reasoning(self, prefix: str, max_tokens: int) -> str: ...

    def count_tokens(self, text: str) -> int: ...


TR_WORDS = r"\b(ve|bir|bu|için|icin|değil|degil|lütfen|lutfen|çok|cok|ama|gibi|ile|mi|mı|mu|mü|ben|biz|siz|istiyorum|gelmedi|yok|var)\b"
DE_WORDS = r"\b(und|nicht|der|die|das|ist|ich|sie|mit|für|auf|bitte|ein|eine|zu|wir|kein|keine|wurde)\b"


def detect_language(text: str) -> str:
    """Cheap hint for localized option text; callers can always pass `language`."""
    lower = text.lower()
    tr = 2 * len(re.findall(r"[ğış]", lower)) + len(re.findall(TR_WORDS, lower))
    de = 2 * len(re.findall(r"[äß]", lower)) + len(re.findall(DE_WORDS, lower))
    if tr > de:
        return "tr"
    if de > tr:
        return "de"
    return "en"


def softmax(logits: Sequence[float], temperature: float = 1.0) -> list[float]:
    top = max(logits)
    weights = [math.exp((x - top) / temperature) for x in logits]
    total = sum(weights)
    return [w / total for w in weights]


class DecisionEngine:
    def __init__(self, backend: Backend, *, model: str, calibration: Mapping[str, Any] | None = None, max_reasoning_tokens: int = 256):
        self.backend = backend
        self.model = model
        self.calibration = dict(calibration or {})
        self.max_reasoning_tokens = max_reasoning_tokens

    def temperature(self, kind: str, lang: str) -> float:
        temps = self.calibration.get("temperatures", {})
        return float(temps.get(f"{kind}:{lang}", temps.get(kind, temps.get("default", 1.0))))

    def conformal_set(self, kind: str, lang: str, table: dict[str, float], coverage: float) -> tuple[list[str], float] | None:
        thresholds = self.calibration.get("conformal", {})
        group = thresholds.get(f"{kind}:{lang}") or thresholds.get(kind) or thresholds.get("default")
        if not group:
            return None
        fitted = sorted(float(k) for k in group)
        level = next((l for l in fitted if l >= coverage), None)
        if level is None:
            return None
        q = float(group[str(level)])
        ranked = sorted(table, key=lambda v: -table[v])
        members = [v for v in ranked if 1.0 - table[v] <= q]
        return (members or ranked[:1], level)

    def auto_threshold(self, kind: str) -> float:
        return float(self.calibration.get("auto_threshold", {}).get(kind, 0.6))

    def _score(self, request: Mapping[str, Any], compiled: Compiled, lang: str) -> dict[str, dict[str, Any]]:
        coverage = request.get("coverage")
        text = render(compiled)
        logits = self.backend.field_logits(text, compiled)
        answers: dict[str, dict[str, Any]] = {}
        for field, row in zip(compiled.fields, logits):
            probs = softmax(row, self.temperature(field.kind, lang))
            table = dict(zip(field.values, probs))
            answer: dict[str, Any] = {"type": field.kind}
            if field.kind == "noul":
                answer["noul"] = table["yes"]
            else:
                answer["probabilities"] = table
                answer["confidence"] = confidence(probs)
                if field.kind == "choice":
                    answer["choice"] = max(field.values, key=lambda v: (table[v], -field.values.index(v)))
                else:
                    question = request["questions"][field.name]
                    levels = [v for v in field.values if v != UNKNOWN]
                    mass = sum(table[v] for v in levels) or 1.0
                    # Expected level position (0-based): list criteria are numbered, named levels count in their given order.
                    answer["score"] = sum(i * table[v] for i, v in enumerate(levels)) / mass
                    criteria = question["criteria"]
                    answer["legend"] = {v: (criteria[int(v)] if isinstance(criteria, list) else criteria[v]) for v in levels}
                if UNKNOWN in table:
                    answer["unknown"] = table[UNKNOWN]
                if coverage:
                    result = self.conformal_set(field.kind, lang, table, float(coverage))
                    if result is not None:
                        answer["set"], answer["coverage"] = result
                    else:
                        answer["coverage"] = None
            answers[field.name] = answer
        return answers

    def _uncertain(self, answers: Mapping[str, Mapping[str, Any]]) -> list[str]:
        out = []
        for name, a in answers.items():
            conf = a.get("confidence")
            if conf is None:  # noul: distance from a coin flip
                conf = abs(2 * a["noul"] - 1)
            if conf < self.auto_threshold(a["type"]):
                out.append(name)
        return out

    def predict(self, request: Mapping[str, Any]) -> dict[str, Any]:
        started = time.perf_counter()
        mode = request.get("reasoning", "off") or "off"
        if mode not in REASONING_MODES:
            raise ValueError(f"reasoning must be one of {sorted(REASONING_MODES)}")
        abstain = bool(request.get("abstain", False))
        state_text = request["state"] if isinstance(request.get("state"), str) else str(request.get("state"))
        lang = request.get("language") or detect_language(state_text + " " + str(request.get("questions")))
        compiled = compile_request(request, abstain=abstain, language=lang)
        answers = self._score(request, compiled, lang)

        trace = None
        redo = list(answers) if mode == "on" else self._uncertain(answers) if mode == "auto" else []
        if redo:
            trace = self.backend.continue_reasoning(render(compiled, close=False), self.max_reasoning_tokens).strip()
            thought = compile_request(request, reasoning=trace, abstain=abstain, language=lang)
            second = self._score(request, thought, lang)
            for name in redo:
                answers[name] = {**second[name], "reasoning": trace}

        text = render(compiled)
        response: dict[str, Any] = {
            "model": self.model,
            "answers": answers,
            "usage": {"input_tokens": self.backend.count_tokens(text), "output_tokens": len(answers) + (self.backend.count_tokens(trace) if trace else 0)},
        }
        if mode != "off":
            response["reasoning_mode"] = mode
            response["reasoned_fields"] = redo
        response["timing_ms"] = round((time.perf_counter() - started) * 1000, 1)
        return response