evalmetry 1.0.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.
@@ -0,0 +1,261 @@
1
+ """Local JSONL benchmark contracts layered on lm-eval task configurations.
2
+
3
+ A bundle contains YAML tasks, local Python scoring code, and JSONL data. Paths
4
+ are relative to each YAML, never the shell's working directory. User functions
5
+ are executable Python and must come from a trusted bundle.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import json
11
+ import platform
12
+ from importlib.metadata import version
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ import yaml
17
+
18
+
19
+ class _ConfigLoader(yaml.SafeLoader):
20
+ """Inspect function references without executing user code."""
21
+
22
+
23
+ _ConfigLoader.add_constructor("!function", lambda loader, node: loader.construct_scalar(node))
24
+
25
+
26
+ def prepare_benchmarks(
27
+ include_paths: list[str], tasks: list[str],
28
+ ) -> tuple[Any, list[Any], dict[str, Any]]:
29
+ """Validate local tasks before model loading and return manager/configs/provenance.
30
+
31
+ The first contract supports explicit task names and local JSONL splits only.
32
+ ``metadata.eval_framework`` declares sample_id, primary_metric,
33
+ primary_filter, higher_is_better, and optional correctness_metric. All Python
34
+ and YAML in each include directory and all declared data/source_files are
35
+ content-hashed. External helper code must be listed in source_files.
36
+ """
37
+ from lm_eval.tasks import TaskManager
38
+
39
+ if not include_paths:
40
+ return None, tasks, {}
41
+ roots = [Path(p).expanduser().resolve() for p in include_paths]
42
+ builtin = set(TaskManager().all_tasks)
43
+ configs, files = _discover_local_tasks(roots, builtin)
44
+ selected, protocols = [], {}
45
+ for name in tasks:
46
+ if name not in configs:
47
+ if name not in builtin:
48
+ raise ValueError(f"unknown task: {name}; use explicit task names")
49
+ selected.append(name)
50
+ continue
51
+
52
+ path, raw = configs[name]
53
+ protocol = _validate_task_protocol(name, raw)
54
+ resolved, data_files = _resolve_split_files(name, path, raw, protocol)
55
+ files.update(data_files)
56
+ for source in protocol.get('source_files', []):
57
+ files.add((path.parent / source).resolve())
58
+ selected.append(_load_local_task(name, path, resolved, protocol))
59
+ protocols[name] = protocol
60
+
61
+ provenance = _build_provenance(files, protocols)
62
+ manager = TaskManager(include_path=[str(p) for p in roots])
63
+ return manager, selected, provenance
64
+
65
+
66
+ def _discover_local_tasks(
67
+ roots: list[Path], builtin: set[str],
68
+ ) -> tuple[dict[str, tuple[Path, dict[str, Any]]], set[Path]]:
69
+ """모든 bundle의 task 이름과 hash 대상 코드를 찾는다. !function은 실행하지 않는다.
70
+
71
+ 선택하지 않은 YAML도 이름 충돌을 검사하고, 모든 Python/YAML을 hash 대상에
72
+ 넣는다. 반면 JSONL과 source_files는 선택된 task에서 참조한 파일만 추가한다.
73
+ """
74
+ configs: dict[str, tuple[Path, dict[str, Any]]] = {}
75
+ files: set[Path] = set()
76
+ for root in roots:
77
+ if not root.is_dir():
78
+ raise ValueError(f"include-path is not a directory: {root}")
79
+ files.update(p for p in root.rglob('*') if p.suffix in {'.py', '.yaml', '.yml'})
80
+ for path in sorted(list(root.rglob('*.yaml')) + list(root.rglob('*.yml'))):
81
+ raw = yaml.load(path.read_text(), Loader=_ConfigLoader)
82
+ if not isinstance(raw, dict) or not isinstance(raw.get('task'), str):
83
+ raise ValueError(f"{path}: expected a named task YAML (groups/includes unsupported)")
84
+ name = raw['task']
85
+ if name in configs or name in builtin:
86
+ raise ValueError(f"duplicate task name: {name} ({path})")
87
+ configs[name] = (path, raw)
88
+ return configs, files
89
+
90
+
91
+ def _validate_task_protocol(name: str, raw: dict[str, Any]) -> dict[str, Any]:
92
+ """task 종류와 채점·filter 계약을 검증한다. 파일 읽기와 사용자 함수 로딩은 뒤에 한다.
93
+
94
+ 여러 설정이 잘못된 경우에도 기존과 같은 오류가 먼저 나오도록 검사 순서를
95
+ 유지한다. higher_is_better는 0/1 숫자가 아닌 bool이어야 한다.
96
+ """
97
+ if 'include' in raw or raw.get('dataset_path') != 'json':
98
+ raise ValueError(f"{name}: use a self-contained local JSONL task")
99
+ kind = raw.get('output_type')
100
+ if kind not in ('multiple_choice', 'generate_until'):
101
+ raise ValueError(f"{name}: unsupported output_type {kind}")
102
+ protocol = dict(raw.get('metadata', {}).get('eval_framework', {}))
103
+ from .judges import validate_judge_config
104
+ validate_judge_config(raw, protocol)
105
+ for field in ('sample_id', 'primary_metric', 'primary_filter', 'higher_is_better'):
106
+ if field not in protocol:
107
+ raise ValueError(f"{name}: metadata.eval_framework.{field} is required")
108
+ metrics = {m['metric']: m for m in raw.get('metric_list', [])}
109
+ primary = protocol['primary_metric']
110
+ correctness = protocol.get('correctness_metric')
111
+ if primary not in metrics or (correctness and correctness not in metrics):
112
+ raise ValueError(f"{name}: declared metric is absent from metric_list")
113
+ direction = protocol['higher_is_better']
114
+ if type(direction) is not bool or metrics[primary].get('higher_is_better') != direction:
115
+ raise ValueError(f"{name}: primary metric direction must match metric_list")
116
+ filters = [f['name'] for f in raw.get('filter_list', [{'name': 'none'}])]
117
+ if protocol['primary_filter'] not in filters:
118
+ raise ValueError(f"{name}: unknown primary_filter")
119
+ return protocol
120
+
121
+
122
+ def _resolve_split_files(
123
+ name: str, path: Path, raw: dict[str, Any], protocol: dict[str, Any],
124
+ ) -> tuple[dict[str, list[str]], set[Path]]:
125
+ """YAML 기준으로 데이터 경로를 풀고 각 split의 모든 JSONL 문서를 검증한다.
126
+
127
+ seen은 split마다 새로 만든다. 한 split이 여러 파일로 나뉘어도 ID 중복을
128
+ 잡고, 서로 다른 split에서 같은 ID를 쓰는 것은 허용한다. 빈 줄은 건너뛰되
129
+ 오류 위치는 실제 파일 줄 번호로 남긴다.
130
+ """
131
+ kind = raw['output_type']
132
+ files: set[Path] = set()
133
+ splits = raw.get('dataset_kwargs', {}).get('data_files', {})
134
+ evaluation_split = raw.get('test_split', raw.get('validation_split'))
135
+ if not isinstance(splits, dict) or not splits or evaluation_split not in splits:
136
+ raise ValueError(f"{name}: data_files must declare the evaluation split")
137
+ resolved = {}
138
+ for split, paths in splits.items():
139
+ paths = [paths] if isinstance(paths, str) else paths
140
+ seen = set()
141
+ resolved[split] = []
142
+ for item in paths:
143
+ data_path = (path.parent / item).resolve()
144
+ files.add(data_path)
145
+ resolved[split].append(str(data_path))
146
+ with data_path.open() as handle:
147
+ for line_number, line in enumerate(handle, 1):
148
+ if not line.strip():
149
+ continue
150
+ doc = json.loads(line)
151
+ validate_document(doc, kind, protocol['sample_id'], seen,
152
+ f"{data_path}:{line_number}",
153
+ answer_optional=protocol.get('scoring') == 'llm_judge')
154
+ if not seen:
155
+ raise ValueError(f"{name}: empty split {split}")
156
+ return resolved, files
157
+
158
+
159
+ def _load_local_task(
160
+ name: str, path: Path, resolved: dict[str, list[str]], protocol: dict[str, Any],
161
+ ) -> dict[str, Any]:
162
+ """검증된 task를 lm-eval loader로 읽는다. 이 단계에서 !function 코드가 로딩된다.
163
+
164
+ judge task는 평가 중에 채점하지 않도록 process_results를 바꾼다.
165
+ 실제 judge 요청은 생성 결과를 확보한 뒤 main.py의 평가 흐름에서 수행한다.
166
+ """
167
+ # Use the installed harness loader, including its native !function support.
168
+ try:
169
+ from lm_eval.tasks._yaml_loader import load_yaml
170
+ loaded = load_yaml(path)
171
+ except ImportError:
172
+ from lm_eval.utils import load_yaml_config
173
+ loaded = load_yaml_config(str(path))
174
+ loaded['dataset_kwargs']['data_files'] = resolved
175
+ if protocol.get('scoring') == 'llm_judge':
176
+ from .judges import pending_judge_results
177
+ judge = loaded['metadata']['eval_framework']['judge']
178
+ if not callable(judge['prompt']) or not callable(judge['score']):
179
+ raise ValueError(f"{name}: judge.prompt and judge.score must use !function")
180
+ # Generation finishes and is saved before any judge request is made.
181
+ loaded['process_results'] = pending_judge_results
182
+ loaded.setdefault('doc_to_target', "{{ answer if answer is defined and answer is not none else '' }}")
183
+ return loaded
184
+
185
+
186
+ def _build_provenance(files: set[Path], protocols: dict[str, Any]) -> dict[str, Any]:
187
+ """선택된 계약, 관련 파일 내용, 의존성 버전을 재현성 정보로 기록한다.
188
+
189
+ 경로와 패키지 이름을 정렬해 해당 항목의 기록 순서를 고정한다. 큰 데이터
190
+ 파일도 통째로 읽지 않고 chunk 단위로 hash에 반영한다.
191
+ """
192
+ hashes = {}
193
+ for path in sorted(files):
194
+ digest = hashlib.sha256()
195
+ with path.open('rb') as handle:
196
+ for block in iter(lambda: handle.read(1024 * 1024), b''):
197
+ digest.update(block)
198
+ hashes[str(path)] = digest.hexdigest()
199
+ dependencies = {"datasets", "jinja2", "PyYAML"}
200
+ for protocol in protocols.values():
201
+ dependencies.update(protocol.get("dependencies", []))
202
+ provenance = {'files': hashes, 'protocols': protocols,
203
+ 'python': platform.python_version(),
204
+ 'packages': {name: version(name) for name in sorted(dependencies)}}
205
+ return provenance
206
+
207
+
208
+ def validate_document(doc: dict, kind: str, id_field: str, seen: set, location: str,
209
+ *, answer_optional: bool = False) -> None:
210
+ """Check template fields and unique stable IDs across one complete split.
211
+
212
+ `seen` is shared across files of the same split and updated in place. IDs are
213
+ compared as strings, so integer 1 and string "1" refer to the same sample.
214
+ `answer_optional` permits an absent judge reference, not a malformed one.
215
+ """
216
+ if not isinstance(doc, dict):
217
+ raise ValueError(f"{location}: expected a JSON object")
218
+ sample_id = doc.get(id_field)
219
+ if type(sample_id) not in (str, int) or sample_id == '':
220
+ raise ValueError(f"{location}: {id_field} must be a nonempty string or integer")
221
+ key = str(sample_id)
222
+ if key in seen:
223
+ raise ValueError(f"{location}: duplicate sample ID {key}")
224
+ seen.add(key)
225
+ text_field = 'question' if kind == 'multiple_choice' else 'prompt'
226
+ if not isinstance(doc.get(text_field), str) or not doc[text_field].strip():
227
+ raise ValueError(f"{location}: missing/nonempty {text_field} required")
228
+ if kind == 'multiple_choice':
229
+ choices, label = doc.get('choices'), doc.get('label')
230
+ if (not isinstance(choices, list) or len(choices) < 2
231
+ or any(not isinstance(choice, str) or not choice.strip() for choice in choices)):
232
+ raise ValueError(f"{location}: choices must contain at least two nonempty strings")
233
+ if type(label) is not int or not 0 <= label < len(choices):
234
+ raise ValueError(f"{location}: label is outside choices")
235
+ else:
236
+ if answer_optional and 'answer' not in doc:
237
+ return
238
+ answer = doc.get('answer')
239
+ if (not isinstance(answer, (str, list))
240
+ or (isinstance(answer, list)
241
+ and (not answer or any(not isinstance(item, str) for item in answer)))):
242
+ raise ValueError(f"{location}: answer must be a string or nonempty list of strings")
243
+
244
+
245
+ def annotate_samples(samples_by_task: dict, provenance: dict) -> None:
246
+ """Add a namespaced contract while preserving all original harness fields.
247
+
248
+ correctness_metric explicitly opts into binary 0/1 interpretation; absent
249
+ correctness stays unknown even when another score happens to equal 1.
250
+ """
251
+ for task, protocol in provenance.get('protocols', {}).items():
252
+ for sample in samples_by_task.get(task, []):
253
+ metric = protocol.get('correctness_metric')
254
+ value = sample.get(metric) if metric else None
255
+ if metric and (not isinstance(value, (bool, int, float)) or value not in (0, 1)):
256
+ raise ValueError(f"{task}: correctness_metric must emit boolean or binary 0/1")
257
+ sample['_eval_framework'] = {
258
+ 'sample_id': sample['doc'][protocol['sample_id']],
259
+ 'primary_filter': protocol['primary_filter'],
260
+ 'is_correct': bool(value) if metric else None,
261
+ }