jev-align 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.
jev_align/__init__.py ADDED
@@ -0,0 +1,13 @@
1
+ """CLI for building AI Functions with pluggable evaluation backends."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ from .capture import Capture
6
+ from .runtime import AIFunction
7
+
8
+ __all__ = ["AIFunction", "Capture", "__version__"]
9
+
10
+ try:
11
+ __version__ = version("jev-align")
12
+ except PackageNotFoundError:
13
+ __version__ = "0+unknown"
@@ -0,0 +1,80 @@
1
+ from __future__ import annotations
2
+
3
+ import random
4
+ from dataclasses import dataclass
5
+ from statistics import mean, median
6
+
7
+ from .models import Prediction
8
+
9
+
10
+ def ambiguity(probability: float) -> float:
11
+ return 1.0 - 2.0 * abs(probability - 0.5)
12
+
13
+
14
+ def prediction_ambiguity(prediction: Prediction) -> float:
15
+ if prediction.label_probabilities is not None:
16
+ return max(
17
+ ambiguity(value) for value in prediction.label_probabilities.values()
18
+ )
19
+ if prediction.confidence is not None:
20
+ return 1.0 - prediction.confidence
21
+ if prediction.probability is None:
22
+ raise ValueError(
23
+ "prediction has neither native confidence nor class probability"
24
+ )
25
+ return ambiguity(prediction.probability)
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class Acquisition:
30
+ prediction: Prediction
31
+ source: str
32
+
33
+
34
+ def select_batch(
35
+ predictions: list[Prediction],
36
+ *,
37
+ batch_size: int = 5,
38
+ exploration_count: int = 1,
39
+ seed: int = 0,
40
+ ) -> list[Acquisition]:
41
+ if batch_size < 1:
42
+ raise ValueError("batch_size must be positive")
43
+ if not 0 <= exploration_count <= batch_size:
44
+ raise ValueError("exploration_count must be between zero and batch_size")
45
+ if len(predictions) < batch_size:
46
+ raise ValueError("not enough unlabeled rows for an acquisition batch")
47
+
48
+ ambiguous_count = batch_size - exploration_count
49
+ ranked = sorted(
50
+ predictions,
51
+ key=lambda item: (-prediction_ambiguity(item), item.story_id),
52
+ )
53
+ ambiguous = ranked[:ambiguous_count]
54
+ chosen = {item.story_id for item in ambiguous}
55
+ remainder = [item for item in predictions if item.story_id not in chosen]
56
+ rng = random.Random(seed)
57
+ exploration = rng.sample(remainder, exploration_count)
58
+ return [
59
+ *(Acquisition(item, "ambiguous") for item in ambiguous),
60
+ *(Acquisition(item, "exploration") for item in exploration),
61
+ ]
62
+
63
+
64
+ def ambiguity_summary(predictions: list[Prediction]) -> dict[str, float | int]:
65
+ values = [prediction_ambiguity(item) for item in predictions]
66
+ if not values:
67
+ return {
68
+ "count": 0,
69
+ "mean": 0.0,
70
+ "median": 0.0,
71
+ "at_least_0_5": 0,
72
+ "at_least_0_8": 0,
73
+ }
74
+ return {
75
+ "count": len(values),
76
+ "mean": mean(values),
77
+ "median": median(values),
78
+ "at_least_0_5": sum(value >= 0.5 for value in values),
79
+ "at_least_0_8": sum(value >= 0.8 for value in values),
80
+ }
jev_align/backends.py ADDED
@@ -0,0 +1,101 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping, Sequence
4
+ from dataclasses import dataclass
5
+ from typing import Literal, Protocol
6
+
7
+ from .models import (
8
+ BackendConfig,
9
+ MulticlassCandidateSpec,
10
+ MultilabelCandidateSpec,
11
+ Prediction,
12
+ ScoreCandidateSpec,
13
+ Story,
14
+ TaskSpec,
15
+ )
16
+
17
+ TaskKind = Literal["binary", "multiclass", "multilabel", "score"]
18
+ UncertaintyKind = Literal["calibrated_probability", "native_confidence"]
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class BackendCapabilities:
23
+ """What an evaluation backend can reliably provide to active learning."""
24
+
25
+ task_types: frozenset[TaskKind]
26
+ uncertainty: Mapping[TaskKind, UncertaintyKind]
27
+
28
+
29
+ class EvaluationBackend(Protocol):
30
+ backend_id: str
31
+ display_name: str
32
+ model: str
33
+ capabilities: BackendCapabilities
34
+
35
+ def evaluate_many(
36
+ self, candidate: TaskSpec, stories: Sequence[Story]
37
+ ) -> list[Prediction]: ...
38
+
39
+
40
+ def create_backend(
41
+ config: BackendConfig, *, concurrency: int
42
+ ) -> EvaluationBackend:
43
+ """Construct the configured adapter without leaking providers into the core."""
44
+ if config.provider == "typesafe":
45
+ from .jev import TypeSafeJevEvaluator
46
+
47
+ return TypeSafeJevEvaluator(model=config.model, concurrency=concurrency)
48
+ raise ValueError(f"unsupported evaluation backend: {config.provider}")
49
+
50
+
51
+ def backend_credential_error(
52
+ config: BackendConfig, environment: Mapping[str, str]
53
+ ) -> str | None:
54
+ if config.provider == "typesafe" and not environment.get("TYPESAFE_API_KEY"):
55
+ return "TYPESAFE_API_KEY must be set for the TypeSafe backend"
56
+ return None
57
+
58
+
59
+ def backend_display_name(backend: EvaluationBackend) -> str:
60
+ """Give minimal third-party adapters a useful name without extra boilerplate."""
61
+ return str(getattr(backend, "display_name", backend.__class__.__name__))
62
+
63
+
64
+ def task_kind(candidate: TaskSpec) -> TaskKind:
65
+ if isinstance(candidate, MultilabelCandidateSpec):
66
+ return "multilabel"
67
+ if isinstance(candidate, ScoreCandidateSpec):
68
+ return "score"
69
+ if isinstance(candidate, MulticlassCandidateSpec):
70
+ return "multiclass"
71
+ return "binary"
72
+
73
+
74
+ def validate_backend_for_task(
75
+ backend: EvaluationBackend, candidate: TaskSpec
76
+ ) -> None:
77
+ """Reject adapters that cannot supply uncertainty for the requested task."""
78
+ kind = task_kind(candidate)
79
+ capabilities = backend.capabilities
80
+ if kind not in capabilities.task_types:
81
+ raise ValueError(
82
+ f"{backend_display_name(backend)} does not support {kind} AI Functions"
83
+ )
84
+ if kind not in capabilities.uncertainty:
85
+ raise ValueError(
86
+ f"{backend_display_name(backend)} does not provide usable uncertainty "
87
+ f"for {kind} AI Functions"
88
+ )
89
+
90
+
91
+ def evaluate_with_progress(
92
+ backend: EvaluationBackend,
93
+ candidate: TaskSpec,
94
+ stories: Sequence[Story],
95
+ description: str,
96
+ ) -> list[Prediction]:
97
+ """Use progress when supported, while preserving lightweight test adapters."""
98
+ method = getattr(backend, "evaluate_many_with_progress", None)
99
+ if callable(method):
100
+ return method(candidate, stories, description)
101
+ return backend.evaluate_many(candidate, stories)
jev_align/capture.py ADDED
@@ -0,0 +1,183 @@
1
+ """Best-effort, bounded local capture. Never makes evaluation requests."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import os
8
+ import random
9
+ import threading
10
+ from datetime import datetime, timezone
11
+ from pathlib import Path
12
+ from queue import Empty, Full, Queue
13
+ from time import monotonic
14
+ from uuid import uuid4
15
+
16
+ from .acquisition import prediction_ambiguity
17
+ from .models import Prediction, Story
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ class Capture:
23
+ """One bounded queue and background JSONL writer per instance.
24
+
25
+ Use as a context manager or call close() at application shutdown. Create
26
+ instances inside each worker process, after forking. Captures are disposable
27
+ observations, never human labels or changes to an alignment run.
28
+ """
29
+
30
+ def __init__(
31
+ self,
32
+ directory: str | Path = ".jev-align/captures",
33
+ *,
34
+ ambiguity_threshold: float = 0.8,
35
+ audit_rate: float = 0.05,
36
+ queue_size: int = 256,
37
+ max_record_bytes: int = 64 * 1024,
38
+ max_file_bytes: int = 64 * 1024 * 1024,
39
+ ) -> None:
40
+ if not 0 <= ambiguity_threshold <= 1 or not 0 <= audit_rate <= 1:
41
+ raise ValueError("ambiguity_threshold and audit_rate must be in [0, 1]")
42
+ if min(queue_size, max_record_bytes, max_file_bytes) < 1:
43
+ raise ValueError("capture size limits must be positive")
44
+ self.path = Path(directory).resolve() / f"{uuid4().hex}.jsonl"
45
+ self.ambiguity_threshold = ambiguity_threshold
46
+ self.audit_rate = audit_rate
47
+ self.max_record_bytes = max_record_bytes
48
+ self.max_file_bytes = max_file_bytes
49
+ self._queue: Queue[bytes] = Queue(maxsize=queue_size)
50
+ self._lock = threading.Lock()
51
+ self._stop = threading.Event()
52
+ self._pid = os.getpid()
53
+ self._reserved_bytes = 0
54
+ self._written = 0
55
+ self._dropped = 0
56
+ self._error: str | None = None
57
+ self._thread = threading.Thread(
58
+ target=self._write_loop, name="jev-align-capture", daemon=True
59
+ )
60
+ self._thread.start()
61
+
62
+ @property
63
+ def stats(self) -> dict[str, int | str | None]:
64
+ """Counters for selected records; filtered-out calls are not drops."""
65
+ if os.getpid() != self._pid:
66
+ return {"written": 0, "dropped": 0, "error": "create Capture after fork"}
67
+ with self._lock:
68
+ return {
69
+ "written": self._written,
70
+ "dropped": self._dropped,
71
+ "error": self._error,
72
+ }
73
+
74
+ def record(
75
+ self,
76
+ story: Story,
77
+ prediction: Prediction,
78
+ *,
79
+ run_id: str,
80
+ definition: dict[str, object],
81
+ fingerprint: str,
82
+ ) -> None:
83
+ # An inherited writer has no running thread. Avoid its inherited locks.
84
+ if os.getpid() != self._pid:
85
+ return
86
+ uncertainty = prediction_ambiguity(prediction)
87
+ reason = "ambiguous"
88
+ if uncertainty < self.ambiguity_threshold:
89
+ if random.random() >= self.audit_rate:
90
+ return
91
+ reason = "audit"
92
+ with self._lock:
93
+ if (
94
+ self._stop.is_set()
95
+ or self._queue.full()
96
+ or self._reserved_bytes >= self.max_file_bytes
97
+ or sum(len(value) for value in story.fields.values())
98
+ > self.max_record_bytes
99
+ ):
100
+ self._dropped += 1
101
+ return
102
+ payload = {
103
+ "version": 1,
104
+ "created_at": datetime.now(timezone.utc).isoformat(),
105
+ "run_id": run_id,
106
+ "definition": definition,
107
+ "fingerprint": fingerprint,
108
+ "input": story.fields,
109
+ "prediction": prediction.model_dump(mode="json"),
110
+ "ambiguity": uncertainty,
111
+ "reason": reason,
112
+ }
113
+ line = (json.dumps(payload) + "\n").encode("utf-8")
114
+ with self._lock:
115
+ if (
116
+ self._stop.is_set()
117
+ or len(line) > self.max_record_bytes
118
+ or self._reserved_bytes + len(line) > self.max_file_bytes
119
+ ):
120
+ self._dropped += 1
121
+ return
122
+ try:
123
+ self._queue.put_nowait(line)
124
+ except Full:
125
+ self._dropped += 1
126
+ else:
127
+ self._reserved_bytes += len(line)
128
+
129
+ def _write_loop(self) -> None:
130
+ batch: list[bytes] = []
131
+ try:
132
+ self.path.parent.mkdir(parents=True, exist_ok=True)
133
+ with self.path.open("xb") as handle:
134
+ deadline = monotonic() + 0.25
135
+ while not self._stop.is_set() or not self._queue.empty():
136
+ try:
137
+ batch.append(
138
+ self._queue.get(timeout=max(0, deadline - monotonic()))
139
+ )
140
+ except Empty:
141
+ pass
142
+ if len(batch) >= 64 or monotonic() >= deadline:
143
+ handle.write(b"".join(batch))
144
+ handle.flush()
145
+ with self._lock:
146
+ self._written += len(batch)
147
+ batch.clear()
148
+ deadline = monotonic() + 0.25
149
+ handle.write(b"".join(batch))
150
+ handle.flush()
151
+ with self._lock:
152
+ self._written += len(batch)
153
+ batch.clear()
154
+ except OSError as error:
155
+ with self._lock:
156
+ self._error = str(error)
157
+ self._stop.set()
158
+ self._dropped += len(batch)
159
+ while True:
160
+ try:
161
+ self._queue.get_nowait()
162
+ self._dropped += 1
163
+ except Empty:
164
+ break
165
+ logger.warning("JEV capture disabled after a local write error: %s", error)
166
+
167
+ def close(self, timeout: float = 5.0) -> None:
168
+ """Stop accepting records and wait up to timeout seconds for the writer.
169
+
170
+ Buffered records may be lost on abrupt exit or if shutdown times out.
171
+ No fsync or durability guarantee is added to the request path.
172
+ """
173
+ if os.getpid() != self._pid:
174
+ return
175
+ with self._lock:
176
+ self._stop.set()
177
+ self._thread.join(timeout=timeout)
178
+
179
+ def __enter__(self) -> Capture:
180
+ return self
181
+
182
+ def __exit__(self, *_args: object) -> None:
183
+ self.close()
@@ -0,0 +1,127 @@
1
+ """Read deduplicated captured inputs without model calls."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import heapq
7
+ import json
8
+ import logging
9
+ import os
10
+ from collections.abc import Iterable
11
+ from pathlib import Path
12
+
13
+ from .models import RunState, Story
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ def captured_story_id(fields: dict[str, str]) -> str:
19
+ payload = json.dumps(fields, sort_keys=True).encode("utf-8")
20
+ return "capture-" + hashlib.sha256(payload).hexdigest()
21
+
22
+
23
+ def sample_captured_inputs(
24
+ directories: Iterable[Path],
25
+ state: RunState,
26
+ *,
27
+ excluded: Iterable[Story],
28
+ saved: Iterable[Story] = (),
29
+ required: Iterable[Story] = (),
30
+ limit: int | None = None,
31
+ ) -> list[Story]:
32
+ """Keep required inputs plus all other unique inputs by default.
33
+
34
+ An optional limit is retained for callers that explicitly request sampling;
35
+ required inputs do not count toward it. Stable hash priorities avoid
36
+ favoring frequent duplicates or the first file scanned when a limit is used.
37
+ Read only complete lines present when each file is opened, so active writers
38
+ cannot extend the scan.
39
+ """
40
+ if limit is not None and limit < 1:
41
+ raise ValueError("capture sample limit must be positive")
42
+ excluded_ids = {captured_story_id(story.fields) for story in excluded}
43
+ required_by_id = {
44
+ story.id: story for story in required if story.id not in excluded_ids
45
+ }
46
+ selected: dict[str, Story] = {}
47
+ heap: list[tuple[int, str]] = []
48
+
49
+ def consider(story: Story) -> None:
50
+ if (
51
+ story.id in excluded_ids
52
+ or story.id in required_by_id
53
+ or story.id in selected
54
+ ):
55
+ return
56
+ if limit is None:
57
+ selected[story.id] = story
58
+ return
59
+ priority = int(
60
+ hashlib.sha256(
61
+ f"{state.seed}:{state.round_number}:{story.id}".encode()
62
+ ).hexdigest(),
63
+ 16,
64
+ )
65
+ if len(heap) == limit:
66
+ if priority >= -heap[0][0]:
67
+ return
68
+ _, removed = heapq.heappop(heap)
69
+ del selected[removed]
70
+ heapq.heappush(heap, (-priority, story.id))
71
+ selected[story.id] = story
72
+
73
+ for story in saved:
74
+ consider(story)
75
+ paths = {path for directory in directories for path in directory.glob("*.jsonl")}
76
+ for path in sorted(paths):
77
+ try:
78
+ with path.open("rb") as handle:
79
+ boundary = os.fstat(handle.fileno()).st_size
80
+ oversized = False
81
+ while handle.tell() < boundary:
82
+ line = handle.readline(min(1024 * 1024, boundary - handle.tell()))
83
+ if not line:
84
+ break
85
+ if not line.endswith(b"\n"):
86
+ oversized = True
87
+ continue
88
+ if oversized:
89
+ oversized = False
90
+ continue
91
+ try:
92
+ row = json.loads(line)
93
+ except (ValueError, UnicodeError):
94
+ continue
95
+ if not isinstance(row, dict) or row.get("version") != 1:
96
+ continue
97
+ if row.get("run_id") != state.run_id:
98
+ continue
99
+ fields = row.get("input")
100
+ if (
101
+ not isinstance(fields, dict)
102
+ or not fields
103
+ or not all(
104
+ isinstance(key, str) and isinstance(value, str)
105
+ for key, value in fields.items()
106
+ )
107
+ ):
108
+ continue
109
+ expected = (
110
+ ["content"]
111
+ if state.column_mode == "all_concatenated"
112
+ else state.selected_columns
113
+ )
114
+ if expected is not None and set(fields) != set(expected):
115
+ continue
116
+ consider(
117
+ Story(id=captured_story_id(fields), row_number=0, fields=fields)
118
+ )
119
+ except OSError as error:
120
+ logger.warning("Could not read capture file %s: %s", path, error)
121
+ selected_ids = (
122
+ sorted(selected) if limit is None else [story_id for _, story_id in sorted(heap)]
123
+ )
124
+ return [
125
+ *(required_by_id[story_id] for story_id in sorted(required_by_id)),
126
+ *(selected[story_id] for story_id in selected_ids),
127
+ ]