jevkit-core 0.2.0__tar.gz

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.
@@ -0,0 +1,12 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ venv/
5
+ dist/
6
+ build/
7
+ *.egg-info/
8
+ .pytest_cache/
9
+ .coverage
10
+ htmlcov/
11
+ .ruff_cache/
12
+ uv.lock
@@ -0,0 +1,46 @@
1
+ Metadata-Version: 2.5
2
+ Name: jevkit-core
3
+ Version: 0.2.0
4
+ Summary: Shared substrate for jevkit: the .jevl record format, canonical request digests, question normalization, and token budgets for TypeSafe's Jev.
5
+ Project-URL: Homepage, https://github.com/pjdurden/jevkit-py
6
+ Project-URL: Issues, https://github.com/pjdurden/jevkit-py/issues
7
+ Author: Prajjwal Chittori
8
+ License-Expression: MIT
9
+ Keywords: calibration,jev,llm,system-one,typesafe
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Topic :: Software Development :: Testing
17
+ Requires-Python: >=3.10
18
+ Description-Content-Type: text/markdown
19
+
20
+ # jevkit-core
21
+
22
+ Shared substrate for [jevkit](https://github.com/pjdurden/jevkit-py), a set of
23
+ tools for building on TypeSafe's Jev. Nothing in this package calls the API.
24
+
25
+ - **`.jevl` record format** — one JSON Lines format that serves as test
26
+ cassette, drift golden set, benchmark instance, and calibration observation.
27
+ See [the spec](../../docs/specs/record-format-v1.md).
28
+ - **Canonical digests** — RFC 8785 canonicalization, so the same request always
29
+ hashes the same way. `record_id()` includes the model; `request_id()` does
30
+ not, which is what lets you pair a golden record with its replay on a new
31
+ model version.
32
+ - **Question normalization** — one view over SDK objects and plain dicts alike.
33
+ - **Token budgets** — estimates against Jev's two documented limits: 64k for
34
+ state plus all questions, 32k for state plus the longest single question.
35
+
36
+ > Unofficial and unaffiliated with TypeSafe.
37
+
38
+ ## Install
39
+
40
+ ```bash
41
+ pip install jevkit-core
42
+ ```
43
+
44
+ ## License
45
+
46
+ MIT
@@ -0,0 +1,27 @@
1
+ # jevkit-core
2
+
3
+ Shared substrate for [jevkit](https://github.com/pjdurden/jevkit-py), a set of
4
+ tools for building on TypeSafe's Jev. Nothing in this package calls the API.
5
+
6
+ - **`.jevl` record format** — one JSON Lines format that serves as test
7
+ cassette, drift golden set, benchmark instance, and calibration observation.
8
+ See [the spec](../../docs/specs/record-format-v1.md).
9
+ - **Canonical digests** — RFC 8785 canonicalization, so the same request always
10
+ hashes the same way. `record_id()` includes the model; `request_id()` does
11
+ not, which is what lets you pair a golden record with its replay on a new
12
+ model version.
13
+ - **Question normalization** — one view over SDK objects and plain dicts alike.
14
+ - **Token budgets** — estimates against Jev's two documented limits: 64k for
15
+ state plus all questions, 32k for state plus the longest single question.
16
+
17
+ > Unofficial and unaffiliated with TypeSafe.
18
+
19
+ ## Install
20
+
21
+ ```bash
22
+ pip install jevkit-core
23
+ ```
24
+
25
+ ## License
26
+
27
+ MIT
@@ -0,0 +1,30 @@
1
+ [project]
2
+ name = "jevkit-core"
3
+ version = "0.2.0"
4
+ description = "Shared substrate for jevkit: the .jevl record format, canonical request digests, question normalization, and token budgets for TypeSafe's Jev."
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ license = "MIT"
8
+ authors = [{ name = "Prajjwal Chittori" }]
9
+ keywords = ["jev", "typesafe", "system-one", "llm", "calibration"]
10
+ classifiers = [
11
+ "Development Status :: 3 - Alpha",
12
+ "Intended Audience :: Developers",
13
+ "License :: OSI Approved :: MIT License",
14
+ "Programming Language :: Python :: 3.10",
15
+ "Programming Language :: Python :: 3.11",
16
+ "Programming Language :: Python :: 3.12",
17
+ "Topic :: Software Development :: Testing",
18
+ ]
19
+ dependencies = []
20
+
21
+ [project.urls]
22
+ Homepage = "https://github.com/pjdurden/jevkit-py"
23
+ Issues = "https://github.com/pjdurden/jevkit-py/issues"
24
+
25
+ [build-system]
26
+ requires = ["hatchling"]
27
+ build-backend = "hatchling.build"
28
+
29
+ [tool.hatch.build.targets.wheel]
30
+ packages = ["src/jevkit_core"]
@@ -0,0 +1,28 @@
1
+ """Shared substrate for the jevkit packages.
2
+
3
+ Nothing here calls the jev API. This package holds the pieces every other
4
+ jevkit tool needs: canonical digests, the `.jevl` record format, a uniform
5
+ view of a question, and token budget estimation.
6
+ """
7
+
8
+ from .answers import NOUL_THRESHOLD, Answer, parse_answer, parse_answers
9
+ from .canonical import canonical_json, digest, record_id, request_id
10
+ from .question import (Question, flatten_text, normalize_question,
11
+ normalize_questions)
12
+ from .record import (FORMAT_VERSION, Record, RecordFormatError, append_record,
13
+ load_cassette, read_records, write_records)
14
+ from .tokens import (STATE_BUDGET, TOTAL_BUDGET, BudgetReport, check_budget,
15
+ estimate_tokens)
16
+
17
+ __version__ = "0.2.0"
18
+
19
+ __all__ = [
20
+ "Answer", "parse_answer", "parse_answers", "NOUL_THRESHOLD",
21
+ "canonical_json", "digest", "record_id", "request_id",
22
+ "Question", "flatten_text", "normalize_question", "normalize_questions",
23
+ "Record", "RecordFormatError", "FORMAT_VERSION",
24
+ "read_records", "write_records", "append_record", "load_cassette",
25
+ "estimate_tokens", "check_budget", "BudgetReport",
26
+ "TOTAL_BUDGET", "STATE_BUDGET",
27
+ "__version__",
28
+ ]
@@ -0,0 +1,151 @@
1
+ """A uniform view of a jev answer, and how it compares to a ground-truth label.
2
+
3
+ `drift`, `calibrate`, and `bench` all need the same three things from an answer:
4
+ what it predicted, how much probability sat on a given outcome, and whether it
5
+ matched a label. Those live here so the three packages agree on the definitions
6
+ rather than each inventing its own.
7
+
8
+ The vocabulary is deliberately narrow:
9
+
10
+ - **predicted** is the outcome the answer selects. For a Choice it is the option
11
+ key. For a Noul it is ``True`` when ``noul`` clears the threshold. For a Score
12
+ it is the index of the most probable level, which is *not* the same as
13
+ rounding ``score``.
14
+ - **confidence** is what the API returned, and Nouls do not have one. A Noul's
15
+ distance from 0.5 is a different quantity and is exposed separately as
16
+ ``decisiveness`` rather than pretending it is the same number.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from dataclasses import dataclass
22
+ from typing import Any
23
+
24
+ __all__ = ["Answer", "parse_answer", "parse_answers", "NOUL_THRESHOLD"]
25
+
26
+ NOUL_THRESHOLD = 0.5
27
+
28
+
29
+ @dataclass
30
+ class Answer:
31
+ """Normalized view over one answer from the API."""
32
+
33
+ id: str
34
+ type: str
35
+ raw: dict[str, Any]
36
+
37
+ # -- what it said ------------------------------------------------------
38
+
39
+ @property
40
+ def probabilities(self) -> dict[str, float]:
41
+ """Outcome -> probability.
42
+
43
+ Choice returns its options. Score returns its level indices as strings.
44
+ Noul has no distribution from the API, so the two-outcome distribution
45
+ it implies is synthesized here.
46
+ """
47
+ if self.type == "noul":
48
+ p = float(self.raw.get("noul", 0.0))
49
+ return {"true": p, "false": 1.0 - p}
50
+ probs = self.raw.get("probabilities") or {}
51
+ return {str(k): float(v) for k, v in probs.items()}
52
+
53
+ @property
54
+ def confidence(self) -> float | None:
55
+ """As returned by the API. ``None`` for a Noul, which carries none."""
56
+ value = self.raw.get("confidence")
57
+ return None if value is None else float(value)
58
+
59
+ @property
60
+ def decisiveness(self) -> float:
61
+ """How far from maximally uncertain this answer is, on 0..1.
62
+
63
+ For a Noul this is ``|noul - 0.5| * 2``. This is *not* confidence and is
64
+ not comparable to the API's confidence across question types; it exists
65
+ so Nouls can be thresholded on something with a defined meaning.
66
+ """
67
+ if self.type == "noul":
68
+ return abs(float(self.raw.get("noul", 0.0)) - NOUL_THRESHOLD) * 2.0
69
+ conf = self.confidence
70
+ return 0.0 if conf is None else conf
71
+
72
+ def predicted(self, *, noul_threshold: float = NOUL_THRESHOLD) -> Any:
73
+ """The outcome this answer selects."""
74
+ if self.type == "noul":
75
+ return float(self.raw.get("noul", 0.0)) >= noul_threshold
76
+ if self.type == "choice":
77
+ return self.raw.get("choice")
78
+ if self.type == "score":
79
+ probs = self.probabilities
80
+ if not probs:
81
+ return None
82
+ best = max(probs, key=lambda k: probs[k])
83
+ try:
84
+ return int(best)
85
+ except ValueError:
86
+ return best
87
+ return None
88
+
89
+ @property
90
+ def score(self) -> float | None:
91
+ """The probability-weighted score, for a Score answer."""
92
+ if self.type != "score":
93
+ return None
94
+ value = self.raw.get("score")
95
+ return None if value is None else float(value)
96
+
97
+ # -- how it compares to a label ---------------------------------------
98
+
99
+ def _label_key(self, label: Any) -> str:
100
+ if self.type == "noul":
101
+ return "true" if bool(label) else "false"
102
+ return str(label)
103
+
104
+ def probability_of(self, label: Any) -> float:
105
+ """Probability this answer assigned to ``label``.
106
+
107
+ Returns 0.0 for an outcome the question never offered, which is the
108
+ honest reading: the model could not have selected it.
109
+ """
110
+ return self.probabilities.get(self._label_key(label), 0.0)
111
+
112
+ def is_correct(self, label: Any, *, noul_threshold: float = NOUL_THRESHOLD) -> bool:
113
+ predicted = self.predicted(noul_threshold=noul_threshold)
114
+ if self.type == "noul":
115
+ return bool(predicted) is bool(label)
116
+ if self.type == "score":
117
+ try:
118
+ return int(predicted) == int(label) # type: ignore[arg-type]
119
+ except (TypeError, ValueError):
120
+ return predicted == label
121
+ return predicted == label
122
+
123
+ @property
124
+ def top_probability(self) -> float:
125
+ """Probability mass on the predicted outcome."""
126
+ probs = self.probabilities
127
+ return max(probs.values()) if probs else 0.0
128
+
129
+
130
+ def parse_answer(qid: str, raw: Any) -> Answer:
131
+ if not isinstance(raw, dict):
132
+ raise ValueError(f"answer {qid!r}: expected an object, got {type(raw).__name__}")
133
+ atype = raw.get("type")
134
+ if atype not in ("choice", "score", "noul"):
135
+ # Infer from shape when the API response omits the discriminator.
136
+ if "choice" in raw:
137
+ atype = "choice"
138
+ elif "noul" in raw:
139
+ atype = "noul"
140
+ elif "score" in raw:
141
+ atype = "score"
142
+ else:
143
+ raise ValueError(
144
+ f"answer {qid!r}: cannot determine type; expected a 'type' field or one "
145
+ f"of 'choice'/'score'/'noul'"
146
+ )
147
+ return Answer(id=qid, type=atype, raw=raw)
148
+
149
+
150
+ def parse_answers(answers: dict[str, Any]) -> dict[str, Answer]:
151
+ return {qid: parse_answer(qid, raw) for qid, raw in answers.items()}
@@ -0,0 +1,57 @@
1
+ """RFC 8785 JSON Canonicalization, and the digests jevkit builds on it."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import math
8
+ from typing import Any
9
+
10
+ __all__ = ["canonical_json", "digest", "request_id", "record_id"]
11
+
12
+
13
+ def _check_finite(value: Any) -> None:
14
+ if isinstance(value, float) and not math.isfinite(value):
15
+ raise ValueError(f"cannot canonicalize non-finite number: {value!r}")
16
+ if isinstance(value, dict):
17
+ for v in value.values():
18
+ _check_finite(v)
19
+ elif isinstance(value, (list, tuple)):
20
+ for v in value:
21
+ _check_finite(v)
22
+
23
+
24
+ def canonical_json(value: Any) -> bytes:
25
+ """Serialize to RFC 8785 canonical form.
26
+
27
+ Object keys sorted by code point, no insignificant whitespace, UTF-8.
28
+ NaN and Infinity are rejected rather than emitted as invalid JSON.
29
+ """
30
+ _check_finite(value)
31
+ return json.dumps(
32
+ value,
33
+ sort_keys=True,
34
+ separators=(",", ":"),
35
+ ensure_ascii=False,
36
+ allow_nan=False,
37
+ ).encode("utf-8")
38
+
39
+
40
+ def digest(value: Any) -> str:
41
+ """`sha256:<hex>` over the canonical form of ``value``."""
42
+ return "sha256:" + hashlib.sha256(canonical_json(value)).hexdigest()
43
+
44
+
45
+ def request_id(state: Any, questions: dict[str, Any]) -> str:
46
+ """Digest of state + questions, with no model.
47
+
48
+ This is what pairs a golden-set record with its replay on a different
49
+ model version, since the full record id includes the model and would
50
+ therefore differ.
51
+ """
52
+ return digest({"questions": questions, "state": state})
53
+
54
+
55
+ def record_id(model: str, state: Any, questions: dict[str, Any]) -> str:
56
+ """Digest of model + state + questions. The record's ``id`` field."""
57
+ return digest({"model": model, "questions": questions, "state": state})
@@ -0,0 +1,124 @@
1
+ """A uniform view of a jev question.
2
+
3
+ Questions reach jevkit in three shapes: plain dicts as sent to the HTTP API,
4
+ SDK objects (``Choice``/``Score``/``Noul``), and whatever a user's own helper
5
+ produces. Every jevkit tool wants the same few facts out of them, so they are
6
+ normalized once here rather than re-sniffed in each package.
7
+
8
+ ``instructions`` and ``criteria`` both accept JSON structure, not just
9
+ strings, so the normalized form keeps the raw value and exposes the flattened
10
+ text separately for anything doing textual analysis.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass, field
16
+ from typing import Any, Iterator
17
+
18
+ __all__ = ["Question", "QuestionType", "normalize_question", "normalize_questions",
19
+ "flatten_text"]
20
+
21
+ QuestionType = str # "choice" | "score" | "noul"
22
+
23
+ _VALID_TYPES = ("choice", "score", "noul")
24
+
25
+
26
+ def flatten_text(value: Any) -> str:
27
+ """Collapse a string / dict / list into one string for textual analysis.
28
+
29
+ Dict keys are included: in a Choice, the keys are the option names and
30
+ carry meaning the model sees.
31
+ """
32
+ if value is None:
33
+ return ""
34
+ if isinstance(value, str):
35
+ return value
36
+ if isinstance(value, (int, float, bool)):
37
+ return str(value)
38
+ if isinstance(value, dict):
39
+ parts: list[str] = []
40
+ for k, v in value.items():
41
+ parts.append(str(k))
42
+ parts.append(flatten_text(v))
43
+ return " ".join(p for p in parts if p)
44
+ if isinstance(value, (list, tuple)):
45
+ return " ".join(p for p in (flatten_text(v) for v in value) if p)
46
+ return str(value)
47
+
48
+
49
+ @dataclass
50
+ class Question:
51
+ """Normalized question. ``raw`` is always the untouched original."""
52
+
53
+ id: str
54
+ type: QuestionType
55
+ instructions: Any
56
+ criteria: Any
57
+ raw: Any = field(repr=False, default=None)
58
+
59
+ @property
60
+ def instructions_text(self) -> str:
61
+ return flatten_text(self.instructions)
62
+
63
+ @property
64
+ def criteria_text(self) -> str:
65
+ return flatten_text(self.criteria)
66
+
67
+ @property
68
+ def text(self) -> str:
69
+ """Everything the model reads, as one string."""
70
+ return f"{self.instructions_text} {self.criteria_text}".strip()
71
+
72
+ @property
73
+ def options(self) -> list[str]:
74
+ """Choice option keys, or Score level descriptions. Empty otherwise."""
75
+ if self.type == "choice" and isinstance(self.criteria, dict):
76
+ return [str(k) for k in self.criteria.keys()]
77
+ if self.type == "score" and isinstance(self.criteria, (list, tuple)):
78
+ return [flatten_text(v) for v in self.criteria]
79
+ if self.type == "score" and isinstance(self.criteria, dict):
80
+ return [flatten_text(v) for v in self.criteria.values()]
81
+ return []
82
+
83
+ def option_descriptions(self) -> Iterator[tuple[str, str]]:
84
+ """(option key, description) pairs for a Choice."""
85
+ if self.type == "choice" and isinstance(self.criteria, dict):
86
+ for k, v in self.criteria.items():
87
+ yield str(k), flatten_text(v)
88
+
89
+
90
+ def _get(obj: Any, name: str) -> Any:
91
+ if isinstance(obj, dict):
92
+ return obj.get(name)
93
+ return getattr(obj, name, None)
94
+
95
+
96
+ def _infer_type(obj: Any) -> str | None:
97
+ declared = _get(obj, "type")
98
+ if isinstance(declared, str) and declared.lower() in _VALID_TYPES:
99
+ return declared.lower()
100
+ # SDK objects carry no "type" field; fall back to the class name.
101
+ cls = type(obj).__name__.lower()
102
+ if cls in _VALID_TYPES:
103
+ return cls
104
+ return None
105
+
106
+
107
+ def normalize_question(qid: str, obj: Any) -> Question:
108
+ qtype = _infer_type(obj)
109
+ if qtype is None:
110
+ raise ValueError(
111
+ f"question {qid!r}: cannot determine type. Expected a 'type' key of "
112
+ f"{_VALID_TYPES}, or a Choice/Score/Noul object."
113
+ )
114
+ return Question(
115
+ id=qid,
116
+ type=qtype,
117
+ instructions=_get(obj, "instructions"),
118
+ criteria=_get(obj, "criteria"),
119
+ raw=obj,
120
+ )
121
+
122
+
123
+ def normalize_questions(questions: dict[str, Any]) -> list[Question]:
124
+ return [normalize_question(qid, obj) for qid, obj in questions.items()]
@@ -0,0 +1,164 @@
1
+ """Reading and writing `.jevl` records. See docs/specs/record-format-v1.md."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from dataclasses import dataclass, field
8
+ from datetime import datetime, timezone
9
+ from typing import Any, IO, Iterable, Iterator
10
+
11
+ from .canonical import record_id, request_id
12
+
13
+ __all__ = ["Record", "FORMAT_VERSION", "RecordFormatError",
14
+ "read_records", "write_records", "append_record", "load_cassette"]
15
+
16
+ FORMAT_VERSION = 1
17
+
18
+ _REQUIRED = ("v", "id", "ts", "model", "request", "answers")
19
+
20
+
21
+ class RecordFormatError(ValueError):
22
+ """A `.jevl` line is malformed, or its version is unreadable."""
23
+
24
+
25
+ def _utcnow() -> str:
26
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
27
+
28
+
29
+ @dataclass
30
+ class Record:
31
+ model: str
32
+ state: Any
33
+ questions: dict[str, Any]
34
+ answers: dict[str, Any]
35
+ id: str = ""
36
+ ts: str = field(default_factory=_utcnow)
37
+ usage: dict[str, Any] | None = None
38
+ label: dict[str, Any] | None = None
39
+ tags: list[str] = field(default_factory=list)
40
+ meta: dict[str, Any] = field(default_factory=dict)
41
+ extra: dict[str, Any] = field(default_factory=dict, repr=False)
42
+
43
+ def __post_init__(self) -> None:
44
+ if not self.id:
45
+ self.id = record_id(self.model, self.state, self.questions)
46
+
47
+ @property
48
+ def request_id(self) -> str:
49
+ """Model-independent digest, used to pair across model versions."""
50
+ return request_id(self.state, self.questions)
51
+
52
+ def to_dict(self) -> dict[str, Any]:
53
+ out: dict[str, Any] = {
54
+ "v": FORMAT_VERSION,
55
+ "id": self.id,
56
+ "ts": self.ts,
57
+ "model": self.model,
58
+ "request": {"state": self.state, "questions": self.questions},
59
+ "answers": self.answers,
60
+ }
61
+ if self.usage is not None:
62
+ out["usage"] = self.usage
63
+ if self.label is not None:
64
+ out["label"] = self.label
65
+ if self.tags:
66
+ out["tags"] = list(self.tags)
67
+ if self.meta:
68
+ out["meta"] = dict(self.meta)
69
+ # Unknown keys survive a read/write round trip.
70
+ for k, v in self.extra.items():
71
+ out.setdefault(k, v)
72
+ return out
73
+
74
+ @classmethod
75
+ def from_dict(cls, raw: dict[str, Any], *, source: str = "<memory>",
76
+ line_no: int = 0) -> "Record":
77
+ where = f"{source}:{line_no}" if line_no else source
78
+ if not isinstance(raw, dict):
79
+ raise RecordFormatError(f"{where}: record must be a JSON object")
80
+
81
+ version = raw.get("v")
82
+ if version is None:
83
+ raise RecordFormatError(f"{where}: missing required key 'v'")
84
+ if not isinstance(version, int):
85
+ raise RecordFormatError(f"{where}: 'v' must be an integer, got {version!r}")
86
+ if version > FORMAT_VERSION:
87
+ raise RecordFormatError(
88
+ f"{where}: record format v{version} is newer than this reader "
89
+ f"(v{FORMAT_VERSION}). Upgrade jevkit-core rather than guessing."
90
+ )
91
+
92
+ missing = [k for k in _REQUIRED if k not in raw]
93
+ if missing:
94
+ raise RecordFormatError(f"{where}: missing required key(s): {', '.join(missing)}")
95
+
96
+ request = raw["request"]
97
+ if not isinstance(request, dict) or "questions" not in request:
98
+ raise RecordFormatError(f"{where}: 'request' must be an object with 'questions'")
99
+
100
+ known = set(_REQUIRED) | {"usage", "label", "tags", "meta"}
101
+ return cls(
102
+ model=raw["model"],
103
+ state=request.get("state"),
104
+ questions=request["questions"],
105
+ answers=raw["answers"],
106
+ id=raw["id"],
107
+ ts=raw["ts"],
108
+ usage=raw.get("usage"),
109
+ label=raw.get("label"),
110
+ tags=list(raw.get("tags") or []),
111
+ meta=dict(raw.get("meta") or {}),
112
+ extra={k: v for k, v in raw.items() if k not in known},
113
+ )
114
+
115
+
116
+ def read_records(path: str | os.PathLike[str] | IO[str]) -> Iterator[Record]:
117
+ """Yield records from a `.jevl` file.
118
+
119
+ A line that fails to parse raises. Skipping silently would let a truncated
120
+ golden set look like a passing one.
121
+ """
122
+ if hasattr(path, "read"):
123
+ yield from _read_stream(path, getattr(path, "name", "<stream>")) # type: ignore[arg-type]
124
+ return
125
+ with open(path, "r", encoding="utf-8") as fh:
126
+ yield from _read_stream(fh, str(path))
127
+
128
+
129
+ def _read_stream(fh: IO[str], source: str) -> Iterator[Record]:
130
+ for line_no, line in enumerate(fh, start=1):
131
+ stripped = line.strip()
132
+ if not stripped:
133
+ continue
134
+ try:
135
+ raw = json.loads(stripped)
136
+ except json.JSONDecodeError as exc:
137
+ raise RecordFormatError(f"{source}:{line_no}: invalid JSON: {exc}") from exc
138
+ yield Record.from_dict(raw, source=source, line_no=line_no)
139
+
140
+
141
+ def write_records(path: str | os.PathLike[str], records: Iterable[Record]) -> int:
142
+ count = 0
143
+ with open(path, "w", encoding="utf-8") as fh:
144
+ for record in records:
145
+ fh.write(json.dumps(record.to_dict(), ensure_ascii=False) + "\n")
146
+ count += 1
147
+ return count
148
+
149
+
150
+ def append_record(path: str | os.PathLike[str], record: Record) -> None:
151
+ with open(path, "a", encoding="utf-8") as fh:
152
+ fh.write(json.dumps(record.to_dict(), ensure_ascii=False) + "\n")
153
+
154
+
155
+ def load_cassette(path: str | os.PathLike[str]) -> dict[str, Record]:
156
+ """Map record id -> Record, last occurrence winning.
157
+
158
+ Re-recording a request appends rather than rewrites, so the last line for
159
+ an id is the current answer.
160
+ """
161
+ table: dict[str, Record] = {}
162
+ for record in read_records(path):
163
+ table[record.id] = record
164
+ return table
@@ -0,0 +1,87 @@
1
+ """Token budget estimation for jev requests.
2
+
3
+ Jev ingests the state once and evaluates every question against it, so two
4
+ separate budgets apply. Both are documented on the model card:
5
+
6
+ 64k state + every question combined
7
+ 32k state + the single longest question
8
+
9
+ These are *estimates*. jevkit deliberately does not bundle a tokenizer: the
10
+ dependency is heavy, the vendor does not publish which tokenizer Jev uses,
11
+ and a wrong tokenizer is more misleading than an honest approximation. The
12
+ estimator is intentionally conservative so that a request jevkit calls safe
13
+ is very unlikely to be rejected.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from typing import Any
19
+
20
+ from .canonical import canonical_json
21
+
22
+ __all__ = ["estimate_tokens", "BudgetReport", "check_budget",
23
+ "TOTAL_BUDGET", "STATE_BUDGET"]
24
+
25
+ TOTAL_BUDGET = 64_000
26
+ STATE_BUDGET = 32_000
27
+
28
+ # Bytes per token. Real-world English on BPE-family tokenizers runs ~4.0;
29
+ # 3.5 buys headroom for punctuation-dense JSON without being absurd.
30
+ _BYTES_PER_TOKEN = 3.5
31
+
32
+
33
+ def estimate_tokens(value: Any) -> int:
34
+ """Conservative token estimate for any JSON-serializable value."""
35
+ if isinstance(value, str):
36
+ payload = value.encode("utf-8")
37
+ else:
38
+ payload = canonical_json(value)
39
+ return max(1, int(len(payload) / _BYTES_PER_TOKEN) + 1)
40
+
41
+
42
+ class BudgetReport:
43
+ """Where a request sits against both documented budgets."""
44
+
45
+ __slots__ = ("state_tokens", "question_tokens", "longest_question_id")
46
+
47
+ def __init__(
48
+ self,
49
+ state_tokens: int,
50
+ question_tokens: dict[str, int],
51
+ longest_question_id: str | None,
52
+ ) -> None:
53
+ self.state_tokens = state_tokens
54
+ self.question_tokens = question_tokens
55
+ self.longest_question_id = longest_question_id
56
+
57
+ @property
58
+ def total(self) -> int:
59
+ return self.state_tokens + sum(self.question_tokens.values())
60
+
61
+ @property
62
+ def longest_pair(self) -> int:
63
+ """state + the single longest question."""
64
+ if self.longest_question_id is None:
65
+ return self.state_tokens
66
+ return self.state_tokens + self.question_tokens[self.longest_question_id]
67
+
68
+ @property
69
+ def over_total(self) -> bool:
70
+ return self.total > TOTAL_BUDGET
71
+
72
+ @property
73
+ def over_state(self) -> bool:
74
+ return self.longest_pair > STATE_BUDGET
75
+
76
+ def __repr__(self) -> str: # pragma: no cover - debug aid
77
+ return (
78
+ f"BudgetReport(total={self.total}, longest_pair={self.longest_pair}, "
79
+ f"over_total={self.over_total}, over_state={self.over_state})"
80
+ )
81
+
82
+
83
+ def check_budget(state: Any, questions: dict[str, Any]) -> BudgetReport:
84
+ state_tokens = estimate_tokens(state)
85
+ per_question = {qid: estimate_tokens(q) for qid, q in questions.items()}
86
+ longest = max(per_question, key=lambda k: per_question[k]) if per_question else None
87
+ return BudgetReport(state_tokens, per_question, longest)
@@ -0,0 +1,66 @@
1
+ import pytest
2
+ from jevkit_core import parse_answer, parse_answers
3
+
4
+ CHOICE = {"type": "choice", "choice": "billing",
5
+ "probabilities": {"billing": 0.7, "technical": 0.3}, "confidence": 0.55}
6
+ SCORE = {"type": "score", "score": 1.03,
7
+ "probabilities": {"0": 0.1, "1": 0.8, "2": 0.1}, "confidence": 0.84}
8
+ NOUL = {"type": "noul", "noul": 0.99}
9
+
10
+
11
+ def test_choice_predicts_its_selection():
12
+ a = parse_answer("q", CHOICE)
13
+ assert a.predicted() == "billing"
14
+ assert a.probability_of("billing") == 0.7
15
+ assert a.is_correct("billing") and not a.is_correct("technical")
16
+
17
+
18
+ def test_noul_predicts_a_bool_against_the_threshold():
19
+ assert parse_answer("q", NOUL).predicted() is True
20
+ assert parse_answer("q", {"type": "noul", "noul": 0.2}).predicted() is False
21
+ assert parse_answer("q", {"type": "noul", "noul": 0.5}).predicted() is True
22
+
23
+
24
+ def test_noul_synthesizes_a_two_outcome_distribution():
25
+ assert parse_answer("q", NOUL).probabilities == {"true": 0.99, "false": pytest.approx(0.01)}
26
+
27
+
28
+ def test_noul_has_no_confidence_but_has_decisiveness():
29
+ a = parse_answer("q", NOUL)
30
+ assert a.confidence is None
31
+ assert a.decisiveness == pytest.approx(0.98)
32
+
33
+
34
+ def test_score_predicts_the_most_probable_level_not_the_rounded_score():
35
+ # score is 1.03 but the mass could sit elsewhere; predicted follows the mass.
36
+ a = parse_answer("q", {"type": "score", "score": 1.03,
37
+ "probabilities": {"0": 0.45, "1": 0.1, "2": 0.45}})
38
+ assert a.predicted() == 0
39
+ assert parse_answer("q", SCORE).predicted() == 1
40
+
41
+
42
+ def test_score_is_correct_against_an_integer_label():
43
+ assert parse_answer("q", SCORE).is_correct(1)
44
+ assert not parse_answer("q", SCORE).is_correct(2)
45
+
46
+
47
+ def test_probability_of_an_unoffered_outcome_is_zero():
48
+ assert parse_answer("q", CHOICE).probability_of("sales") == 0.0
49
+
50
+
51
+ def test_top_probability_is_the_mass_on_the_prediction():
52
+ assert parse_answer("q", CHOICE).top_probability == 0.7
53
+
54
+
55
+ def test_type_is_inferred_when_the_discriminator_is_missing():
56
+ assert parse_answer("q", {"choice": "a", "probabilities": {"a": 1.0}}).type == "choice"
57
+ assert parse_answer("q", {"noul": 0.4}).type == "noul"
58
+
59
+
60
+ def test_unrecognisable_answer_raises():
61
+ with pytest.raises(ValueError, match="cannot determine type"):
62
+ parse_answer("q", {"mystery": 1})
63
+
64
+
65
+ def test_parse_answers_maps_every_question():
66
+ assert set(parse_answers({"a": CHOICE, "b": NOUL})) == {"a", "b"}
@@ -0,0 +1,40 @@
1
+ import json
2
+ import pytest
3
+ from jevkit_core import canonical_json, digest, record_id, request_id
4
+
5
+
6
+ def test_key_order_does_not_change_canonical_form():
7
+ assert canonical_json({"b": 1, "a": 2}) == canonical_json({"a": 2, "b": 1})
8
+
9
+
10
+ def test_canonical_form_has_no_insignificant_whitespace():
11
+ assert canonical_json({"a": [1, 2]}) == b'{"a":[1,2]}'
12
+
13
+
14
+ def test_canonical_form_is_utf8_not_escaped():
15
+ assert canonical_json({"k": "café"}) == '{"k":"café"}'.encode("utf-8")
16
+
17
+
18
+ def test_non_finite_numbers_are_rejected():
19
+ for bad in (float("nan"), float("inf"), float("-inf")):
20
+ with pytest.raises(ValueError):
21
+ canonical_json({"x": bad})
22
+ with pytest.raises(ValueError):
23
+ canonical_json({"nested": {"deep": [float("nan")]}})
24
+
25
+
26
+ def test_digest_is_stable_and_prefixed():
27
+ d = digest({"a": 1})
28
+ assert d.startswith("sha256:") and len(d) == 71
29
+ assert d == digest({"a": 1})
30
+
31
+
32
+ def test_request_id_ignores_model_but_record_id_does_not():
33
+ state, questions = "hello", {"q": {"type": "noul", "instructions": "ok?"}}
34
+ assert record_id("jev-1.13.0", state, questions) != record_id("jev-1.14.0", state, questions)
35
+ assert request_id(state, questions) == request_id(state, questions)
36
+
37
+
38
+ def test_record_id_changes_when_state_changes():
39
+ q = {"q": {"type": "noul", "instructions": "ok?"}}
40
+ assert record_id("m", "a", q) != record_id("m", "b", q)
@@ -0,0 +1,91 @@
1
+ import io
2
+ import json
3
+ import pytest
4
+ from jevkit_core import (FORMAT_VERSION, Record, RecordFormatError, load_cassette,
5
+ read_records, write_records)
6
+
7
+
8
+ def make(model="jev-1.13.0", state="s", answers=None):
9
+ return Record(model=model, state=state,
10
+ questions={"q": {"type": "noul", "instructions": "ok?"}},
11
+ answers=answers or {"q": {"type": "noul", "noul": 0.9}})
12
+
13
+
14
+ def test_id_is_derived_when_absent():
15
+ assert make().id.startswith("sha256:")
16
+
17
+
18
+ def test_round_trip_preserves_fields(tmp_path):
19
+ path = tmp_path / "a.jevl"
20
+ original = make()
21
+ original.tags = ["x"]
22
+ original.usage = {"input_tokens": 10}
23
+ assert write_records(path, [original]) == 1
24
+ (restored,) = list(read_records(path))
25
+ assert restored.id == original.id
26
+ assert restored.model == original.model
27
+ assert restored.tags == ["x"]
28
+ assert restored.usage == {"input_tokens": 10}
29
+
30
+
31
+ def test_unknown_keys_survive_round_trip(tmp_path):
32
+ path = tmp_path / "a.jevl"
33
+ raw = make().to_dict()
34
+ raw["future_field"] = {"kept": True}
35
+ path.write_text(json.dumps(raw) + "\n")
36
+ (record,) = list(read_records(path))
37
+ assert record.extra["future_field"] == {"kept": True}
38
+ assert record.to_dict()["future_field"] == {"kept": True}
39
+
40
+
41
+ def test_newer_format_version_is_refused(tmp_path):
42
+ path = tmp_path / "a.jevl"
43
+ raw = make().to_dict()
44
+ raw["v"] = FORMAT_VERSION + 1
45
+ path.write_text(json.dumps(raw) + "\n")
46
+ with pytest.raises(RecordFormatError, match="newer than this reader"):
47
+ list(read_records(path))
48
+
49
+
50
+ def test_malformed_line_raises_rather_than_skipping(tmp_path):
51
+ path = tmp_path / "a.jevl"
52
+ path.write_text(json.dumps(make().to_dict()) + "\nnot json\n")
53
+ with pytest.raises(RecordFormatError, match="invalid JSON"):
54
+ list(read_records(path))
55
+
56
+
57
+ def test_missing_required_key_raises(tmp_path):
58
+ path = tmp_path / "a.jevl"
59
+ raw = make().to_dict()
60
+ del raw["answers"]
61
+ path.write_text(json.dumps(raw) + "\n")
62
+ with pytest.raises(RecordFormatError, match="missing required key"):
63
+ list(read_records(path))
64
+
65
+
66
+ def test_blank_lines_are_skipped(tmp_path):
67
+ path = tmp_path / "a.jevl"
68
+ path.write_text("\n" + json.dumps(make().to_dict()) + "\n\n")
69
+ assert len(list(read_records(path))) == 1
70
+
71
+
72
+ def test_cassette_last_record_wins(tmp_path):
73
+ path = tmp_path / "a.jevl"
74
+ first = make(answers={"q": {"type": "noul", "noul": 0.1}})
75
+ second = make(answers={"q": {"type": "noul", "noul": 0.9}})
76
+ assert first.id == second.id
77
+ write_records(path, [first, second])
78
+ table = load_cassette(path)
79
+ assert len(table) == 1
80
+ assert table[first.id].answers["q"]["noul"] == 0.9
81
+
82
+
83
+ def test_request_id_pairs_records_across_model_versions():
84
+ a, b = make(model="jev-1.13.0"), make(model="jev-1.14.0")
85
+ assert a.id != b.id
86
+ assert a.request_id == b.request_id
87
+
88
+
89
+ def test_reads_from_a_stream():
90
+ text = json.dumps(make().to_dict()) + "\n"
91
+ assert len(list(read_records(io.StringIO(text)))) == 1
@@ -0,0 +1,39 @@
1
+ from jevkit_core import STATE_BUDGET, TOTAL_BUDGET, check_budget, estimate_tokens
2
+
3
+
4
+ def test_estimate_grows_with_length():
5
+ assert estimate_tokens("a" * 1000) > estimate_tokens("a" * 10)
6
+
7
+
8
+ def test_estimate_is_never_zero():
9
+ assert estimate_tokens("") >= 1
10
+
11
+
12
+ def test_small_request_is_within_both_budgets():
13
+ report = check_budget("short state", {"q": {"type": "noul", "instructions": "ok?"}})
14
+ assert not report.over_state and not report.over_total
15
+
16
+
17
+ def test_oversized_state_trips_the_state_budget():
18
+ report = check_budget("x" * (STATE_BUDGET * 4), {"q": {"type": "noul", "instructions": "ok?"}})
19
+ assert report.over_state
20
+
21
+
22
+ def test_many_questions_trip_the_total_budget():
23
+ questions = {f"q{i}": {"type": "noul", "instructions": "y" * 2000} for i in range(200)}
24
+ report = check_budget("s", questions)
25
+ assert report.over_total
26
+
27
+
28
+ def test_longest_question_is_identified():
29
+ report = check_budget("s", {
30
+ "small": {"type": "noul", "instructions": "a"},
31
+ "big": {"type": "noul", "instructions": "a" * 500},
32
+ })
33
+ assert report.longest_question_id == "big"
34
+
35
+
36
+ def test_empty_questions_does_not_crash():
37
+ report = check_budget("s", {})
38
+ assert report.longest_question_id is None
39
+ assert report.longest_pair == report.state_tokens