mic-evals 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.
- mic/__init__.py +59 -0
- mic/__main__.py +3 -0
- mic/_async.py +36 -0
- mic/_runtime/__init__.py +1 -0
- mic/_runtime/artifacts.py +177 -0
- mic/_runtime/batch.py +78 -0
- mic/_runtime/callbacks.py +51 -0
- mic/_runtime/case.py +111 -0
- mic/_runtime/contracts.py +14 -0
- mic/_runtime/discovery.py +57 -0
- mic/_runtime/engine.py +279 -0
- mic/_runtime/files.py +27 -0
- mic/_runtime/materialization.py +222 -0
- mic/_runtime/options.py +76 -0
- mic/_runtime/reporting.py +68 -0
- mic/_runtime/summary.py +107 -0
- mic/_runtime/validation.py +140 -0
- mic/cli.py +216 -0
- mic/decorators.py +130 -0
- mic/errors.py +17 -0
- mic/integrations/__init__.py +3 -0
- mic/integrations/pydantic.py +120 -0
- mic/models.py +152 -0
- mic/providers/__init__.py +21 -0
- mic/providers/_braintrust/__init__.py +1 -0
- mic/providers/_braintrust/transport.py +184 -0
- mic/providers/_io.py +98 -0
- mic/providers/base.py +102 -0
- mic/providers/bigquery.py +328 -0
- mic/providers/braintrust.py +248 -0
- mic/providers/files.py +114 -0
- mic/providers/memory.py +78 -0
- mic/py.typed +0 -0
- mic/reporters/__init__.py +7 -0
- mic/reporters/base.py +15 -0
- mic/reporters/braintrust.py +229 -0
- mic/reporters/console.py +47 -0
- mic/reporters/html.py +82 -0
- mic/reporters/templates/report.html +21 -0
- mic/reporters/templates/report.js +219 -0
- mic/runner.py +5 -0
- mic/schema/__init__.py +64 -0
- mic/schema/_compiler.py +176 -0
- mic/schema/_contracts.py +76 -0
- mic/schema/_dataclasses.py +148 -0
- mic/schema/_values.py +236 -0
- mic_evals-0.1.0.dist-info/METADATA +268 -0
- mic_evals-0.1.0.dist-info/RECORD +51 -0
- mic_evals-0.1.0.dist-info/WHEEL +4 -0
- mic_evals-0.1.0.dist-info/entry_points.txt +2 -0
- mic_evals-0.1.0.dist-info/licenses/LICENSE +21 -0
mic/__init__.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Typed micro-evaluations. Cloud SDKs and credentials are loaded only on demand."""
|
|
2
|
+
|
|
3
|
+
from ._runtime.materialization import ainspect_dataset, inspect_dataset
|
|
4
|
+
from .decorators import case_schema, dataset, eval, scorer
|
|
5
|
+
from .errors import ConfigurationError, DatasetError, MicError, MissingExpectedError
|
|
6
|
+
from .models import (
|
|
7
|
+
MISSING,
|
|
8
|
+
CaseSchema,
|
|
9
|
+
Dataset,
|
|
10
|
+
Evaluation,
|
|
11
|
+
JsonObject,
|
|
12
|
+
JsonValue,
|
|
13
|
+
Missing,
|
|
14
|
+
RawCase,
|
|
15
|
+
ReadLimits,
|
|
16
|
+
RunResult,
|
|
17
|
+
Score,
|
|
18
|
+
ScoreContext,
|
|
19
|
+
Scorer,
|
|
20
|
+
TaskContext,
|
|
21
|
+
TaskResult,
|
|
22
|
+
)
|
|
23
|
+
from .runner import apreflight, arun, preflight, run
|
|
24
|
+
from .schema import Schema, SchemaError, schema
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"MISSING",
|
|
28
|
+
"CaseSchema",
|
|
29
|
+
"ConfigurationError",
|
|
30
|
+
"Dataset",
|
|
31
|
+
"DatasetError",
|
|
32
|
+
"Evaluation",
|
|
33
|
+
"JsonObject",
|
|
34
|
+
"JsonValue",
|
|
35
|
+
"MicError",
|
|
36
|
+
"Missing",
|
|
37
|
+
"MissingExpectedError",
|
|
38
|
+
"RawCase",
|
|
39
|
+
"ReadLimits",
|
|
40
|
+
"RunResult",
|
|
41
|
+
"Schema",
|
|
42
|
+
"SchemaError",
|
|
43
|
+
"schema",
|
|
44
|
+
"Score",
|
|
45
|
+
"ScoreContext",
|
|
46
|
+
"Scorer",
|
|
47
|
+
"TaskContext",
|
|
48
|
+
"TaskResult",
|
|
49
|
+
"ainspect_dataset",
|
|
50
|
+
"apreflight",
|
|
51
|
+
"arun",
|
|
52
|
+
"case_schema",
|
|
53
|
+
"dataset",
|
|
54
|
+
"eval",
|
|
55
|
+
"inspect_dataset",
|
|
56
|
+
"preflight",
|
|
57
|
+
"run",
|
|
58
|
+
"scorer",
|
|
59
|
+
]
|
mic/__main__.py
ADDED
mic/_async.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Cancellation-safe joins for resources that must outlive their worker."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from collections.abc import Callable, Coroutine
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .errors import ConfigurationError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def run_sync[T](
|
|
11
|
+
sync_name: str, async_name: str, factory: Callable[[], Coroutine[Any, Any, T]]
|
|
12
|
+
) -> T:
|
|
13
|
+
"""Run an async implementation from synchronous code without leaking a coroutine."""
|
|
14
|
+
try:
|
|
15
|
+
asyncio.get_running_loop()
|
|
16
|
+
except RuntimeError:
|
|
17
|
+
pass
|
|
18
|
+
else:
|
|
19
|
+
raise ConfigurationError(
|
|
20
|
+
f"{sync_name} cannot run inside an event loop; use 'await {async_name}(...)'"
|
|
21
|
+
)
|
|
22
|
+
return asyncio.run(factory())
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
async def drain[T](future: asyncio.Future[T]) -> T:
|
|
26
|
+
"""Join after cancellation has been recorded, tolerating further cancellation.
|
|
27
|
+
|
|
28
|
+
Shielding prevents the caller from cancelling the worker. Cancellation of the
|
|
29
|
+
worker itself still propagates; it cannot make progress and must not be retried.
|
|
30
|
+
"""
|
|
31
|
+
while True:
|
|
32
|
+
try:
|
|
33
|
+
return await asyncio.shield(future)
|
|
34
|
+
except asyncio.CancelledError:
|
|
35
|
+
if future.done():
|
|
36
|
+
return future.result()
|
mic/_runtime/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Internal evaluation lifecycle, scheduling and case execution."""
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Local evidence is written before optional remote reporting."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import importlib.metadata
|
|
5
|
+
import platform
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
from collections.abc import Sequence
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from datetime import UTC, datetime
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import cast
|
|
13
|
+
|
|
14
|
+
from ..errors import ConfigurationError
|
|
15
|
+
from ..models import JsonObject, JsonValue
|
|
16
|
+
from .files import atomic_write
|
|
17
|
+
from .validation import dumps, json_object
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def atomic_json(path: Path, value: JsonObject) -> None:
|
|
21
|
+
atomic_write(path, (dumps(value) + "\n",))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def prepare_directory(path: Path) -> None:
|
|
25
|
+
if path.exists() and (not path.is_dir() or any(path.iterdir())):
|
|
26
|
+
raise ConfigurationError(f"Output directory must be empty: {path}")
|
|
27
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def write_dataset(path: Path, rows: Sequence[JsonObject]) -> None:
|
|
31
|
+
atomic_write(path, (dumps(row) + "\n" for row in rows))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def append_case(path: Path, case: JsonObject) -> None:
|
|
35
|
+
with path.open("a", encoding="utf-8") as stream:
|
|
36
|
+
stream.write(dumps(case) + "\n")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class Persistence:
|
|
41
|
+
"""Successful writes and all failed attempts, without claiming disk recovery."""
|
|
42
|
+
|
|
43
|
+
written: set[str] = field(default_factory=set[str])
|
|
44
|
+
errors: list[tuple[str, Exception]] = field(default_factory=list[tuple[str, Exception]])
|
|
45
|
+
|
|
46
|
+
def annotate(self, exc: BaseException, path: Path) -> None:
|
|
47
|
+
if self.errors:
|
|
48
|
+
exc.add_note(f"Local evidence in {path} may be incomplete or stale")
|
|
49
|
+
for name, error in self.errors:
|
|
50
|
+
exc.add_note(f"Could not persist {name}: {type(error).__name__}: {error}")
|
|
51
|
+
elif "report.html" in self.written:
|
|
52
|
+
exc.add_note(f"Local evidence: {path / 'report.html'}")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def has_artifact_failure(manifest: JsonObject) -> bool:
|
|
56
|
+
return any(
|
|
57
|
+
isinstance(error, dict) and error.get("phase") == "artifact"
|
|
58
|
+
for error in cast(list[JsonValue], manifest["failures"])
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def end_run(manifest: JsonObject, *, started: float) -> None:
|
|
63
|
+
"""Stamp terminal timing even when no output directory could be acquired."""
|
|
64
|
+
manifest["ended_at"] = datetime.now(UTC).isoformat()
|
|
65
|
+
manifest["duration_ms"] = (time.perf_counter() - started) * 1000
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def finish_artifacts(
|
|
69
|
+
path: Path, manifest: JsonObject, cases: Sequence[JsonObject], *, started: float
|
|
70
|
+
) -> Persistence:
|
|
71
|
+
"""Save a terminal outcome and attempt one bounded recovery after write failures.
|
|
72
|
+
|
|
73
|
+
Both destinations are attempted independently. Recovery reprojects the updated
|
|
74
|
+
failure manifest; persistent faults may leave disk behind the in-memory result.
|
|
75
|
+
Setup errors (2) and cancellation (130) retain their original precedence.
|
|
76
|
+
"""
|
|
77
|
+
from ..reporters.html import render_report
|
|
78
|
+
|
|
79
|
+
end_run(manifest, started=started)
|
|
80
|
+
result = Persistence()
|
|
81
|
+
for _ in range(2):
|
|
82
|
+
errors: list[tuple[str, Exception]] = []
|
|
83
|
+
for name in ("run.json", "report.html"):
|
|
84
|
+
try:
|
|
85
|
+
if name == "run.json":
|
|
86
|
+
atomic_json(path / name, manifest)
|
|
87
|
+
else:
|
|
88
|
+
atomic_write(path / name, (render_report(manifest, cases),))
|
|
89
|
+
result.written.add(name)
|
|
90
|
+
except Exception as exc:
|
|
91
|
+
# Rendering is an artifact boundary too. BaseException (including
|
|
92
|
+
# cancellation) deliberately remains outside this recovery path.
|
|
93
|
+
errors.append((name, exc))
|
|
94
|
+
result.written.discard(name)
|
|
95
|
+
if not errors:
|
|
96
|
+
break
|
|
97
|
+
result.errors.extend(errors)
|
|
98
|
+
for name, exc in errors:
|
|
99
|
+
record = failure("artifact", exc)
|
|
100
|
+
record["message"] = f"Could not persist {name}: {exc}"
|
|
101
|
+
cast(list[JsonValue], manifest["failures"]).append(record)
|
|
102
|
+
if manifest["exit_code"] not in (2, 130):
|
|
103
|
+
manifest.update({"status": "failed", "exit_code": 1})
|
|
104
|
+
return result
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def code_provenance(fn: object) -> JsonObject:
|
|
108
|
+
module_name = str(getattr(fn, "__module__", "unknown"))
|
|
109
|
+
symbol = str(getattr(fn, "__qualname__", getattr(fn, "__name__", "unknown")))
|
|
110
|
+
try:
|
|
111
|
+
package_version = importlib.metadata.version("mic-evals")
|
|
112
|
+
except importlib.metadata.PackageNotFoundError:
|
|
113
|
+
package_version = "uninstalled"
|
|
114
|
+
metadata: JsonObject = {
|
|
115
|
+
"python": platform.python_version(),
|
|
116
|
+
"package_version": package_version,
|
|
117
|
+
"definition": f"{module_name}:{symbol}",
|
|
118
|
+
"dependencies": {},
|
|
119
|
+
}
|
|
120
|
+
# Record optional schema/backend versions only when they were actually loaded.
|
|
121
|
+
dependencies: JsonObject = {}
|
|
122
|
+
for distribution, module_prefix in (("pydantic", "pydantic"),):
|
|
123
|
+
if module_prefix in sys.modules:
|
|
124
|
+
try:
|
|
125
|
+
dependencies[distribution] = importlib.metadata.version(distribution)
|
|
126
|
+
except importlib.metadata.PackageNotFoundError:
|
|
127
|
+
pass
|
|
128
|
+
metadata["dependencies"] = dependencies
|
|
129
|
+
module = sys.modules.get(module_name)
|
|
130
|
+
module_file = getattr(module, "__file__", None)
|
|
131
|
+
if module_file is not None:
|
|
132
|
+
source_path = Path(module_file).resolve()
|
|
133
|
+
if source_path.is_file():
|
|
134
|
+
metadata["source_path"] = str(source_path)
|
|
135
|
+
metadata["source_sha256"] = hashlib.sha256(source_path.read_bytes()).hexdigest()
|
|
136
|
+
# A framework content hash also works outside Git and is meaningful for an
|
|
137
|
+
# editable install with uncommitted changes.
|
|
138
|
+
package = Path(__file__).parents[1]
|
|
139
|
+
hasher = hashlib.sha256()
|
|
140
|
+
for source in sorted(package.rglob("*.py")):
|
|
141
|
+
hasher.update(str(source.relative_to(package)).encode())
|
|
142
|
+
hasher.update(source.read_bytes())
|
|
143
|
+
metadata["framework_sha256"] = hasher.hexdigest()
|
|
144
|
+
return metadata
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def failure(
|
|
148
|
+
phase: str,
|
|
149
|
+
exc: BaseException,
|
|
150
|
+
*,
|
|
151
|
+
row_index: int | None = None,
|
|
152
|
+
case_id: str | None = None,
|
|
153
|
+
trial: int | None = None,
|
|
154
|
+
scorer: str | None = None,
|
|
155
|
+
) -> JsonObject:
|
|
156
|
+
import traceback
|
|
157
|
+
|
|
158
|
+
result: JsonObject = {
|
|
159
|
+
"phase": phase,
|
|
160
|
+
"type": type(exc).__name__,
|
|
161
|
+
"message": str(exc),
|
|
162
|
+
"row_index": row_index,
|
|
163
|
+
"case_id": case_id,
|
|
164
|
+
"trial": trial,
|
|
165
|
+
"traceback": "".join(traceback.format_exception(type(exc), exc, exc.__traceback__)),
|
|
166
|
+
}
|
|
167
|
+
if scorer is not None:
|
|
168
|
+
result["scorer"] = scorer
|
|
169
|
+
return result
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def case_errors(cases: Sequence[JsonObject]) -> list[JsonValue]:
|
|
173
|
+
errors: list[JsonValue] = []
|
|
174
|
+
for case in cases:
|
|
175
|
+
for error in cast(list[JsonValue], case.get("errors", [])):
|
|
176
|
+
errors.append(json_object(error))
|
|
177
|
+
return errors
|
mic/_runtime/batch.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Admit bounded trials, journal completion and join workers before returning."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import cast
|
|
7
|
+
|
|
8
|
+
from .._async import drain
|
|
9
|
+
from ..models import Evaluation, JsonObject
|
|
10
|
+
from .artifacts import append_case
|
|
11
|
+
from .callbacks import CallbackPool
|
|
12
|
+
from .case import run_case
|
|
13
|
+
from .materialization import DatasetSnapshot
|
|
14
|
+
from .options import Options
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class BatchResult:
|
|
19
|
+
cases: list[JsonObject]
|
|
20
|
+
interrupted: asyncio.CancelledError | None
|
|
21
|
+
error: Exception | None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
async def execute[I, O, E, M](
|
|
25
|
+
spec: Evaluation[I, O, E, M],
|
|
26
|
+
snapshot: DatasetSnapshot[I, E, M],
|
|
27
|
+
options: Options,
|
|
28
|
+
destination: Path,
|
|
29
|
+
) -> BatchResult:
|
|
30
|
+
planned = len(snapshot.cases) * options.trials
|
|
31
|
+
cases: list[JsonObject] = []
|
|
32
|
+
pool = CallbackPool(options.concurrency)
|
|
33
|
+
next_job = 0
|
|
34
|
+
interrupted: asyncio.CancelledError | None = None
|
|
35
|
+
runtime_error: Exception | None = None
|
|
36
|
+
|
|
37
|
+
async def worker() -> None:
|
|
38
|
+
nonlocal next_job
|
|
39
|
+
while next_job < planned:
|
|
40
|
+
sequence = next_job
|
|
41
|
+
next_job += 1
|
|
42
|
+
row_index, trial_index = divmod(sequence, options.trials)
|
|
43
|
+
case = await run_case(
|
|
44
|
+
spec,
|
|
45
|
+
snapshot.cases[row_index],
|
|
46
|
+
snapshot.rows[row_index],
|
|
47
|
+
row_index,
|
|
48
|
+
trial_index + 1,
|
|
49
|
+
options,
|
|
50
|
+
pool,
|
|
51
|
+
)
|
|
52
|
+
cases.append(case)
|
|
53
|
+
append_case(destination / "cases.jsonl", case)
|
|
54
|
+
if case["status"] == "cancelled":
|
|
55
|
+
raise asyncio.CancelledError(f"Case {case['case_id']} was cancelled")
|
|
56
|
+
|
|
57
|
+
workers = [asyncio.create_task(worker()) for _ in range(min(options.concurrency, planned))]
|
|
58
|
+
try:
|
|
59
|
+
await asyncio.gather(*workers)
|
|
60
|
+
except asyncio.CancelledError as exc:
|
|
61
|
+
interrupted = exc
|
|
62
|
+
for worker_task in workers:
|
|
63
|
+
if not worker_task.done():
|
|
64
|
+
worker_task.cancel()
|
|
65
|
+
await drain(asyncio.gather(*workers, return_exceptions=True))
|
|
66
|
+
except Exception as exc:
|
|
67
|
+
runtime_error = exc
|
|
68
|
+
for worker_task in workers:
|
|
69
|
+
if not worker_task.done():
|
|
70
|
+
worker_task.cancel()
|
|
71
|
+
await drain(asyncio.gather(*workers, return_exceptions=True))
|
|
72
|
+
finally:
|
|
73
|
+
try:
|
|
74
|
+
await pool.close()
|
|
75
|
+
except asyncio.CancelledError as exc:
|
|
76
|
+
interrupted = exc
|
|
77
|
+
cases.sort(key=lambda value: (cast(int, value["row_index"]), cast(int, value["trial"])))
|
|
78
|
+
return BatchResult(cases, interrupted, runtime_error)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Bounded dispatch of synchronous and asynchronous callbacks."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import inspect
|
|
5
|
+
from collections.abc import Awaitable, Callable
|
|
6
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
7
|
+
from functools import partial
|
|
8
|
+
from typing import cast
|
|
9
|
+
|
|
10
|
+
from .._async import drain
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CallbackPool:
|
|
14
|
+
def __init__(self, concurrency: int) -> None:
|
|
15
|
+
self.executor = ThreadPoolExecutor(max_workers=concurrency, thread_name_prefix="mic")
|
|
16
|
+
|
|
17
|
+
async def invoke[T, **P](
|
|
18
|
+
self,
|
|
19
|
+
fn: Callable[P, T | Awaitable[T]],
|
|
20
|
+
*args: P.args,
|
|
21
|
+
**kwargs: P.kwargs,
|
|
22
|
+
) -> T:
|
|
23
|
+
if inspect.iscoroutinefunction(fn):
|
|
24
|
+
return await cast(Awaitable[T], fn(*args, **kwargs))
|
|
25
|
+
loop = asyncio.get_running_loop()
|
|
26
|
+
future = loop.run_in_executor(self.executor, partial(fn, *args, **kwargs))
|
|
27
|
+
try:
|
|
28
|
+
result = await asyncio.shield(future)
|
|
29
|
+
except asyncio.CancelledError:
|
|
30
|
+
# A running Python thread cannot be killed. Retain this worker slot
|
|
31
|
+
# and drain it rather than admitting replacements under a false bound.
|
|
32
|
+
try:
|
|
33
|
+
abandoned = await drain(future)
|
|
34
|
+
if inspect.iscoroutine(abandoned):
|
|
35
|
+
abandoned.close()
|
|
36
|
+
elif isinstance(abandoned, asyncio.Future):
|
|
37
|
+
abandoned.cancel()
|
|
38
|
+
except Exception:
|
|
39
|
+
pass
|
|
40
|
+
raise
|
|
41
|
+
return await cast(Awaitable[T], result) if inspect.isawaitable(result) else cast(T, result)
|
|
42
|
+
|
|
43
|
+
async def close(self) -> None:
|
|
44
|
+
closing = asyncio.create_task(
|
|
45
|
+
asyncio.to_thread(self.executor.shutdown, wait=True, cancel_futures=True)
|
|
46
|
+
)
|
|
47
|
+
try:
|
|
48
|
+
await asyncio.shield(closing)
|
|
49
|
+
except asyncio.CancelledError:
|
|
50
|
+
await drain(closing)
|
|
51
|
+
raise
|
mic/_runtime/case.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Execute one isolated trial and preserve its successful scores and failures."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import copy
|
|
5
|
+
import time
|
|
6
|
+
from typing import cast
|
|
7
|
+
|
|
8
|
+
from ..models import Evaluation, JsonObject, JsonValue, ScoreContext, TaskContext, TaskResult
|
|
9
|
+
from .artifacts import failure
|
|
10
|
+
from .callbacks import CallbackPool
|
|
11
|
+
from .contracts import Case
|
|
12
|
+
from .options import Options
|
|
13
|
+
from .validation import json_object, normalize_score, serialize, validate
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
async def run_case[I, O, E, M](
|
|
17
|
+
spec: Evaluation[I, O, E, M],
|
|
18
|
+
row: Case[I, E, M],
|
|
19
|
+
encoded: JsonObject,
|
|
20
|
+
row_index: int,
|
|
21
|
+
trial: int,
|
|
22
|
+
options: Options,
|
|
23
|
+
pool: CallbackPool,
|
|
24
|
+
) -> JsonObject:
|
|
25
|
+
result: JsonObject = copy.deepcopy(encoded)
|
|
26
|
+
result.pop("id", None)
|
|
27
|
+
scores: list[JsonValue] = []
|
|
28
|
+
errors: list[JsonValue] = []
|
|
29
|
+
latencies: JsonObject = {"task_ms": 0.0, "scoring_ms": 0.0, "total_ms": 0.0}
|
|
30
|
+
result.update(
|
|
31
|
+
{
|
|
32
|
+
"case_id": row.id,
|
|
33
|
+
"row_index": row_index,
|
|
34
|
+
"trial": trial,
|
|
35
|
+
"status": "completed",
|
|
36
|
+
"scores": scores,
|
|
37
|
+
"errors": errors,
|
|
38
|
+
"latency": latencies,
|
|
39
|
+
"provenance": copy.deepcopy(row.provenance),
|
|
40
|
+
}
|
|
41
|
+
)
|
|
42
|
+
started = time.perf_counter()
|
|
43
|
+
phase = "task"
|
|
44
|
+
scorer_name: str | None = None
|
|
45
|
+
phase_started = started
|
|
46
|
+
try:
|
|
47
|
+
async with asyncio.timeout(options.timeout):
|
|
48
|
+
cloned = copy.deepcopy(row)
|
|
49
|
+
ctx = TaskContext(cloned.id, trial, cloned.expected, cloned.metadata)
|
|
50
|
+
raw = await pool.invoke(spec.function, ctx, cloned.input)
|
|
51
|
+
latencies["task_ms"] = (time.perf_counter() - phase_started) * 1000
|
|
52
|
+
phase = "schema"
|
|
53
|
+
metadata = copy.deepcopy(row.metadata)
|
|
54
|
+
if isinstance(raw, TaskResult):
|
|
55
|
+
enriched = cast(TaskResult[O], raw)
|
|
56
|
+
output_raw = enriched.output
|
|
57
|
+
task_metadata = json_object(enriched.metadata, "$.task_metadata")
|
|
58
|
+
result["task_metadata"] = task_metadata
|
|
59
|
+
merged = (
|
|
60
|
+
{}
|
|
61
|
+
if metadata is None
|
|
62
|
+
else json_object(serialize(spec.dataset.schema.metadata, metadata))
|
|
63
|
+
)
|
|
64
|
+
merged.update(task_metadata)
|
|
65
|
+
metadata = validate(spec.dataset.schema.metadata, merged)
|
|
66
|
+
json_object(serialize(spec.dataset.schema.metadata, metadata), "$.metadata")
|
|
67
|
+
else:
|
|
68
|
+
output_raw = raw
|
|
69
|
+
output = validate(spec.output, output_raw)
|
|
70
|
+
result["output"] = serialize(spec.output, output)
|
|
71
|
+
phase = "scorer"
|
|
72
|
+
phase_started = time.perf_counter()
|
|
73
|
+
for scorer in spec.scorers:
|
|
74
|
+
scorer_name = scorer.name
|
|
75
|
+
context = ScoreContext(
|
|
76
|
+
copy.deepcopy(row.input),
|
|
77
|
+
copy.deepcopy(output),
|
|
78
|
+
copy.deepcopy(row.expected),
|
|
79
|
+
copy.deepcopy(metadata),
|
|
80
|
+
row.id,
|
|
81
|
+
trial,
|
|
82
|
+
)
|
|
83
|
+
raw_score = await pool.invoke(scorer.function, context)
|
|
84
|
+
scores.append(normalize_score(raw_score, scorer.name))
|
|
85
|
+
latencies["scoring_ms"] = (time.perf_counter() - phase_started) * 1000
|
|
86
|
+
except asyncio.CancelledError as exc:
|
|
87
|
+
result["status"] = "cancelled"
|
|
88
|
+
errors.append(
|
|
89
|
+
failure(
|
|
90
|
+
"cancelled",
|
|
91
|
+
exc,
|
|
92
|
+
row_index=row_index,
|
|
93
|
+
case_id=row.id,
|
|
94
|
+
trial=trial,
|
|
95
|
+
scorer=scorer_name,
|
|
96
|
+
)
|
|
97
|
+
)
|
|
98
|
+
except Exception as exc:
|
|
99
|
+
result["status"] = "failed"
|
|
100
|
+
errors.append(
|
|
101
|
+
failure(
|
|
102
|
+
phase, exc, row_index=row_index, case_id=row.id, trial=trial, scorer=scorer_name
|
|
103
|
+
)
|
|
104
|
+
)
|
|
105
|
+
finally:
|
|
106
|
+
if phase == "task" and latencies["task_ms"] == 0:
|
|
107
|
+
latencies["task_ms"] = (time.perf_counter() - phase_started) * 1000
|
|
108
|
+
elif phase == "scorer" and latencies["scoring_ms"] == 0:
|
|
109
|
+
latencies["scoring_ms"] = (time.perf_counter() - phase_started) * 1000
|
|
110
|
+
latencies["total_ms"] = (time.perf_counter() - started) * 1000
|
|
111
|
+
return result
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Internal normalized values shared by materialization and execution."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
|
|
5
|
+
from ..models import JsonObject, JsonValue, Missing
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class Case[I, E, M]:
|
|
10
|
+
id: str
|
|
11
|
+
input: I
|
|
12
|
+
expected: E | Missing
|
|
13
|
+
metadata: M | None
|
|
14
|
+
provenance: JsonObject = field(default_factory=dict[str, JsonValue])
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Explicit-module discovery; descriptor inspection never invokes a dataset factory."""
|
|
2
|
+
|
|
3
|
+
import importlib
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import mic
|
|
8
|
+
from mic.errors import ConfigurationError
|
|
9
|
+
|
|
10
|
+
type Definition = (
|
|
11
|
+
mic.Dataset[Any, Any, Any] | mic.Evaluation[Any, Any, Any, Any] | mic.Scorer[Any, Any, Any, Any]
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class DefinitionInfo:
|
|
17
|
+
kind: str
|
|
18
|
+
name: str
|
|
19
|
+
selector: str
|
|
20
|
+
definition: Definition
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _kind(value: object) -> str | None:
|
|
24
|
+
if isinstance(value, mic.Dataset):
|
|
25
|
+
return "dataset"
|
|
26
|
+
if isinstance(value, mic.Evaluation):
|
|
27
|
+
return "eval"
|
|
28
|
+
if isinstance(value, mic.Scorer):
|
|
29
|
+
return "scorer"
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def list_definitions(module_name: str) -> list[DefinitionInfo]:
|
|
34
|
+
"""Import only the requested trusted Python module, then inspect its exports."""
|
|
35
|
+
try:
|
|
36
|
+
module = importlib.import_module(module_name)
|
|
37
|
+
except Exception as exc:
|
|
38
|
+
raise ConfigurationError(f"Cannot import {module_name!r}: {exc}") from exc
|
|
39
|
+
result: list[DefinitionInfo] = []
|
|
40
|
+
for symbol, value in sorted(vars(module).items()):
|
|
41
|
+
kind = _kind(value)
|
|
42
|
+
if not symbol.startswith("_") and kind is not None:
|
|
43
|
+
result.append(DefinitionInfo(kind, value.name, f"{module_name}:{symbol}", value))
|
|
44
|
+
return result
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def resolve_definition(selector: str) -> Definition:
|
|
48
|
+
"""Exact ``module:symbol`` selection; no substring matching or global registry."""
|
|
49
|
+
if selector.count(":") != 1:
|
|
50
|
+
raise ConfigurationError("Select an exact definition with module:symbol")
|
|
51
|
+
module_name, symbol = selector.split(":")
|
|
52
|
+
if not module_name or not symbol.isidentifier() or symbol.startswith("_"):
|
|
53
|
+
raise ConfigurationError("Select an exact public definition with module:symbol")
|
|
54
|
+
for definition in list_definitions(module_name):
|
|
55
|
+
if definition.selector == selector:
|
|
56
|
+
return definition.definition
|
|
57
|
+
raise ConfigurationError(f"No mic definition found at {selector!r}")
|