hermes-benchmark 0.0.5__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.
Files changed (52) hide show
  1. hermes_benchmark/__init__.py +0 -0
  2. hermes_benchmark/capacity/__init__.py +16 -0
  3. hermes_benchmark/capacity/estimates.py +309 -0
  4. hermes_benchmark/capacity/models.py +142 -0
  5. hermes_benchmark/capacity/pilot.py +133 -0
  6. hermes_benchmark/capacity/profiler.py +387 -0
  7. hermes_benchmark/cli.py +519 -0
  8. hermes_benchmark/config.py +209 -0
  9. hermes_benchmark/enrichment/__init__.py +21 -0
  10. hermes_benchmark/enrichment/builder.py +556 -0
  11. hermes_benchmark/enrichment/clustering.py +56 -0
  12. hermes_benchmark/enrichment/models.py +18 -0
  13. hermes_benchmark/enrichment/providers.py +96 -0
  14. hermes_benchmark/io.py +38 -0
  15. hermes_benchmark/language_templates.py +81 -0
  16. hermes_benchmark/ner_ontology.py +124 -0
  17. hermes_benchmark/pipeline.py +1142 -0
  18. hermes_benchmark/progress.py +46 -0
  19. hermes_benchmark/tasks/__init__.py +70 -0
  20. hermes_benchmark/tasks/classification.py +1381 -0
  21. hermes_benchmark/tasks/classification_provider.py +261 -0
  22. hermes_benchmark/tasks/extractive_qa.py +1318 -0
  23. hermes_benchmark/tasks/google_client.py +26 -0
  24. hermes_benchmark/tasks/knowledge_base.py +429 -0
  25. hermes_benchmark/tasks/models.py +110 -0
  26. hermes_benchmark/tasks/ner.py +808 -0
  27. hermes_benchmark/tasks/output_names.py +118 -0
  28. hermes_benchmark/tasks/paragraphs.py +926 -0
  29. hermes_benchmark/tasks/qa_provider.py +290 -0
  30. hermes_benchmark/tasks/reranking.py +745 -0
  31. hermes_benchmark/templates/ar.yaml +116 -0
  32. hermes_benchmark/templates/az.yaml +116 -0
  33. hermes_benchmark/templates/de.yaml +117 -0
  34. hermes_benchmark/templates/en.yaml +116 -0
  35. hermes_benchmark/templates/es.yaml +116 -0
  36. hermes_benchmark/templates/fr.yaml +116 -0
  37. hermes_benchmark/templates/pt.yaml +116 -0
  38. hermes_benchmark/templates/ru.yaml +116 -0
  39. hermes_benchmark/templates/tr.yaml +116 -0
  40. hermes_benchmark/templates/zh.yaml +109 -0
  41. hermes_benchmark/utils/__init__.py +1 -0
  42. hermes_benchmark/utils/hashing.py +18 -0
  43. hermes_benchmark/wikipedia/__init__.py +15 -0
  44. hermes_benchmark/wikipedia/corpus.py +371 -0
  45. hermes_benchmark/wikipedia/dump.py +159 -0
  46. hermes_benchmark/wikipedia/models.py +49 -0
  47. hermes_benchmark/wikipedia/text.py +235 -0
  48. hermes_benchmark-0.0.5.dist-info/METADATA +137 -0
  49. hermes_benchmark-0.0.5.dist-info/RECORD +52 -0
  50. hermes_benchmark-0.0.5.dist-info/WHEEL +4 -0
  51. hermes_benchmark-0.0.5.dist-info/entry_points.txt +2 -0
  52. hermes_benchmark-0.0.5.dist-info/licenses/LICENSE +19 -0
File without changes
@@ -0,0 +1,16 @@
1
+ """Wikipedia dump profiling and benchmark capacity estimation."""
2
+
3
+ from .estimates import estimate_task_capacities
4
+ from .models import CapacityReport, CapacityRange, CorpusProfile, TaskCapacity
5
+ from .pilot import run_pilot
6
+ from .profiler import profile_dump
7
+
8
+ __all__ = [
9
+ "CapacityRange",
10
+ "CapacityReport",
11
+ "CorpusProfile",
12
+ "TaskCapacity",
13
+ "estimate_task_capacities",
14
+ "profile_dump",
15
+ "run_pilot",
16
+ ]
@@ -0,0 +1,309 @@
1
+ """Task-capacity formulas compatible with the legacy 10K pipeline."""
2
+
3
+ from typing import Dict, Iterable, List, Mapping, Optional, Tuple
4
+
5
+ from hermes_benchmark.config import ServicesConfig, TaskConfig
6
+
7
+ from .models import (
8
+ CapacityRange,
9
+ CapacityReport,
10
+ CorpusProfile,
11
+ PoolConstraint,
12
+ TaskCapacity,
13
+ )
14
+
15
+
16
+ _STATUS_PRIORITY = {
17
+ "disabled": 0,
18
+ "sufficient": 1,
19
+ "uncertain": 2,
20
+ "insufficient": 3,
21
+ "blocked": 4,
22
+ }
23
+
24
+
25
+ def _task_status(values: Iterable[CapacityRange]) -> str:
26
+ statuses = [value.status for value in values]
27
+ return max(statuses, key=lambda value: _STATUS_PRIORITY[value])
28
+
29
+
30
+ def _missing_services(
31
+ services: ServicesConfig,
32
+ required: Iterable[str],
33
+ ) -> Tuple[str, ...]:
34
+ missing = []
35
+ for name in required:
36
+ if getattr(services, name) is None:
37
+ missing.append(name)
38
+ return tuple(missing)
39
+
40
+
41
+ def _option(task: TaskConfig, name: str, default: Optional[int]) -> Optional[int]:
42
+ if name not in task.options:
43
+ return default
44
+ value = task.options[name]
45
+ if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
46
+ raise ValueError(f"Task option {name!r} must be a positive integer")
47
+ return value
48
+
49
+
50
+ def _structural_range(
51
+ profile: CorpusProfile,
52
+ requested: int,
53
+ observed: int,
54
+ estimated: Optional[int],
55
+ upper: Optional[int],
56
+ basis: str,
57
+ missing: Tuple[str, ...] = (),
58
+ ) -> CapacityRange:
59
+ if missing:
60
+ status = "blocked"
61
+ elif observed >= requested:
62
+ status = "sufficient"
63
+ elif profile.complete:
64
+ status = "insufficient"
65
+ else:
66
+ status = "uncertain"
67
+ return CapacityRange(
68
+ requested=requested,
69
+ observed_candidates=observed,
70
+ lower_bound=observed,
71
+ estimated=estimated,
72
+ upper_bound=observed if profile.complete else upper,
73
+ confidence="exact" if profile.complete else "sample_projection",
74
+ status=status,
75
+ basis=basis,
76
+ dependencies=missing,
77
+ )
78
+
79
+
80
+ def _proxy_range(
81
+ profile: CorpusProfile,
82
+ requested: int,
83
+ observed: int,
84
+ projected_upper: Optional[int],
85
+ basis: str,
86
+ missing: Tuple[str, ...],
87
+ ) -> CapacityRange:
88
+ upper = observed if profile.complete else projected_upper
89
+ if missing:
90
+ status = "blocked"
91
+ elif upper is not None and upper < requested and profile.complete:
92
+ status = "insufficient"
93
+ else:
94
+ status = "uncertain"
95
+ return CapacityRange(
96
+ requested=requested,
97
+ observed_candidates=observed,
98
+ lower_bound=0,
99
+ estimated=None,
100
+ upper_bound=upper,
101
+ confidence="proxy_upper_bound",
102
+ status=status,
103
+ basis=basis,
104
+ dependencies=missing,
105
+ )
106
+
107
+
108
+ def _article_upper(profile: CorpusProfile, multiplier: int = 1) -> Optional[int]:
109
+ if profile.complete:
110
+ return profile.eligible_articles * multiplier
111
+ limits = [
112
+ value
113
+ for value in (profile.article_limit, profile.max_pages)
114
+ if value is not None
115
+ ]
116
+ return min(limits) * multiplier if limits else None
117
+
118
+
119
+ def _disabled(name: str, task: TaskConfig) -> TaskCapacity:
120
+ return TaskCapacity(
121
+ name=name,
122
+ enabled=False,
123
+ requested=task.target,
124
+ status="disabled",
125
+ variants={},
126
+ )
127
+
128
+
129
+ def estimate_task_capacities(
130
+ profile: CorpusProfile,
131
+ tasks: Mapping[str, TaskConfig],
132
+ services: ServicesConfig,
133
+ ) -> CapacityReport:
134
+ """Estimate each configured task without invoking external services."""
135
+
136
+ results: Dict[str, TaskCapacity] = {}
137
+ report_warnings: List[str] = list(profile.warnings)
138
+ embeddings = _missing_services(services, ("embeddings",))
139
+ knowledge_base = _missing_services(services, ("knowledge_base",))
140
+ llm_and_embeddings = _missing_services(services, ("llm", "embeddings"))
141
+
142
+ for name, task in tasks.items():
143
+ if not task.enabled:
144
+ results[name] = _disabled(name, task)
145
+ continue
146
+ variants: Dict[str, CapacityRange]
147
+ warnings: Tuple[str, ...] = ()
148
+ if name in {
149
+ "paragraph_coherence",
150
+ "paragraph_ordering",
151
+ "article_membership",
152
+ "next_paragraph",
153
+ }:
154
+ missing = () if name == "paragraph_ordering" else embeddings
155
+ value = _structural_range(
156
+ profile,
157
+ task.target,
158
+ profile.articles_four_plus,
159
+ profile.estimated_articles_four_plus,
160
+ _article_upper(profile),
161
+ "articles with at least four selected paragraphs",
162
+ missing,
163
+ )
164
+ variants = {"default": value}
165
+ elif name == "reranking":
166
+ candidate_count = _option(task, "candidate_count", 20)
167
+ query_per_title = _option(task, "query_per_title", 1)
168
+ assert candidate_count is not None
169
+ assert query_per_title is not None
170
+ observed = (
171
+ profile.reranking_articles * query_per_title
172
+ if profile.reranking_articles >= candidate_count
173
+ else 0
174
+ )
175
+ projected_articles = profile.estimated_reranking_articles
176
+ estimated = (
177
+ projected_articles * query_per_title
178
+ if projected_articles is not None
179
+ and projected_articles >= candidate_count
180
+ else (0 if projected_articles is not None else None)
181
+ )
182
+ variants = {
183
+ "type1": _structural_range(
184
+ profile,
185
+ task.target,
186
+ observed,
187
+ estimated,
188
+ _article_upper(profile, query_per_title),
189
+ (
190
+ "reranking-ready articles; at least "
191
+ f"{candidate_count} distinct titles required"
192
+ ),
193
+ embeddings,
194
+ )
195
+ }
196
+ elif name == "classification":
197
+ candidate_total = _option(task, "candidate_total", None)
198
+ observed = profile.category_articles
199
+ projected = profile.estimated_category_articles
200
+ if candidate_total is not None:
201
+ observed = min(observed, candidate_total)
202
+ projected = (
203
+ min(projected, candidate_total) if projected is not None else None
204
+ )
205
+ value = _proxy_range(
206
+ profile,
207
+ task.target,
208
+ observed,
209
+ projected,
210
+ "category-bearing article proxy before clustering and LLM review",
211
+ llm_and_embeddings,
212
+ )
213
+ variants = {name: value for name in ("type1", "type2", "type3")}
214
+ elif name == "ner":
215
+ context = _proxy_range(
216
+ profile,
217
+ task.target,
218
+ profile.entity_contexts,
219
+ profile.estimated_entity_contexts,
220
+ "internal-link context proxy before Wikidata typing",
221
+ knowledge_base,
222
+ )
223
+ mention = _proxy_range(
224
+ profile,
225
+ task.target,
226
+ profile.entity_mentions,
227
+ profile.estimated_entity_mentions,
228
+ "internal-link mention proxy before Wikidata typing",
229
+ knowledge_base,
230
+ )
231
+ variants = {"type1": context, "type2": context, "type3": mention}
232
+ elif name == "extractive_qa":
233
+ qa_service_names = ["knowledge_base"]
234
+ if task.options.get("generation_method", "template") == "hybrid":
235
+ qa_service_names.append("llm")
236
+ qa_services = _missing_services(services, tuple(qa_service_names))
237
+ variants = {
238
+ "type1": _proxy_range(
239
+ profile,
240
+ task.target,
241
+ profile.qa_capped_candidates,
242
+ profile.estimated_qa_candidates,
243
+ ("entity-bearing QA context proxy with per-label " "candidate cap"),
244
+ qa_services,
245
+ )
246
+ }
247
+ else:
248
+ value = CapacityRange(
249
+ requested=task.target,
250
+ observed_candidates=0,
251
+ lower_bound=0,
252
+ estimated=None,
253
+ upper_bound=None,
254
+ confidence="unsupported",
255
+ status="blocked",
256
+ basis="unsupported task",
257
+ )
258
+ variants = {"default": value}
259
+ warnings = (f"No capacity model is registered for task {name!r}.",)
260
+ report_warnings.extend(warnings)
261
+
262
+ results[name] = TaskCapacity(
263
+ name=name,
264
+ enabled=True,
265
+ requested=task.target,
266
+ status=_task_status(variants.values()),
267
+ variants=variants,
268
+ warnings=warnings,
269
+ )
270
+
271
+ constraints = []
272
+ for constraint_name, pair in (
273
+ (
274
+ "multiple_choice_disjoint_articles",
275
+ ("paragraph_coherence", "paragraph_ordering"),
276
+ ),
277
+ (
278
+ "other_disjoint_articles",
279
+ ("article_membership", "next_paragraph"),
280
+ ),
281
+ ):
282
+ if not all(name in tasks and tasks[name].enabled for name in pair):
283
+ continue
284
+ requested = sum(tasks[name].target for name in pair)
285
+ if profile.articles_four_plus >= requested:
286
+ status = "sufficient"
287
+ elif profile.complete:
288
+ status = "insufficient"
289
+ else:
290
+ status = "uncertain"
291
+ constraints.append(
292
+ PoolConstraint(
293
+ name=constraint_name,
294
+ tasks=pair,
295
+ requested=requested,
296
+ observed_available=profile.articles_four_plus,
297
+ estimated_available=profile.estimated_articles_four_plus,
298
+ status=status,
299
+ )
300
+ )
301
+
302
+ return CapacityReport(
303
+ schema_version=1,
304
+ run_status="complete",
305
+ profile=profile,
306
+ tasks=results,
307
+ constraints=tuple(constraints),
308
+ warnings=tuple(dict.fromkeys(report_warnings)),
309
+ )
@@ -0,0 +1,142 @@
1
+ """Public models for dump profiles and task-capacity reports."""
2
+
3
+ from dataclasses import asdict, dataclass
4
+ from typing import Any, Dict, Mapping, Optional, Tuple
5
+
6
+
7
+ @dataclass(frozen=True)
8
+ class CorpusProfile:
9
+ """Observed and projected properties of a configured corpus scan."""
10
+
11
+ mode: str
12
+ dump_path: str
13
+ complete: bool
14
+ completion_reason: str
15
+ pages_scanned: int
16
+ main_namespace_pages: int
17
+ redirects_skipped: int
18
+ empty_pages_skipped: int
19
+ title_suffixes_skipped: int
20
+ insufficient_paragraphs_skipped: int
21
+ eligible_articles: int
22
+ paragraphs_available: int
23
+ paragraphs_selected: int
24
+ articles_four_plus: int
25
+ category_articles: int
26
+ entity_contexts: int
27
+ entity_mentions: int
28
+ qa_candidate_contexts: int
29
+ qa_capped_candidates: int
30
+ reranking_articles: int
31
+ bytes_read: int
32
+ total_bytes: int
33
+ estimated_pages: Optional[int]
34
+ estimated_eligible_articles: Optional[int]
35
+ estimated_articles_four_plus: Optional[int]
36
+ estimated_category_articles: Optional[int]
37
+ estimated_entity_contexts: Optional[int]
38
+ estimated_entity_mentions: Optional[int]
39
+ estimated_qa_candidates: Optional[int]
40
+ estimated_reranking_articles: Optional[int]
41
+ article_limit: Optional[int]
42
+ max_pages: Optional[int]
43
+ warnings: Tuple[str, ...] = ()
44
+
45
+ @property
46
+ def fraction_read(self) -> Optional[float]:
47
+ if self.total_bytes <= 0:
48
+ return None
49
+ return min(1.0, self.bytes_read / self.total_bytes)
50
+
51
+ def to_dict(self) -> Dict[str, Any]:
52
+ value = asdict(self)
53
+ value["warnings"] = list(self.warnings)
54
+ value["fraction_read"] = self.fraction_read
55
+ return value
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class CapacityRange:
60
+ """Capacity information for one concrete output variant."""
61
+
62
+ requested: int
63
+ observed_candidates: int
64
+ lower_bound: int
65
+ estimated: Optional[int]
66
+ upper_bound: Optional[int]
67
+ confidence: str
68
+ status: str
69
+ basis: str
70
+ dependencies: Tuple[str, ...] = ()
71
+
72
+ def to_dict(self) -> Dict[str, Any]:
73
+ value = asdict(self)
74
+ value["dependencies"] = list(self.dependencies)
75
+ return value
76
+
77
+
78
+ @dataclass(frozen=True)
79
+ class TaskCapacity:
80
+ """Capacity result for one configured task and its variants."""
81
+
82
+ name: str
83
+ enabled: bool
84
+ requested: int
85
+ status: str
86
+ variants: Mapping[str, CapacityRange]
87
+ warnings: Tuple[str, ...] = ()
88
+
89
+ def to_dict(self) -> Dict[str, Any]:
90
+ return {
91
+ "name": self.name,
92
+ "enabled": self.enabled,
93
+ "requested": self.requested,
94
+ "status": self.status,
95
+ "variants": {
96
+ name: value.to_dict() for name, value in self.variants.items()
97
+ },
98
+ "warnings": list(self.warnings),
99
+ }
100
+
101
+
102
+ @dataclass(frozen=True)
103
+ class PoolConstraint:
104
+ """A legacy disjoint-selection constraint shared by multiple tasks."""
105
+
106
+ name: str
107
+ tasks: Tuple[str, ...]
108
+ requested: int
109
+ observed_available: int
110
+ estimated_available: Optional[int]
111
+ status: str
112
+
113
+ def to_dict(self) -> Dict[str, Any]:
114
+ value = asdict(self)
115
+ value["tasks"] = list(self.tasks)
116
+ return value
117
+
118
+
119
+ @dataclass(frozen=True)
120
+ class CapacityReport:
121
+ """Complete, serializable capacity analysis result."""
122
+
123
+ schema_version: int
124
+ run_status: str
125
+ profile: CorpusProfile
126
+ tasks: Mapping[str, TaskCapacity]
127
+ constraints: Tuple[PoolConstraint, ...] = ()
128
+ warnings: Tuple[str, ...] = ()
129
+ output_dir: Optional[str] = None
130
+
131
+ def to_dict(self) -> Dict[str, Any]:
132
+ return {
133
+ "schema_version": self.schema_version,
134
+ "run_status": self.run_status,
135
+ "profile": self.profile.to_dict(),
136
+ "tasks": {
137
+ name: value.to_dict() for name, value in self.tasks.items()
138
+ },
139
+ "constraints": [value.to_dict() for value in self.constraints],
140
+ "warnings": list(self.warnings),
141
+ "output_dir": self.output_dir,
142
+ }
@@ -0,0 +1,133 @@
1
+ """Safe, isolated pilot corpus construction."""
2
+
3
+ import json
4
+ import os
5
+ import shutil
6
+ import tempfile
7
+ import uuid
8
+ from dataclasses import replace
9
+ from pathlib import Path
10
+
11
+ from hermes_benchmark.config import GenerationConfig
12
+ from hermes_benchmark.progress import progress_bar
13
+ from hermes_benchmark.wikipedia import build_corpus
14
+
15
+ from .estimates import estimate_task_capacities
16
+ from .models import CapacityReport
17
+ from .profiler import profile_dump
18
+
19
+
20
+ def _bounded(requested: int, configured: int) -> int:
21
+ return min(requested, configured)
22
+
23
+
24
+ def run_pilot(
25
+ config: GenerationConfig,
26
+ *,
27
+ article_limit: int = 100,
28
+ max_pages: int = 10_000,
29
+ ) -> CapacityReport:
30
+ """Build and publish an isolated pilot corpus plus its capacity report."""
31
+
32
+ if article_limit <= 0:
33
+ raise ValueError("article_limit must be greater than zero")
34
+ if max_pages <= 0:
35
+ raise ValueError("max_pages must be greater than zero")
36
+
37
+ effective_articles = article_limit
38
+ if config.corpus.article_limit is not None:
39
+ effective_articles = _bounded(
40
+ effective_articles,
41
+ config.corpus.article_limit,
42
+ )
43
+ effective_pages = max_pages
44
+ if config.corpus.max_pages is not None:
45
+ effective_pages = _bounded(effective_pages, config.corpus.max_pages)
46
+
47
+ stage_progress = progress_bar(total=2, desc="Pilot", unit="stage")
48
+ try:
49
+ stage_progress.set_postfix_str("profiling", refresh=False)
50
+ profile = profile_dump(
51
+ config.dump.path,
52
+ config.corpus,
53
+ page_limit=effective_pages,
54
+ tasks=config.tasks,
55
+ mode="pilot",
56
+ )
57
+ base_report = estimate_task_capacities(
58
+ profile,
59
+ config.tasks,
60
+ config.services,
61
+ )
62
+ stage_progress.update(1)
63
+ stage_progress.set_postfix_str("building corpus", refresh=False)
64
+
65
+ output_root = Path(config.output_dir)
66
+ final_dir = output_root / "pilot"
67
+ output_root.mkdir(parents=True, exist_ok=True)
68
+ staging_dir = Path(
69
+ tempfile.mkdtemp(prefix=".pilot.", dir=str(output_root))
70
+ )
71
+ backup_dir = output_root / f".pilot.backup.{uuid.uuid4().hex}"
72
+ warnings = list(base_report.warnings)
73
+ pilot_corpus = config.corpus.model_copy(
74
+ update={
75
+ "article_limit": effective_articles,
76
+ "max_pages": effective_pages,
77
+ }
78
+ )
79
+ stats = build_corpus(
80
+ config.dump.path,
81
+ staging_dir,
82
+ pilot_corpus,
83
+ )
84
+ if stats.articles_written == 0:
85
+ run_status = "fail"
86
+ warnings.append("The pilot did not produce any eligible articles.")
87
+ elif stats.articles_written < effective_articles:
88
+ run_status = "pass_with_warnings"
89
+ warnings.append(
90
+ "The pilot reached the dump/page limit before the requested "
91
+ f"{effective_articles} articles; {stats.articles_written} were written."
92
+ )
93
+ else:
94
+ run_status = "pass"
95
+
96
+ report = replace(
97
+ base_report,
98
+ run_status=run_status,
99
+ warnings=tuple(dict.fromkeys(warnings)),
100
+ output_dir=str(final_dir),
101
+ )
102
+ report_path = staging_dir / "report.json"
103
+ with report_path.open("w", encoding="utf-8") as report_file:
104
+ json.dump(
105
+ report.to_dict(),
106
+ report_file,
107
+ ensure_ascii=False,
108
+ indent=2,
109
+ sort_keys=True,
110
+ )
111
+ report_file.write("\n")
112
+
113
+ moved_old = False
114
+ try:
115
+ if final_dir.exists():
116
+ os.replace(final_dir, backup_dir)
117
+ moved_old = True
118
+ os.replace(staging_dir, final_dir)
119
+ except BaseException:
120
+ if moved_old and backup_dir.exists() and not final_dir.exists():
121
+ os.replace(backup_dir, final_dir)
122
+ raise
123
+ if backup_dir.exists():
124
+ shutil.rmtree(backup_dir)
125
+ stage_progress.update(1)
126
+ stage_progress.set_postfix_str("complete", refresh=False)
127
+ return report
128
+ except BaseException:
129
+ if "staging_dir" in locals() and staging_dir.exists():
130
+ shutil.rmtree(staging_dir)
131
+ raise
132
+ finally:
133
+ stage_progress.close()