regulated-eval 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.
Files changed (45) hide show
  1. regulated_eval/__init__.py +95 -0
  2. regulated_eval/ab/__init__.py +268 -0
  3. regulated_eval/adapters/__init__.py +73 -0
  4. regulated_eval/adapters/base.py +126 -0
  5. regulated_eval/adapters/gigachat.py +226 -0
  6. regulated_eval/adapters/openai_compat.py +198 -0
  7. regulated_eval/adapters/retry.py +123 -0
  8. regulated_eval/adapters/yandexgpt.py +178 -0
  9. regulated_eval/cli.py +752 -0
  10. regulated_eval/config.py +112 -0
  11. regulated_eval/datasets/__init__.py +1 -0
  12. regulated_eval/datasets/loader.py +145 -0
  13. regulated_eval/datasets/manifest.py +164 -0
  14. regulated_eval/datasets/versioning.py +163 -0
  15. regulated_eval/exceptions.py +39 -0
  16. regulated_eval/human_loop/__init__.py +13 -0
  17. regulated_eval/human_loop/annotation_store.py +314 -0
  18. regulated_eval/judges/__init__.py +24 -0
  19. regulated_eval/judges/calibration.py +214 -0
  20. regulated_eval/judges/conflicts.py +200 -0
  21. regulated_eval/judges/judge.py +268 -0
  22. regulated_eval/metrics/__init__.py +1 -0
  23. regulated_eval/metrics/aggregator.py +145 -0
  24. regulated_eval/observability/__init__.py +66 -0
  25. regulated_eval/observability/logs.py +321 -0
  26. regulated_eval/observability/metrics.py +253 -0
  27. regulated_eval/observability/otel_setup.py +223 -0
  28. regulated_eval/observability/redaction.py +220 -0
  29. regulated_eval/pipeline.py +501 -0
  30. regulated_eval/py.typed +2 -0
  31. regulated_eval/reports/__init__.py +20 -0
  32. regulated_eval/reports/html.py +200 -0
  33. regulated_eval/reports/markdown.py +140 -0
  34. regulated_eval/schemas.py +263 -0
  35. regulated_eval/security/__init__.py +50 -0
  36. regulated_eval/security/audit.py +201 -0
  37. regulated_eval/security/injection_filter.py +186 -0
  38. regulated_eval/security/pii_redactor.py +212 -0
  39. regulated_eval/security/provider_allowlist.py +208 -0
  40. regulated_eval/security/secrets.py +110 -0
  41. regulated_eval-1.0.0.dist-info/METADATA +272 -0
  42. regulated_eval-1.0.0.dist-info/RECORD +45 -0
  43. regulated_eval-1.0.0.dist-info/WHEEL +4 -0
  44. regulated_eval-1.0.0.dist-info/entry_points.txt +2 -0
  45. regulated_eval-1.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,95 @@
1
+ """Regulated LLM Eval Framework — evaluation for LLMs in regulated domains.
2
+
3
+ Library API (FR-014):
4
+ - run_eval: Run full evaluation pipeline
5
+ - calibrate_judge: Calibrate judge against expert annotations
6
+ - generate_report: Generate Markdown/HTML report
7
+ - ab_test: Run A/B test
8
+ - resolve_conflicts: Resolve judge-human conflicts
9
+ - replay_run: Replay previous run
10
+ - load_dataset: Load and validate dataset
11
+ - verify_annotations: Verify annotation chain
12
+ """
13
+
14
+ __version__ = "1.0.0"
15
+
16
+ from regulated_eval.adapters import (
17
+ BaseAdapter,
18
+ GigaChatAdapter,
19
+ LLMRequest,
20
+ LLMResponse,
21
+ OpenAICompatibleAdapter,
22
+ YandexGPTAdapter,
23
+ create_adapter,
24
+ )
25
+ from regulated_eval.config import Config
26
+ from regulated_eval.datasets.loader import load_golden_set
27
+ from regulated_eval.datasets.manifest import create_manifest, load_manifest_file, save_manifest_file
28
+ from regulated_eval.human_loop.annotation_store import AnnotationStore
29
+ from regulated_eval.judges import CalibrationEngine, ConflictResolver
30
+ from regulated_eval.metrics.aggregator import MetricsAggregator
31
+ from regulated_eval.observability import (
32
+ init_otel,
33
+ record_eval_run_duration,
34
+ record_hallucination_rate,
35
+ record_llm_request_duration,
36
+ shutdown_otel,
37
+ )
38
+ from regulated_eval.pipeline import EvalOrchestrator, ReplayOrchestrator
39
+ from regulated_eval.reports import generate_html_report, generate_markdown_report
40
+ from regulated_eval.schemas import (
41
+ Annotation,
42
+ CalibrationResult,
43
+ GoldenRecord,
44
+ GoldenSetSummary,
45
+ JudgeResult,
46
+ MetricsBlock,
47
+ RunManifest,
48
+ )
49
+
50
+ __all__ = [
51
+ # Version
52
+ "__version__",
53
+ # Config
54
+ "Config",
55
+ # Adapters
56
+ "BaseAdapter",
57
+ "LLMRequest",
58
+ "LLMResponse",
59
+ "YandexGPTAdapter",
60
+ "GigaChatAdapter",
61
+ "OpenAICompatibleAdapter",
62
+ "create_adapter",
63
+ # Pipeline
64
+ "EvalOrchestrator",
65
+ "ReplayOrchestrator",
66
+ # Datasets
67
+ "load_golden_set",
68
+ "create_manifest",
69
+ "save_manifest_file",
70
+ "load_manifest_file",
71
+ # Judges
72
+ "CalibrationEngine",
73
+ "ConflictResolver",
74
+ # Metrics
75
+ "MetricsAggregator",
76
+ # Reports
77
+ "generate_markdown_report",
78
+ "generate_html_report",
79
+ # Security
80
+ "AnnotationStore",
81
+ # Observability
82
+ "init_otel",
83
+ "shutdown_otel",
84
+ "record_eval_run_duration",
85
+ "record_llm_request_duration",
86
+ "record_hallucination_rate",
87
+ # Schemas
88
+ "GoldenRecord",
89
+ "JudgeResult",
90
+ "RunManifest",
91
+ "Annotation",
92
+ "MetricsBlock",
93
+ "CalibrationResult",
94
+ "GoldenSetSummary",
95
+ ]
@@ -0,0 +1,268 @@
1
+ """A/B testing with statistical significance.
2
+
3
+ Covers FR-008, TM-04.
4
+
5
+ Provides:
6
+ - Bootstrap confidence intervals
7
+ - Wilcoxon signed-rank test
8
+ - Cohen's d effect size
9
+ - Result interpretation
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+ from dataclasses import dataclass
16
+
17
+ import numpy as np
18
+ from scipy import stats
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ @dataclass
24
+ class ABResult:
25
+ """Result of A/B test."""
26
+
27
+ prompt_a: str = ""
28
+ prompt_b: str = ""
29
+ model_a: str = ""
30
+ model_b: str = ""
31
+ metric_a: float = 0.0
32
+ metric_b: float = 0.0
33
+ delta: float = 0.0
34
+ p_value: float = 1.0
35
+ ci_low: float = 0.0
36
+ ci_high: float = 0.0
37
+ cohens_d: float = 0.0
38
+ n_a: int = 0
39
+ n_b: int = 0
40
+ significant: bool = False
41
+ winner: str = "" # "a", "b", or "none"
42
+ interpretation: str = ""
43
+
44
+
45
+ class ABTest:
46
+ """A/B test with statistical significance testing.
47
+
48
+ Uses:
49
+ - Bootstrap CI (1000 iterations) for metric differences
50
+ - Wilcoxon signed-rank test for p-value
51
+ - Cohen's d for effect size
52
+ """
53
+
54
+ def __init__(
55
+ self,
56
+ n_bootstrap: int = 1000,
57
+ alpha: float = 0.05,
58
+ random_seed: int = 42,
59
+ ) -> None:
60
+ """Initialize A/B test.
61
+
62
+ Args:
63
+ n_bootstrap: Number of bootstrap iterations.
64
+ alpha: Significance level (default 0.05).
65
+ random_seed: Random seed for reproducibility.
66
+ """
67
+ self._n_bootstrap = n_bootstrap
68
+ self._alpha = alpha
69
+ self._rng = np.random.RandomState(random_seed)
70
+
71
+ def run(
72
+ self,
73
+ scores_a: list[float],
74
+ scores_b: list[float],
75
+ prompt_a: str = "",
76
+ prompt_b: str = "",
77
+ model_a: str = "",
78
+ model_b: str = "",
79
+ higher_is_better: bool = True,
80
+ ) -> ABResult:
81
+ """Run A/B test between two sets of scores.
82
+
83
+ Args:
84
+ scores_a: Scores for variant A.
85
+ scores_b: Scores for variant B.
86
+ prompt_a: Prompt A identifier.
87
+ prompt_b: Prompt B identifier.
88
+ model_a: Model A name.
89
+ model_b: Model B name.
90
+ higher_is_better: Whether higher scores are better.
91
+
92
+ Returns:
93
+ ABResult with statistical analysis.
94
+ """
95
+ result = ABResult(
96
+ prompt_a=prompt_a,
97
+ prompt_b=prompt_b,
98
+ model_a=model_a,
99
+ model_b=model_b,
100
+ n_a=len(scores_a),
101
+ n_b=len(scores_b),
102
+ )
103
+
104
+ if not scores_a or not scores_b:
105
+ result.interpretation = "Insufficient data for A/B test"
106
+ return result
107
+
108
+ # Compute mean metrics
109
+ result.metric_a = float(np.mean(scores_a))
110
+ result.metric_b = float(np.mean(scores_b))
111
+ result.delta = result.metric_b - result.metric_a
112
+
113
+ # Bootstrap CI for delta
114
+ ci_low, ci_high = self._bootstrap_ci(scores_a, scores_b, higher_is_better)
115
+ result.ci_low = ci_low
116
+ result.ci_high = ci_high
117
+
118
+ # Wilcoxon signed-rank test
119
+ # Pad shorter list with mean values for paired test
120
+ min_len = min(len(scores_a), len(scores_b))
121
+ if min_len >= 2:
122
+ a_sample = scores_a[:min_len]
123
+ b_sample = scores_b[:min_len]
124
+ _, result.p_value = stats.wilcoxon(a_sample, b_sample)
125
+ else:
126
+ result.p_value = 1.0
127
+
128
+ # Cohen's d effect size
129
+ result.cohens_d = self._cohens_d(scores_a, scores_b)
130
+
131
+ # Determine significance
132
+ result.significant = result.p_value < self._alpha
133
+
134
+ # Determine winner
135
+ if result.significant:
136
+ if higher_is_better:
137
+ result.winner = "b" if result.delta > 0 else "a"
138
+ else:
139
+ result.winner = "b" if result.delta < 0 else "a"
140
+ else:
141
+ result.winner = "none"
142
+
143
+ # Generate interpretation
144
+ result.interpretation = self._interpret_result(result, higher_is_better)
145
+
146
+ logger.info(
147
+ "A/B test: delta=%.4f, p=%.4f, d=%.4f, significant=%s, winner=%s",
148
+ result.delta,
149
+ result.p_value,
150
+ result.cohens_d,
151
+ result.significant,
152
+ result.winner,
153
+ )
154
+
155
+ return result
156
+
157
+ def _bootstrap_ci(
158
+ self,
159
+ scores_a: list[float],
160
+ scores_b: list[float],
161
+ higher_is_better: bool,
162
+ ) -> tuple[float, float]:
163
+ """Compute bootstrap confidence interval for delta.
164
+
165
+ Args:
166
+ scores_a: Scores for variant A.
167
+ scores_b: Scores for variant B.
168
+ higher_is_better: Whether higher scores are better.
169
+
170
+ Returns:
171
+ Tuple of (ci_low, ci_high).
172
+ """
173
+ deltas = []
174
+ min_len = min(len(scores_a), len(scores_b))
175
+
176
+ for _ in range(self._n_bootstrap):
177
+ indices = self._rng.randint(0, min_len, size=min_len)
178
+ a_sample = [scores_a[i] for i in indices]
179
+ b_sample = [scores_b[i] for i in indices]
180
+
181
+ delta_a = np.mean(a_sample)
182
+ delta_b = np.mean(b_sample)
183
+ deltas.append(delta_b - delta_a)
184
+
185
+ alpha = self._alpha
186
+ ci_low = np.percentile(deltas, 100 * alpha / 2)
187
+ ci_high = np.percentile(deltas, 100 * (1 - alpha / 2))
188
+
189
+ return float(ci_low), float(ci_high)
190
+
191
+ @staticmethod
192
+ def _cohens_d(scores_a: list[float], scores_b: list[float]) -> float:
193
+ """Compute Cohen's d effect size.
194
+
195
+ Args:
196
+ scores_a: Scores for variant A.
197
+ scores_b: Scores for variant B.
198
+
199
+ Returns:
200
+ Cohen's d value.
201
+ """
202
+ n_a = len(scores_a)
203
+ n_b = len(scores_b)
204
+
205
+ if n_a < 2 or n_b < 2:
206
+ return 0.0
207
+
208
+ mean_a = np.mean(scores_a)
209
+ mean_b = np.mean(scores_b)
210
+ std_a = np.std(scores_a, ddof=1)
211
+ std_b = np.std(scores_b, ddof=1)
212
+
213
+ # Pooled standard deviation
214
+ pooled_std = np.sqrt(((n_a - 1) * std_a**2 + (n_b - 1) * std_b**2) / (n_a + n_b - 2))
215
+
216
+ if pooled_std == 0:
217
+ return 0.0
218
+
219
+ return float((mean_b - mean_a) / pooled_std)
220
+
221
+ def _interpret_result(
222
+ self,
223
+ result: ABResult,
224
+ higher_is_better: bool,
225
+ ) -> str:
226
+ """Interpret A/B test results.
227
+
228
+ Args:
229
+ result: ABResult to interpret.
230
+ higher_is_better: Whether higher scores are better.
231
+
232
+ Returns:
233
+ Interpretation string.
234
+ """
235
+ if not result.significant:
236
+ return (
237
+ f"No statistically significant difference (p={result.p_value:.4f} >= 0.05). "
238
+ f"Delta: {result.delta:+.4f} [{result.ci_low:.4f}, {result.ci_high:.4f}]. "
239
+ f"Consider increasing dataset size."
240
+ )
241
+
242
+ winner_name = "A" if result.winner == "a" else "B" if result.winner == "b" else "none"
243
+
244
+ effect_size = abs(result.cohens_d)
245
+ if effect_size < 0.2:
246
+ effect_label = "negligible"
247
+ elif effect_size < 0.5:
248
+ effect_label = "small"
249
+ elif effect_size < 0.8:
250
+ effect_label = "medium"
251
+ else:
252
+ effect_label = "large"
253
+
254
+ return (
255
+ f"Statistically significant difference found (p={result.p_value:.4f} < 0.05). "
256
+ f"Winner: {winner_name}. Delta: {result.delta:+.4f}. "
257
+ f"Effect size: {effect_label} (d={result.cohens_d:+.4f})."
258
+ )
259
+
260
+ @property
261
+ def n_bootstrap(self) -> int:
262
+ """Number of bootstrap iterations."""
263
+ return self._n_bootstrap
264
+
265
+ @n_bootstrap.setter
266
+ def n_bootstrap(self, value: int) -> None:
267
+ """Set number of bootstrap iterations."""
268
+ self._n_bootstrap = value
@@ -0,0 +1,73 @@
1
+ # Adapters package
2
+
3
+ """LLM provider adapters for Regulated LLM Eval Framework.
4
+
5
+ Covers FR-003, NFR-008.
6
+
7
+ Adapters:
8
+ - YandexGPT: Russian LLM (not foreign)
9
+ - GigaChat: Russian LLM (not foreign)
10
+ - OpenAICompatible: OpenAI-compatible API (foreign, requires opt-in)
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from regulated_eval.adapters.base import BaseAdapter, LLMRequest, LLMResponse
16
+ from regulated_eval.adapters.gigachat import GigaChatAdapter
17
+ from regulated_eval.adapters.openai_compat import OpenAICompatibleAdapter
18
+ from regulated_eval.adapters.retry import RateLimitError, RateLimitExhausted, check_rate_limit
19
+ from regulated_eval.adapters.yandexgpt import YandexGPTAdapter
20
+
21
+ __all__ = [
22
+ "BaseAdapter",
23
+ "LLMRequest",
24
+ "LLMResponse",
25
+ "YandexGPTAdapter",
26
+ "GigaChatAdapter",
27
+ "OpenAICompatibleAdapter",
28
+ "RateLimitError",
29
+ "RateLimitExhausted",
30
+ "check_rate_limit",
31
+ ]
32
+
33
+
34
+ def create_adapter(
35
+ provider: str,
36
+ model: str = "",
37
+ config=None,
38
+ base_url: str | None = None,
39
+ ) -> BaseAdapter:
40
+ """Create an adapter by provider name.
41
+
42
+ Args:
43
+ provider: Provider name (yandexgpt, gigachat, openai_compat).
44
+ model: Model name (uses default if empty).
45
+ config: Optional Config object.
46
+ base_url: Optional base URL for openai_compat.
47
+
48
+ Returns:
49
+ Configured BaseAdapter instance.
50
+
51
+ Raises:
52
+ ValueError: If provider is not supported.
53
+ """
54
+ from regulated_eval.config import Config # noqa: PLC0414
55
+
56
+ cfg = config or Config()
57
+
58
+ if provider == "yandexgpt":
59
+ model_name = model or "yandexgpt-lite"
60
+ return YandexGPTAdapter(model=model_name, config=cfg)
61
+
62
+ elif provider == "gigachat":
63
+ model_name = model or "gigachat"
64
+ return GigaChatAdapter(model=model_name, config=cfg)
65
+
66
+ elif provider == "openai_compat":
67
+ model_name = model or "gpt-4o-mini"
68
+ url = base_url or cfg.openai_base_url
69
+ return OpenAICompatibleAdapter(model=model_name, base_url=url, config=cfg)
70
+
71
+ else:
72
+ supported = ", ".join(["yandexgpt", "gigachat", "openai_compat"])
73
+ raise ValueError(f"Unsupported provider: {provider}. Supported: {supported}")
@@ -0,0 +1,126 @@
1
+ """Base adapter protocol for LLM providers.
2
+
3
+ Covers FR-003, NFR-008.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+ import time
10
+ from abc import ABC, abstractmethod
11
+ from dataclasses import dataclass, field
12
+
13
+ from regulated_eval.observability import get_tracer, record_llm_request_duration
14
+
15
+
16
+ @dataclass
17
+ class LLMRequest:
18
+ """Request to send to an LLM provider."""
19
+
20
+ prompt: str
21
+ model: str = ""
22
+ temperature: float = 0.0
23
+ max_tokens: int = 1024
24
+ stop_sequences: list[str] = field(default_factory=list)
25
+ metadata: dict = field(default_factory=dict)
26
+
27
+
28
+ @dataclass
29
+ class LLMResponse:
30
+ """Response from an LLM provider."""
31
+
32
+ text: str
33
+ model: str = ""
34
+ tokens_input: int = 0
35
+ tokens_output: int = 0
36
+ finish_reason: str = ""
37
+ latency_ms: float = 0.0
38
+ error: str | None = None
39
+
40
+
41
+ class BaseAdapter(ABC):
42
+ """Abstract base class for LLM provider adapters.
43
+
44
+ All adapters must implement:
45
+ - generate(): Send a prompt and return a response
46
+ - cost(): Estimate cost for a request
47
+ - provider(): Return the provider name
48
+ """
49
+
50
+ @abstractmethod
51
+ async def generate(self, request: LLMRequest) -> LLMResponse:
52
+ """Send a prompt to the LLM and return the response.
53
+
54
+ Args:
55
+ request: LLM request with prompt, model, etc.
56
+
57
+ Returns:
58
+ LLMResponse with text, tokens, latency, etc.
59
+
60
+ Raises:
61
+ RateLimitExhausted: If all retry attempts are exhausted.
62
+ MissingCredentials: If API key is not set.
63
+ """
64
+
65
+ def generate_sync(self, request: LLMRequest) -> LLMResponse:
66
+ """Synchronous wrapper for generate().
67
+
68
+ Args:
69
+ request: LLM request with prompt, model, etc.
70
+
71
+ Returns:
72
+ LLMResponse with text, tokens, latency, etc.
73
+ """
74
+ start_time = time.monotonic()
75
+ tracer = get_tracer("regulated-eval")
76
+
77
+ with tracer.start_as_current_span("adapter.call") as span:
78
+ span.set_attribute("llm.provider", self.provider())
79
+ span.set_attribute("llm.model", self.model_name())
80
+ span.set_attribute("llm.role", request.metadata.get("role", "generator"))
81
+
82
+ response = asyncio.run(self.generate(request))
83
+
84
+ # Record span attributes
85
+ span.set_attribute("llm.tokens_input", response.tokens_input)
86
+ span.set_attribute("llm.tokens_output", response.tokens_output)
87
+ span.set_attribute("llm.has_error", response.error is not None)
88
+
89
+ duration = time.monotonic() - start_time
90
+ response.latency_ms = duration * 1000
91
+ span.set_attribute("llm.duration_ms", response.latency_ms)
92
+
93
+ # Record metric
94
+ record_llm_request_duration(
95
+ {},
96
+ duration,
97
+ provider=self.provider(),
98
+ model=self.model_name(),
99
+ role=request.metadata.get("role", "generator"),
100
+ )
101
+
102
+ return response
103
+
104
+ @abstractmethod
105
+ def provider(self) -> str:
106
+ """Return the provider name (e.g. 'yandexgpt', 'gigachat')."""
107
+
108
+ @abstractmethod
109
+ def model_name(self) -> str:
110
+ """Return the model name being used."""
111
+
112
+ def estimate_cost(self, tokens_input: int, tokens_output: int) -> float | None:
113
+ """Estimate cost in RUB for the given token counts.
114
+
115
+ Override in subclasses with provider-specific pricing.
116
+ Returns None if pricing is not configured.
117
+ """
118
+ return None
119
+
120
+ @property
121
+ def is_foreign(self) -> bool:
122
+ """Whether this adapter connects to a foreign (non-Russian) provider.
123
+
124
+ Used for 152-FZ compliance (NFR-SEC-001).
125
+ """
126
+ return False