sqljev 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
sqljev/core.py ADDED
@@ -0,0 +1,674 @@
1
+ """sqljev.core — plain-language predicates for SQL rows, judged by a System One decision model.
2
+
3
+ j = Jev() # backend "local": Laya in-process
4
+ j.prob(rows, "the customer is angry") # [0.93, 0.08, ...]
5
+ j.call("jev_choice", [(row, "which team?", ["billing", "technical"]), ...])
6
+
7
+ A SQL question is the same question over many rows, so everything here is organised around one
8
+ (question, rows) pair: rows are serialised to compact JSON objects, de-duplicated by content, looked up
9
+ in an answer cache and only the misses are sent to the model, in as few forward passes / requests as the
10
+ backend allows.
11
+
12
+ Backends (setting `backend`, env SQLJEV_BACKEND):
13
+
14
+ local Laya (Convai Innovations, Apache 2.0) in this process via `laya` — `predict_batch` packs
15
+ many rows into shared forward passes. Default. Fastest, free, data never leaves the host.
16
+ gateway a `sqljev gateway` over HTTP (which runs Laya locally) — many rows per request.
17
+ For databases that call out over HTTP: SQL Server, Snowflake, BigQuery, Redshift.
18
+ laya-serve a stock `laya-serve` over the Jev wire protocol (POST /v1/systemone) — one row per request.
19
+ jev TypeSafe's hosted Jev (POST /v1/systemone) — 20 rows per request in one shared state.
20
+
21
+ This file is deliberately one stdlib-only module, so it can be pasted into a database UDF body.
22
+ `laya` (and torch) are imported lazily and only by the `local` backend.
23
+
24
+ The connection pool, retries and the Jev batch prompt are ported from pg-jev
25
+ (https://github.com/realZachi/pg-jev, PostgreSQL License, Copyright (c) 2026 Zachi).
26
+ """
27
+ import hashlib
28
+ import http.client
29
+ import json
30
+ import os
31
+ import random
32
+ import select
33
+ import socket
34
+ import ssl
35
+ import threading
36
+ import time
37
+ from collections import OrderedDict, deque
38
+ from concurrent.futures import ThreadPoolExecutor
39
+ from urllib.parse import urlsplit
40
+
41
+ __version__ = "0.1.0"
42
+
43
+ BACKENDS = ("local", "gateway", "laya-serve", "jev")
44
+ KINDS = ("noul", "score", "choice")
45
+ LAYA_CHECKPOINTS = ("english", "multilingual", "typed-decisions")
46
+ JEV_USD_PER_INPUT_TOKEN = 0.042 / 1_000_000 # jev-1.13 list price; output tokens are free
47
+
48
+ # Per-backend defaults. batch_size means: rows per forward pass (local), rows per HTTP request (the rest).
49
+ BACKEND_DEFAULTS = {
50
+ "local": {"api_url": None, "batch_size": 64, "concurrency": 1},
51
+ "gateway": {"api_url": "http://127.0.0.1:8765/v1/eval", "batch_size": 256, "concurrency": 4},
52
+ # Laya reads one state of 512-1,024 tokens: packing several rows into it would cut rows off.
53
+ "laya-serve": {"api_url": "http://127.0.0.1:8000/v1/systemone", "batch_size": 1, "concurrency": 4},
54
+ # pg-jev measured: batches of 1-20 rows 100 % correct, 40 rows 92-98 %, 80 rows 77-94 %.
55
+ "jev": {"api_url": "https://api.typesafe.ai/v1/systemone", "batch_size": 20, "concurrency": 16,
56
+ "model": "jev-latest"},
57
+ }
58
+ COMMON_DEFAULTS = {"backend": "local", "api_key": None, "model": None, "device": None, "max_len": None,
59
+ "threshold": 0.5, "timeout": 60.0, "keepalive": 600.0, "max_rows": 0, "max_chars": 0,
60
+ "cache_size": 200_000, "drop_nulls": True}
61
+
62
+
63
+ def _bool(v):
64
+ return v if isinstance(v, bool) else str(v).strip().lower() in ("1", "true", "on", "yes")
65
+
66
+
67
+ SETTINGS = {"backend": str, "api_url": str, "api_key": str, "model": str, "device": str, "max_len": int,
68
+ "batch_size": int, "concurrency": int, "threshold": float, "timeout": float, "keepalive": float,
69
+ "max_rows": int, "max_chars": int, "cache_size": int, "drop_nulls": _bool}
70
+
71
+
72
+ class JevError(RuntimeError):
73
+ """Raised for configuration, validation, spend-guard and model/API errors."""
74
+
75
+
76
+ def make_config(env=None, **overrides):
77
+ """Settings from SQLJEV_<NAME> environment variables, then keyword overrides (None = unset),
78
+ then the backend's defaults. Returns a plain dict (picklable, so it travels to Spark executors)."""
79
+ env = os.environ if env is None else env
80
+ cfg = {}
81
+ for name, cast in SETTINGS.items():
82
+ v = env.get("SQLJEV_" + name.upper())
83
+ if v not in (None, ""):
84
+ cfg[name] = cast(v)
85
+ for name, v in overrides.items():
86
+ if name not in SETTINGS:
87
+ raise JevError("sqljev: unknown setting %r (known: %s)" % (name, ", ".join(sorted(SETTINGS))))
88
+ if v is not None:
89
+ cfg[name] = SETTINGS[name](v)
90
+ backend = cfg.get("backend", COMMON_DEFAULTS["backend"])
91
+ if backend not in BACKENDS:
92
+ raise JevError("sqljev: unknown backend %r (one of %s)" % (backend, ", ".join(BACKENDS)))
93
+ for k, v in {**COMMON_DEFAULTS, **BACKEND_DEFAULTS[backend]}.items():
94
+ cfg.setdefault(k, v)
95
+ if not cfg["api_key"]:
96
+ cfg["api_key"] = env.get({"jev": "TYPESAFE_API_KEY", "laya-serve": "LAYA_API_KEY",
97
+ "gateway": "SQLJEV_GATEWAY_TOKEN"}.get(backend, ""), None)
98
+ if backend == "laya-serve":
99
+ cfg["batch_size"] = 1
100
+ cfg["batch_size"] = max(1, cfg["batch_size"])
101
+ cfg["concurrency"] = max(1, cfg["concurrency"])
102
+ return cfg
103
+
104
+
105
+ # ---------------------------------------------------------------- rows
106
+
107
+ def to_row_json(row, drop_nulls=True):
108
+ """Compact JSON text for a row. dicts (column -> value) become objects; SQL NULL columns are dropped
109
+ by default, which saves tokens in Laya's 512-1,024-token window. A string that already holds JSON
110
+ (to_json(t), OBJECT_CONSTRUCT(*), FOR JSON ...) is re-encoded the same way; other strings become a
111
+ JSON string."""
112
+ if isinstance(row, (bytes, bytearray)):
113
+ row = row.decode()
114
+ if isinstance(row, str):
115
+ try:
116
+ row = json.loads(row)
117
+ except ValueError:
118
+ return json.dumps(row, ensure_ascii=False)
119
+ if drop_nulls and isinstance(row, dict):
120
+ row = {k: v for k, v in row.items() if v is not None}
121
+ return json.dumps(row, ensure_ascii=False, separators=(",", ":"), default=str)
122
+
123
+
124
+ def row_hash(text):
125
+ return hashlib.sha1(text.encode()).hexdigest()
126
+
127
+
128
+ def parse_options(v):
129
+ """text[] / ARRAY / list / numpy array / '["a","b"]' / 'a,b' -> list of str, or None."""
130
+ if v is None:
131
+ return None
132
+ if hasattr(v, "tolist"):
133
+ v = v.tolist()
134
+ if isinstance(v, str):
135
+ s = v.strip()
136
+ if s.startswith("["):
137
+ v = json.loads(s)
138
+ else:
139
+ v = [p.strip() for p in s.split(",") if p.strip()]
140
+ return [str(o) for o in v]
141
+
142
+
143
+ def check_question(kind, options):
144
+ if kind not in KINDS:
145
+ raise JevError("sqljev: unknown kind %r (one of noul, score, choice)" % (kind,))
146
+ opts = parse_options(options)
147
+ if kind == "noul":
148
+ return kind, None
149
+ if not opts or len(opts) < 2:
150
+ raise JevError("sqljev: %s needs at least two %s" % (kind, "levels" if kind == "score" else "options"))
151
+ return kind, opts
152
+
153
+
154
+ # ---------------------------------------------------------------- questions: one place, used at inference and for training data
155
+
156
+ def _clause(text):
157
+ return text.strip().rstrip("?.!").strip()
158
+
159
+
160
+ def laya_question(kind, query, opts):
161
+ """The question Laya is asked about one SQL row (the row is the state). `sqljev dataset` builds
162
+ training data with this same function, so a fine-tuned checkpoint sees exactly what it is asked."""
163
+ if kind == "noul":
164
+ # A statement ("the customer is angry") becomes "Is it true that the customer is angry?"; a question
165
+ # is asked as written. On labelled rows this beat "Does this record satisfy ..." phrasings.
166
+ q = query.strip()
167
+ return {"type": "noul", "instructions": q if q.endswith("?") else "Is it true that %s?" % _clause(q)}
168
+ if kind == "score":
169
+ return {"type": "score", "instructions": query, "criteria": list(opts)}
170
+ return {"type": "choice", "instructions": query, "criteria": {o: o for o in opts}}
171
+
172
+
173
+ def jev_question(i, kind, query, opts):
174
+ """pg-jev's batch prompt: one shared state {"condition", "rows"}, one question per row index."""
175
+ ref = "rows[%d]" % i
176
+ if kind == "noul":
177
+ return {"type": "noul",
178
+ "instructions": "Does the record `%s` satisfy the condition stated in `condition`?" % ref}
179
+ if kind == "score":
180
+ return {"type": "score", "instructions": "Rate the record `%s`: %s" % (ref, query), "criteria": opts}
181
+ return {"type": "choice", "instructions": "For the record `%s`: %s" % (ref, query),
182
+ "criteria": {o: None for o in opts}}
183
+
184
+
185
+ # ---------------------------------------------------------------- UDF-style dispatch shared by every database adapter
186
+
187
+ # name -> (kind or None when it is an argument, min args, max args); args start with (row, question)
188
+ FUNCTIONS = {
189
+ "jev": ("noul", 2, 3), # row, condition [, threshold] -> boolean
190
+ "jev_prob": ("noul", 2, 2), # row, condition -> float
191
+ "jev_score": ("score", 3, 3), # row, question, levels -> float 0..n-1
192
+ "jev_score_norm": ("score", 3, 3), # row, question, levels -> float 0..1
193
+ "jev_choice": ("choice", 3, 3), # row, question, options -> text
194
+ "jev_confidence": (None, 4, 4), # row, question, kind, options -> float
195
+ "jev_eval": (None, 2, 4), # row, question [, kind [, options]] -> answer object
196
+ }
197
+
198
+
199
+ def function_name(fn):
200
+ name = str(fn).rsplit(".", 1)[-1].strip('`"[] ').lower()
201
+ if name not in FUNCTIONS:
202
+ raise JevError("sqljev: unknown function %r (one of %s)" % (fn, ", ".join(FUNCTIONS)))
203
+ return name
204
+
205
+
206
+ # ---------------------------------------------------------------- the engine
207
+
208
+ class Jev:
209
+ """Judges SQL rows against plain-language questions. Thread-safe; keep one per process."""
210
+
211
+ def __init__(self, cfg=None, **overrides):
212
+ self.cfg = make_config(**{**(cfg or {}), **overrides})
213
+ self._lock = threading.Lock()
214
+ self._model_lock = threading.Lock()
215
+ self._cache = OrderedDict()
216
+ self._conns = []
217
+ self._pool = None
218
+ self._ssl = None
219
+ self._laya = None
220
+ self._sent = [0, 0] # rows, chars sent since the last reset_budget()
221
+ self._stats = {"requests": 0, "forward_batches": 0, "rows_evaluated": 0, "cache_hits": 0,
222
+ "duplicates": 0, "input_tokens": 0, "output_tokens": 0, "model_ms": 0.0,
223
+ "errors": 0, "retries": 0}
224
+
225
+ # ------------------------------------------------ public API
226
+
227
+ def evaluate(self, rows, query, kind="noul", options=None):
228
+ """Answer dicts aligned with `rows` (None for a None row). Duplicates and cached rows cost nothing."""
229
+ kind, opts = check_question(kind, options)
230
+ key = self._qkey(kind, query, opts)
231
+ texts = [None if r is None else to_row_json(r, self.cfg["drop_nulls"]) for r in rows]
232
+ hashes = [None if t is None else row_hash(t) for t in texts]
233
+ found, todo = {}, {}
234
+ for h, t in zip(hashes, texts):
235
+ if h is None:
236
+ continue
237
+ if h in found or h in todo:
238
+ self._bump("duplicates")
239
+ continue
240
+ a = self._cache_get(key, h)
241
+ if a is None:
242
+ todo[h] = t
243
+ else:
244
+ found[h] = a
245
+ self._bump("cache_hits")
246
+ if todo:
247
+ pairs = list(todo.items())
248
+ bs = self.cfg["batch_size"]
249
+ batches = [pairs[i:i + bs] for i in range(0, len(pairs), bs)]
250
+ self._guard(pairs)
251
+ if len(batches) == 1 or self.cfg["concurrency"] == 1:
252
+ for b in batches:
253
+ found.update(self._run_batch(key, kind, query, opts, b))
254
+ else:
255
+ futs = [self._executor().submit(self._run_batch, key, kind, query, opts, b) for b in batches]
256
+ for f in futs:
257
+ found.update(f.result())
258
+ return [None if h is None else found[h] for h in hashes]
259
+
260
+ def evaluate_iter(self, rows, query, kind="noul", options=None, key=None):
261
+ """Stream (row, answer) pairs in input order with a bounded read-ahead, so a consumer that stops
262
+ early (LIMIT) only pays for the rows in flight. Memory stays constant for any number of rows.
263
+ `key(item)` picks what the model sees of each item (default: the item itself)."""
264
+ kind, opts = check_question(kind, options)
265
+ view = key or (lambda item: item)
266
+ key = self._qkey(kind, query, opts)
267
+ bs, cap = self.cfg["batch_size"], 2 * self.cfg["concurrency"]
268
+ limit = max(bs * cap, 64)
269
+ it, window, inflight, active = iter(rows), deque(), {}, []
270
+ pending, pending_set, exhausted = [], set(), False
271
+
272
+ def submit(pairs):
273
+ self._guard(pairs)
274
+ fut = self._executor().submit(self._run_batch, key, kind, query, opts, pairs)
275
+ for h, _ in pairs:
276
+ inflight[h] = fut
277
+ active.append(fut)
278
+ return fut
279
+
280
+ def flush():
281
+ nonlocal pending
282
+ if pending:
283
+ submit(pending)
284
+ pending_set.clear()
285
+ pending = []
286
+
287
+ while True:
288
+ active[:] = [f for f in active if not f.done()]
289
+ while not exhausted and len(window) < limit and len(active) < cap:
290
+ try:
291
+ row = next(it)
292
+ except StopIteration:
293
+ exhausted = True
294
+ break
295
+ seen = None if row is None else view(row)
296
+ if seen is None:
297
+ window.append([row, None, None, None])
298
+ continue
299
+ t = to_row_json(seen, self.cfg["drop_nulls"])
300
+ h = row_hash(t)
301
+ a = self._cache_get(key, h)
302
+ window.append([row, h, t, a])
303
+ if a is not None:
304
+ self._bump("cache_hits")
305
+ elif h in inflight or h in pending_set:
306
+ self._bump("duplicates")
307
+ else:
308
+ pending.append((h, t))
309
+ pending_set.add(h)
310
+ if len(pending) >= bs:
311
+ flush()
312
+ if exhausted:
313
+ flush()
314
+ if not window:
315
+ return
316
+ row, h, t, a = window[0]
317
+ if a is None and h is not None:
318
+ a = self._cache_get(key, h)
319
+ if a is None:
320
+ if h in pending_set:
321
+ flush()
322
+ fut = inflight.get(h) or submit([(h, t)])
323
+ a = fut.result()[h]
324
+ inflight.pop(h, None)
325
+ window.popleft()
326
+ yield row, a
327
+
328
+ def call(self, fn, calls):
329
+ """Evaluate a UDF call batch: `calls` is a list of argument tuples (row, question, ...) as a
330
+ database hands them over. Calls are grouped by question, so one batch can mix questions."""
331
+ name = function_name(fn)
332
+ fixed_kind, lo, hi = FUNCTIONS[name]
333
+ calls = [tuple(_null(v) for v in c) for c in calls]
334
+ out = [None] * len(calls)
335
+ groups = {}
336
+ for i, args in enumerate(calls):
337
+ if not lo <= len(args) <= hi:
338
+ raise JevError("sqljev: %s takes %d-%d arguments, got %d" % (name, lo, hi, len(args)))
339
+ row, query = args[0], args[1]
340
+ if row is None or query is None:
341
+ continue
342
+ if fixed_kind:
343
+ kind = fixed_kind
344
+ opts = args[2] if kind != "noul" else None
345
+ else:
346
+ kind = args[2] if len(args) > 2 and args[2] is not None else "noul"
347
+ opts = args[3] if len(args) > 3 else None
348
+ kind, opts = check_question(kind, opts)
349
+ gk = (kind, query, None if opts is None else tuple(opts))
350
+ groups.setdefault(gk, []).append(i)
351
+ for (kind, query, opts), idx in groups.items():
352
+ answers = self.evaluate([calls[i][0] for i in idx], query, kind, opts and list(opts))
353
+ for i, a in zip(idx, answers):
354
+ out[i] = self._extract(name, a, calls[i], opts)
355
+ return out
356
+
357
+ def prob(self, rows, condition):
358
+ return self.call("jev_prob", [(r, condition) for r in rows])
359
+
360
+ def where(self, rows, condition, threshold=None):
361
+ return self.call("jev", [(r, condition, threshold) for r in rows])
362
+
363
+ def score(self, rows, question, levels, normalize=False):
364
+ return self.call("jev_score_norm" if normalize else "jev_score", [(r, question, levels) for r in rows])
365
+
366
+ def choice(self, rows, question, options):
367
+ return self.call("jev_choice", [(r, question, options) for r in rows])
368
+
369
+ def stats(self):
370
+ with self._lock:
371
+ s = dict(self._stats)
372
+ s["cached_answers"] = len(self._cache)
373
+ s["pooled_connections"] = len(self._conns)
374
+ s["backend"] = self.cfg["backend"]
375
+ s["estimated_cost_usd"] = round(s["input_tokens"] * JEV_USD_PER_INPUT_TOKEN, 6) \
376
+ if self.cfg["backend"] == "jev" else 0.0
377
+ return s
378
+
379
+ def clear_cache(self):
380
+ with self._lock:
381
+ self._cache.clear()
382
+
383
+ def reset_budget(self):
384
+ with self._lock:
385
+ self._sent = [0, 0]
386
+
387
+ def close(self):
388
+ if self._pool is not None:
389
+ self._pool.shutdown(wait=False)
390
+ self._pool = None
391
+ with self._lock:
392
+ conns, self._conns = self._conns, []
393
+ for c, _ in conns:
394
+ c.close()
395
+
396
+ # ------------------------------------------------ internals
397
+
398
+ def _extract(self, name, a, args, opts):
399
+ if a is None:
400
+ return None
401
+ if name == "jev":
402
+ t = args[2] if len(args) > 2 and args[2] is not None else self.cfg["threshold"]
403
+ return a["noul"] >= float(t)
404
+ if name == "jev_prob":
405
+ return a["noul"]
406
+ if name == "jev_score":
407
+ return a["score"]
408
+ if name == "jev_score_norm":
409
+ return a["score"] / max(len(opts) - 1, 1)
410
+ if name == "jev_choice":
411
+ return a["choice"]
412
+ if name == "jev_confidence":
413
+ return a.get("confidence")
414
+ return a
415
+
416
+ def _qkey(self, kind, query, opts):
417
+ return json.dumps([kind, query, opts])
418
+
419
+ def _bump(self, name, n=1):
420
+ with self._lock:
421
+ self._stats[name] += n
422
+
423
+ def _cache_get(self, key, h):
424
+ if not self.cfg["cache_size"]:
425
+ return None
426
+ with self._lock:
427
+ a = self._cache.get((key, h))
428
+ if a is not None:
429
+ self._cache.move_to_end((key, h))
430
+ return a
431
+
432
+ def _cache_put(self, key, answers):
433
+ size = self.cfg["cache_size"]
434
+ if not size:
435
+ return
436
+ with self._lock:
437
+ for h, a in answers.items():
438
+ self._cache[(key, h)] = a
439
+ while len(self._cache) > size:
440
+ self._cache.popitem(last=False)
441
+
442
+ def _guard(self, pairs):
443
+ """Spend guard: refuse to send more than max_rows rows / max_chars characters of row data
444
+ from this engine until reset_budget()."""
445
+ mr, mc = self.cfg["max_rows"], self.cfg["max_chars"]
446
+ if not (mr or mc):
447
+ return
448
+ with self._lock:
449
+ rows, chars = self._sent[0] + len(pairs), self._sent[1] + sum(len(t) for _, t in pairs)
450
+ if mr and rows > mr:
451
+ raise JevError("sqljev: would send %d rows to the model, above max_rows = %d" % (rows, mr))
452
+ if mc and chars > mc:
453
+ raise JevError("sqljev: would send %d characters of row data, above max_chars = %d" % (chars, mc))
454
+ self._sent = [rows, chars]
455
+
456
+ def _executor(self):
457
+ with self._lock:
458
+ if self._pool is None:
459
+ self._pool = ThreadPoolExecutor(max_workers=self.cfg["concurrency"], thread_name_prefix="sqljev")
460
+ return self._pool
461
+
462
+ def _run_batch(self, key, kind, query, opts, pairs):
463
+ """Judge one batch of (row_hash, row_json) pairs; returns {row_hash: answer}."""
464
+ try:
465
+ b = self.cfg["backend"]
466
+ if b == "local":
467
+ got = self._run_local(kind, query, opts, pairs)
468
+ elif b == "gateway":
469
+ got = self._run_gateway(kind, query, opts, pairs)
470
+ else:
471
+ got = self._run_systemone(kind, query, opts, pairs)
472
+ except Exception:
473
+ self._bump("errors")
474
+ raise
475
+ self._cache_put(key, got)
476
+ self._bump("rows_evaluated", len(pairs))
477
+ return got
478
+
479
+ # -- local: Laya in-process, many rows per forward pass
480
+
481
+ def _laya_model(self):
482
+ if self._laya is None:
483
+ try:
484
+ import laya
485
+ except ImportError:
486
+ raise JevError("sqljev: backend 'local' needs Laya: pip install 'sqljev[laya]'")
487
+ m = self.cfg["model"]
488
+ if m and m not in LAYA_CHECKPOINTS: # a fine-tuned checkpoint: local directory or Hub id
489
+ self._laya = ("agent", laya.load(m, device=self.cfg["device"]))
490
+ else:
491
+ self._laya = ("router", laya.Router(device=self.cfg["device"]))
492
+ return self._laya
493
+
494
+ def _run_local(self, kind, query, opts, pairs):
495
+ q = {"q": laya_question(kind, query, opts)}
496
+ states = [json.loads(t) for _, t in pairs]
497
+ t0 = time.time()
498
+ with self._model_lock: # one forward pass at a time; the model is the bottleneck
499
+ how, m = self._laya_model()
500
+ if how == "agent":
501
+ res = m.predict_batch(states, q, batch_size=self.cfg["batch_size"], max_len=self.cfg["max_len"],
502
+ sort_by_length=True)
503
+ else:
504
+ req = [{"state": s, "questions": q} for s in states]
505
+ if self.cfg["model"]:
506
+ for r in req:
507
+ r["model"] = self.cfg["model"]
508
+ res = m.predict_batch(req, batch_size=self.cfg["batch_size"])
509
+ with self._lock:
510
+ self._stats["forward_batches"] += -(-len(pairs) // self.cfg["batch_size"])
511
+ self._stats["model_ms"] += (time.time() - t0) * 1000
512
+ return {h: r["answers"]["q"] for (h, _), r in zip(pairs, res)}
513
+
514
+ # -- gateway: sqljev's own batch protocol
515
+
516
+ def _run_gateway(self, kind, query, opts, pairs):
517
+ body = json.dumps({"kind": kind, "question": query, "options": opts,
518
+ "rows": [json.loads(t) for _, t in pairs]}).encode()
519
+ data = self._post(body)
520
+ answers = data.get("answers") or []
521
+ if len(answers) != len(pairs):
522
+ raise JevError("sqljev: gateway returned %d answers for %d rows" % (len(answers), len(pairs)))
523
+ return {h: a for (h, _), a in zip(pairs, answers)}
524
+
525
+ # -- /v1/systemone: TypeSafe Jev (20 rows in one state) or laya-serve (one row per request)
526
+
527
+ def _run_systemone(self, kind, query, opts, pairs):
528
+ rows = [json.loads(t) for _, t in pairs]
529
+ if self.cfg["backend"] == "jev":
530
+ state = {"condition": query, "rows": rows} if kind == "noul" else {"rows": rows}
531
+ qs = {("r%d" % i): jev_question(i, kind, query, opts) for i in range(len(rows))}
532
+ ids = ["r%d" % i for i in range(len(rows))]
533
+ else:
534
+ state, qs, ids = rows[0], {"q": laya_question(kind, query, opts)}, ["q"]
535
+ req = {"state": state, "questions": qs}
536
+ if self.cfg["model"]:
537
+ req["model"] = self.cfg["model"]
538
+ data = self._post(json.dumps(req).encode())
539
+ answers = data.get("answers") or {}
540
+ if any(i not in answers for i in ids):
541
+ raise JevError("sqljev: the model returned %d of %d answers" % (len(answers), len(ids)))
542
+ usage = data.get("usage") or {}
543
+ with self._lock:
544
+ self._stats["input_tokens"] += usage.get("input_tokens", 0)
545
+ self._stats["output_tokens"] += usage.get("output_tokens", 0)
546
+ return {h: answers[i] for (h, _), i in zip(pairs, ids)}
547
+
548
+ # -- HTTP: persistent keep-alive connections, retries honouring Retry-After (from pg-jev)
549
+
550
+ def _borrow(self, url):
551
+ while True:
552
+ with self._lock:
553
+ entry = self._conns.pop() if self._conns else None
554
+ if entry is None:
555
+ break
556
+ c, last = entry
557
+ if time.time() - last < self.cfg["keepalive"] and _alive(c):
558
+ return c, True
559
+ c.close()
560
+ if url.scheme == "https":
561
+ if self._ssl is None:
562
+ self._ssl = ssl.create_default_context()
563
+ c = http.client.HTTPSConnection(url.hostname, url.port, timeout=self.cfg["timeout"], context=self._ssl)
564
+ else:
565
+ c = http.client.HTTPConnection(url.hostname, url.port, timeout=self.cfg["timeout"])
566
+ return c, False
567
+
568
+ def _release(self, c, reusable):
569
+ if reusable:
570
+ with self._lock:
571
+ if len(self._conns) < self.cfg["concurrency"]:
572
+ self._conns.append((c, time.time()))
573
+ return
574
+ c.close()
575
+
576
+ def _post(self, body):
577
+ url = urlsplit(self.cfg["api_url"] or "")
578
+ if url.scheme not in ("http", "https"):
579
+ raise JevError("sqljev: api_url %r is not an http(s) URL" % self.cfg["api_url"])
580
+ if self.cfg["backend"] == "jev" and not self.cfg["api_key"]:
581
+ raise JevError("sqljev: no API key. Set SQLJEV_API_KEY or TYPESAFE_API_KEY.")
582
+ path = (url.path or "/") + ("?" + url.query if url.query else "")
583
+ headers = {"Content-Type": "application/json", "User-Agent": "sqljev/" + __version__}
584
+ if self.cfg["api_key"]:
585
+ headers["Authorization"] = "Bearer " + self.cfg["api_key"]
586
+ delay, last = 0.5, None
587
+ for attempt in range(7):
588
+ c, reused = self._borrow(url)
589
+ t0 = time.time()
590
+ try:
591
+ if not reused:
592
+ c.connect()
593
+ _tcp_keepalive(c)
594
+ c.request("POST", path, body=body, headers=headers)
595
+ resp = c.getresponse()
596
+ raw = resp.read()
597
+ except (http.client.HTTPException, OSError) as e:
598
+ c.close()
599
+ last = "%s: %s" % (type(e).__name__, e)
600
+ self._bump("retries")
601
+ if reused and attempt == 0:
602
+ continue # a keep-alive connection went stale: retry at once on a fresh one
603
+ time.sleep(delay + random.random() * 0.25)
604
+ delay = min(delay * 2, 8)
605
+ continue
606
+ self._release(c, not resp.will_close)
607
+ if resp.status == 200:
608
+ with self._lock:
609
+ self._stats["requests"] += 1
610
+ self._stats["model_ms"] += (time.time() - t0) * 1000
611
+ return json.loads(raw.decode())
612
+ last = "%s %s" % (resp.status, raw.decode(errors="replace")[:300])
613
+ if resp.status in (408, 429, 503, 529) or resp.status >= 500:
614
+ self._bump("retries")
615
+ wait = _retry_after(resp)
616
+ time.sleep(min(wait if wait is not None else delay, 30) + random.random() * 0.25)
617
+ delay = min(delay * 2, 8)
618
+ continue
619
+ raise JevError("sqljev: %s error %s" % (self.cfg["backend"], last))
620
+ raise JevError("sqljev: %s unreachable after retries: %s" % (self.cfg["backend"], last))
621
+
622
+
623
+ def _null(v):
624
+ """SQL NULL arrives as None, or as NaN from pandas-based UDF runtimes (Snowflake, Spark)."""
625
+ return None if isinstance(v, float) and v != v else v
626
+
627
+
628
+ def _alive(c):
629
+ """An idle keep-alive connection never has unread data: readability means EOF or a TLS close alert."""
630
+ sock = c.sock
631
+ if sock is None:
632
+ return False
633
+ try:
634
+ readable, _, _ = select.select([sock], [], [], 0)
635
+ return not readable
636
+ except (OSError, ValueError):
637
+ return False
638
+
639
+
640
+ def _tcp_keepalive(c):
641
+ try:
642
+ sock = c.sock
643
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
644
+ for name, value in (("TCP_KEEPIDLE", 30), ("TCP_KEEPINTVL", 10), ("TCP_KEEPCNT", 3)):
645
+ if hasattr(socket, name):
646
+ sock.setsockopt(socket.IPPROTO_TCP, getattr(socket, name), value)
647
+ except OSError:
648
+ pass
649
+
650
+
651
+ def _retry_after(resp):
652
+ v = resp.getheader("retry-after-ms")
653
+ if v and v.strip().isdigit():
654
+ return int(v) / 1000.0
655
+ v = resp.getheader("retry-after")
656
+ if v:
657
+ try:
658
+ return float(v)
659
+ except ValueError:
660
+ pass
661
+ return None
662
+
663
+
664
+ _DEFAULT = None
665
+ _DEFAULT_LOCK = threading.Lock()
666
+
667
+
668
+ def default_engine(**overrides):
669
+ """A process-wide engine, so a UDF's answer cache and connections survive across calls."""
670
+ global _DEFAULT
671
+ with _DEFAULT_LOCK:
672
+ if _DEFAULT is None:
673
+ _DEFAULT = Jev(**overrides)
674
+ return _DEFAULT