File size: 7,536 Bytes
f8dbde1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
160
161
162
163
164
"""``POST /v1/fill`` request contract (SPEC.md §5). Parsing only; no model runtime yet."""

from __future__ import annotations

import json
import math
from dataclasses import dataclass
from typing import Any

from deg.parsers import DateOrder
from deg.schema import FillPlan, SchemaError, compile_schema

MODEL_ALIASES = frozenset({"deg-latest", "deg-qwen3.5-0.8b-0.1"})
OPTION_KEYS = frozenset(
    {"max_new_tokens", "max_items", "date_order", "default_offset", "temperature", "seed",
     "array_decode", "null_margin"}
)
# how an anchored array's items are found (SPEC.md §12.6): threshold = anchor p > 0.5;
# count_peaks = the count head's N, then the N best anchor peaks; hybrid = threshold, and
# count_peaks when no anchor fires but the count head says N > 0
ARRAY_DECODES = ("threshold", "count_peaks", "hybrid")


class RequestError(ValueError):
    """Client error (HTTP 422) with a list of ``{loc, msg}``."""

    def __init__(self, errors: list[dict[str, Any]], status: int = 422) -> None:
        super().__init__("; ".join(f"{'/'.join(map(str, e['loc']))}: {e['msg']}" for e in errors))
        self.errors = errors
        self.status = status

    def body(self) -> dict[str, Any]:
        return {"error": {"type": "invalid_request", "status": self.status, "detail": self.errors}}


@dataclass(frozen=True)
class FillOptions:
    max_new_tokens: int = 256  # per generate field, on top of maxLength
    # array length cap when the schema gives no maxItems; None = no cap (anchored arrays
    # are unbounded; index-addressed arrays stop at the count head's MAX_COUNT)
    max_items: int | None = None
    date_order: DateOrder | None = None  # numeric a/b/yyyy dates; None = only unambiguous
    default_offset: str = "Z"  # date-time spans without an offset
    temperature: float = 0.0  # 0 = greedy (default); > 0 requires seed
    seed: int | None = None
    # one of ARRAY_DECODES; count_peaks once the count head is trained by anchor_count
    # (run-2b step-4500: results/deg/regress-2b; hybrid was picked at step-3000 before it)
    array_decode: str = "count_peaks"
    # extract null bias in nats (SPEC.md §12.1): a nullable/optional extract leaf is null
    # when its best valid span's log-prob < null log-prob + null_margin. > 0 trades
    # made-up answers for omissions. None = the server's default (--null-margin), else 0
    null_margin: float | None = None


@dataclass(frozen=True)
class FillRequest:
    context: Any
    context_text: str
    plan: FillPlan
    options: FillOptions
    model: str


def render_context(context: Any) -> str:
    """The exact text the model reads once; span offsets index into it (code points)."""
    if isinstance(context, str):
        return context
    return json.dumps(context, ensure_ascii=False, separators=(", ", ": "), allow_nan=False)


def parse_options(raw: Any, errors: list[dict[str, Any]]) -> FillOptions:
    loc = ["body", "options"]
    if raw is None:
        return FillOptions()
    if not isinstance(raw, dict):
        errors.append({"loc": loc, "msg": "options must be an object"})
        return FillOptions()
    for key in raw:
        if key not in OPTION_KEYS:
            errors.append({"loc": [*loc, key], "msg": "unknown option"})
    values = FillOptions().__dict__ | {k: v for k, v in raw.items() if k in OPTION_KEYS}
    for key in ("max_new_tokens", "max_items"):
        v = values[key]
        if key == "max_items" and v is None:
            continue
        if not isinstance(v, int) or isinstance(v, bool) or v < 1:
            errors.append({"loc": [*loc, key], "msg": "must be a positive integer"})
    if values["date_order"] not in (None, "MDY", "DMY"):
        errors.append({"loc": [*loc, "date_order"], "msg": "must be null, 'MDY' or 'DMY'"})
    offset = values["default_offset"]
    if not (isinstance(offset, str) and (offset == "Z" or (
            len(offset) == 6 and offset[0] in "+-" and offset[3] == ":"
            and offset[1:3].isdigit() and offset[4:].isdigit()))):
        errors.append({"loc": [*loc, "default_offset"], "msg": "must be 'Z' or ±HH:MM"})
    if values["array_decode"] not in ARRAY_DECODES:
        errors.append({"loc": [*loc, "array_decode"],
                       "msg": "must be one of " + ", ".join(repr(x) for x in ARRAY_DECODES)})
    margin = values["null_margin"]
    if margin is not None and not (isinstance(margin, (int, float)) and not isinstance(
            margin, bool) and math.isfinite(margin)):
        errors.append({"loc": [*loc, "null_margin"], "msg": "must be null or a finite number"})
    temp = values["temperature"]
    if not (isinstance(temp, (int, float)) and not isinstance(temp, bool)
            and math.isfinite(temp) and temp >= 0):
        errors.append({"loc": [*loc, "temperature"], "msg": "must be a finite number >= 0"})
    seed = values["seed"]
    if seed is not None and (not isinstance(seed, int) or isinstance(seed, bool)):
        errors.append({"loc": [*loc, "seed"], "msg": "must be an integer"})
    elif isinstance(temp, (int, float)) and temp > 0 and seed is None:
        errors.append({"loc": [*loc, "seed"], "msg": "temperature > 0 requires a seed"})
    try:
        return FillOptions(**values)
    except TypeError:
        return FillOptions()


def parse_fill_request(payload: Any) -> FillRequest:
    """Validate ``{context, schema, model?, options?}``. Raises :class:`RequestError`."""
    if not isinstance(payload, dict):
        raise RequestError([{"loc": ["body"], "msg": "request body must be a JSON object"}])
    errors: list[dict[str, Any]] = []
    for key in payload:
        if key not in {"context", "schema", "model", "options"}:
            errors.append({"loc": ["body", key], "msg": "unknown field"})
    context = payload.get("context")
    context_text = ""
    if context is None:
        errors.append({"loc": ["body", "context"], "msg": "field required"})
    else:
        try:
            context_text = render_context(context)
        except (TypeError, ValueError):
            errors.append({"loc": ["body", "context"], "msg": "context must be finite JSON"})
        if not context_text.strip() or context_text in ("{}", "[]"):
            errors.append({"loc": ["body", "context"], "msg": "context must not be empty"})
    model = payload.get("model", "deg-latest")
    if model not in MODEL_ALIASES:
        errors.append({"loc": ["body", "model"], "msg": f"unknown model {model!r}"})
    options = parse_options(payload.get("options"), errors)
    plan = None
    if "schema" not in payload:
        errors.append({"loc": ["body", "schema"], "msg": "field required"})
    else:
        try:
            plan = compile_schema(payload["schema"])
        except SchemaError as exc:
            errors.extend({"loc": ["body", *e["loc"]], "msg": e["msg"]} for e in exc.errors)
    if errors:
        raise RequestError(errors)
    assert plan is not None
    return FillRequest(context, context_text, plan, options, model)


def generate_arg(spec: str) -> str:
    """argparse type of ``--generate``: ``self``, ``base`` (the two-copy hybrid),
    ``base+lora:<dir>`` (the hybrid with a GENERATE LoRA merged into the base copy;
    :func:`deg.model.parse_generate` checks the directory at load) or ``moe`` (a deg-MoE
    checkpoint's W experts; its default)."""
    import argparse

    if spec in ("self", "base", "moe") or (spec.startswith("base+lora:") and len(spec) > 10):
        return spec
    raise argparse.ArgumentTypeError(f"expected self, base, moe or base+lora:<dir>: {spec!r}")