biblicus 0.1.1__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.
biblicus/evaluation.py ADDED
@@ -0,0 +1,261 @@
1
+ """
2
+ Evaluation utilities for Biblicus retrieval runs.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ import time
9
+ from pathlib import Path
10
+ from typing import Dict, List, Optional
11
+
12
+ from pydantic import BaseModel, ConfigDict, Field, model_validator
13
+
14
+ from .constants import DATASET_SCHEMA_VERSION
15
+ from .backends import get_backend
16
+ from .corpus import Corpus
17
+ from .models import QueryBudget, RetrievalResult, RetrievalRun
18
+ from .time import utc_now_iso
19
+
20
+
21
+ class EvaluationQuery(BaseModel):
22
+ """
23
+ Query record for retrieval evaluation.
24
+
25
+ :ivar query_id: Unique identifier for the query.
26
+ :vartype query_id: str
27
+ :ivar query_text: Natural language query to execute.
28
+ :vartype query_text: str
29
+ :ivar expected_item_id: Optional expected item identifier.
30
+ :vartype expected_item_id: str or None
31
+ :ivar expected_source_uri: Optional expected source uniform resource identifier.
32
+ :vartype expected_source_uri: str or None
33
+ :ivar kind: Query kind (gold or synthetic).
34
+ :vartype kind: str
35
+ """
36
+
37
+ model_config = ConfigDict(extra="forbid")
38
+
39
+ query_id: str
40
+ query_text: str
41
+ expected_item_id: Optional[str] = None
42
+ expected_source_uri: Optional[str] = None
43
+ kind: str = Field(default="gold")
44
+
45
+ @model_validator(mode="after")
46
+ def _require_expectation(self) -> "EvaluationQuery":
47
+ if not self.expected_item_id and not self.expected_source_uri:
48
+ raise ValueError("Evaluation queries must include expected_item_id or expected_source_uri")
49
+ return self
50
+
51
+
52
+ class EvaluationDataset(BaseModel):
53
+ """
54
+ Dataset for retrieval evaluation.
55
+
56
+ :ivar schema_version: Dataset schema version.
57
+ :vartype schema_version: int
58
+ :ivar name: Dataset name.
59
+ :vartype name: str
60
+ :ivar description: Optional description.
61
+ :vartype description: str or None
62
+ :ivar queries: List of evaluation queries.
63
+ :vartype queries: list[EvaluationQuery]
64
+ """
65
+
66
+ model_config = ConfigDict(extra="forbid")
67
+
68
+ schema_version: int = Field(ge=1)
69
+ name: str
70
+ description: Optional[str] = None
71
+ queries: List[EvaluationQuery] = Field(default_factory=list)
72
+
73
+ @model_validator(mode="after")
74
+ def _enforce_schema_version(self) -> "EvaluationDataset":
75
+ if self.schema_version != DATASET_SCHEMA_VERSION:
76
+ raise ValueError(f"Unsupported dataset schema version: {self.schema_version}")
77
+ return self
78
+
79
+
80
+ class EvaluationResult(BaseModel):
81
+ """
82
+ Result bundle for a retrieval evaluation.
83
+
84
+ :ivar dataset: Dataset metadata.
85
+ :vartype dataset: dict[str, object]
86
+ :ivar backend_id: Backend identifier.
87
+ :vartype backend_id: str
88
+ :ivar run_id: Retrieval run identifier.
89
+ :vartype run_id: str
90
+ :ivar evaluated_at: International Organization for Standardization 8601 evaluation timestamp.
91
+ :vartype evaluated_at: str
92
+ :ivar metrics: Quality metrics for retrieval.
93
+ :vartype metrics: dict[str, float]
94
+ :ivar system: System metrics for retrieval.
95
+ :vartype system: dict[str, float]
96
+ """
97
+
98
+ model_config = ConfigDict(extra="forbid")
99
+
100
+ dataset: Dict[str, object]
101
+ backend_id: str
102
+ run_id: str
103
+ evaluated_at: str
104
+ metrics: Dict[str, float]
105
+ system: Dict[str, float]
106
+
107
+
108
+ def load_dataset(path: Path) -> EvaluationDataset:
109
+ """
110
+ Load an evaluation dataset from JavaScript Object Notation.
111
+
112
+ :param path: Path to the dataset JavaScript Object Notation file.
113
+ :type path: Path
114
+ :return: Parsed evaluation dataset.
115
+ :rtype: EvaluationDataset
116
+ """
117
+
118
+ data = json.loads(path.read_text(encoding="utf-8"))
119
+ return EvaluationDataset.model_validate(data)
120
+
121
+
122
+ def evaluate_run(
123
+ *,
124
+ corpus: Corpus,
125
+ run: RetrievalRun,
126
+ dataset: EvaluationDataset,
127
+ budget: QueryBudget,
128
+ ) -> EvaluationResult:
129
+ """
130
+ Evaluate a retrieval run against a dataset.
131
+
132
+ :param corpus: Corpus associated with the run.
133
+ :type corpus: Corpus
134
+ :param run: Retrieval run manifest.
135
+ :type run: RetrievalRun
136
+ :param dataset: Evaluation dataset.
137
+ :type dataset: EvaluationDataset
138
+ :param budget: Evidence selection budget.
139
+ :type budget: QueryBudget
140
+ :return: Evaluation result bundle.
141
+ :rtype: EvaluationResult
142
+ """
143
+
144
+ backend = get_backend(run.recipe.backend_id)
145
+ latency_seconds: List[float] = []
146
+ hit_count = 0
147
+ reciprocal_ranks: List[float] = []
148
+
149
+ for query in dataset.queries:
150
+ timer_start = time.perf_counter()
151
+ result = backend.query(corpus, run=run, query_text=query.query_text, budget=budget)
152
+ elapsed_seconds = time.perf_counter() - timer_start
153
+ latency_seconds.append(elapsed_seconds)
154
+ expected_rank = _expected_rank(result, query)
155
+ if expected_rank is not None:
156
+ hit_count += 1
157
+ reciprocal_ranks.append(1.0 / expected_rank)
158
+ else:
159
+ reciprocal_ranks.append(0.0)
160
+
161
+ total_queries = max(len(dataset.queries), 1)
162
+ max_total_items = float(budget.max_total_items)
163
+ hit_rate = hit_count / total_queries
164
+ precision_at_max_total_items = hit_count / (total_queries * max_total_items)
165
+ mean_reciprocal_rank = sum(reciprocal_ranks) / total_queries
166
+
167
+ metrics = {
168
+ "hit_rate": hit_rate,
169
+ "precision_at_max_total_items": precision_at_max_total_items,
170
+ "mean_reciprocal_rank": mean_reciprocal_rank,
171
+ }
172
+ system = {
173
+ "average_latency_milliseconds": _average_latency_milliseconds(latency_seconds),
174
+ "percentile_95_latency_milliseconds": _percentile_95_latency_milliseconds(latency_seconds),
175
+ "index_bytes": float(_run_artifact_bytes(corpus, run)),
176
+ }
177
+ dataset_meta = {
178
+ "name": dataset.name,
179
+ "description": dataset.description,
180
+ "queries": len(dataset.queries),
181
+ }
182
+ return EvaluationResult(
183
+ dataset=dataset_meta,
184
+ backend_id=run.recipe.backend_id,
185
+ run_id=run.run_id,
186
+ evaluated_at=utc_now_iso(),
187
+ metrics=metrics,
188
+ system=system,
189
+ )
190
+
191
+
192
+ def _expected_rank(result: RetrievalResult, query: EvaluationQuery) -> Optional[int]:
193
+ """
194
+ Locate the first evidence rank that matches the expected item or source.
195
+
196
+ :param result: Retrieval result for a query.
197
+ :type result: RetrievalResult
198
+ :param query: Evaluation query definition.
199
+ :type query: EvaluationQuery
200
+ :return: Rank of the first matching evidence item, or None.
201
+ :rtype: int or None
202
+ """
203
+
204
+ for evidence in result.evidence:
205
+ if query.expected_item_id and evidence.item_id == query.expected_item_id:
206
+ return evidence.rank
207
+ if query.expected_source_uri and evidence.source_uri == query.expected_source_uri:
208
+ return evidence.rank
209
+ return None
210
+
211
+
212
+ def _average_latency_milliseconds(latencies: List[float]) -> float:
213
+ """
214
+ Compute average latency in milliseconds.
215
+
216
+ :param latencies: Latency samples in seconds.
217
+ :type latencies: list[float]
218
+ :return: Average latency in milliseconds.
219
+ :rtype: float
220
+ """
221
+
222
+ if not latencies:
223
+ return 0.0
224
+ return sum(latencies) / len(latencies) * 1000.0
225
+
226
+
227
+ def _percentile_95_latency_milliseconds(latencies: List[float]) -> float:
228
+ """
229
+ Compute the percentile 95 latency in milliseconds.
230
+
231
+ :param latencies: Latency samples in seconds.
232
+ :type latencies: list[float]
233
+ :return: Percentile 95 latency in milliseconds.
234
+ :rtype: float
235
+ """
236
+
237
+ if not latencies:
238
+ return 0.0
239
+ sorted_latencies = sorted(latencies)
240
+ percentile_index = int(round(0.95 * (len(sorted_latencies) - 1)))
241
+ return sorted_latencies[percentile_index] * 1000.0
242
+
243
+
244
+ def _run_artifact_bytes(corpus: Corpus, run: RetrievalRun) -> int:
245
+ """
246
+ Sum artifact sizes for a retrieval run.
247
+
248
+ :param corpus: Corpus that owns the artifacts.
249
+ :type corpus: Corpus
250
+ :param run: Retrieval run manifest.
251
+ :type run: RetrievalRun
252
+ :return: Total artifact bytes.
253
+ :rtype: int
254
+ """
255
+
256
+ total_bytes = 0
257
+ for artifact_relpath in run.artifact_paths:
258
+ artifact_path = corpus.root / artifact_relpath
259
+ if artifact_path.exists():
260
+ total_bytes += artifact_path.stat().st_size
261
+ return total_bytes
@@ -0,0 +1,92 @@
1
+ """
2
+ Markdown front matter helpers.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from dataclasses import dataclass
8
+ from typing import Any, Dict, Tuple
9
+
10
+ import yaml
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class FrontMatterDocument:
15
+ """
16
+ Parsed front matter and markdown body.
17
+
18
+ :ivar metadata: Front matter metadata mapping.
19
+ :vartype metadata: dict[str, Any]
20
+ :ivar body: Markdown body text.
21
+ :vartype body: str
22
+ """
23
+
24
+ metadata: Dict[str, Any]
25
+ body: str
26
+
27
+
28
+ def parse_front_matter(text: str) -> FrontMatterDocument:
29
+ """
30
+ Parse Yet Another Markup Language front matter from a Markdown document.
31
+
32
+ :param text: Markdown content with optional front matter.
33
+ :type text: str
34
+ :return: Parsed front matter and body.
35
+ :rtype: FrontMatterDocument
36
+ :raises ValueError: If front matter is present but not a mapping.
37
+ """
38
+
39
+ if not text.startswith("---\n"):
40
+ return FrontMatterDocument(metadata={}, body=text)
41
+
42
+ front_matter_end = text.find("\n---\n", 4)
43
+ if front_matter_end == -1:
44
+ return FrontMatterDocument(metadata={}, body=text)
45
+
46
+ raw_yaml = text[4:front_matter_end]
47
+ body = text[front_matter_end + len("\n---\n") :]
48
+
49
+ metadata = yaml.safe_load(raw_yaml) or {}
50
+ if not isinstance(metadata, dict):
51
+ raise ValueError("Yet Another Markup Language front matter must be a mapping object")
52
+
53
+ return FrontMatterDocument(metadata=dict(metadata), body=body)
54
+
55
+
56
+ def render_front_matter(metadata: Dict[str, Any], body: str) -> str:
57
+ """
58
+ Render Yet Another Markup Language front matter with a Markdown body.
59
+
60
+ :param metadata: Front matter metadata mapping.
61
+ :type metadata: dict[str, Any]
62
+ :param body: Markdown body text.
63
+ :type body: str
64
+ :return: Markdown with Yet Another Markup Language front matter.
65
+ :rtype: str
66
+ """
67
+
68
+ if not metadata:
69
+ return body
70
+
71
+ yaml_text = yaml.safe_dump(
72
+ metadata,
73
+ sort_keys=False,
74
+ allow_unicode=True,
75
+ default_flow_style=False,
76
+ ).strip()
77
+
78
+ return f"---\n{yaml_text}\n---\n{body}"
79
+
80
+
81
+ def split_markdown_front_matter(path_text: str) -> Tuple[Dict[str, Any], str]:
82
+ """
83
+ Split Markdown into front matter metadata and body.
84
+
85
+ :param path_text: Markdown content.
86
+ :type path_text: str
87
+ :return: Metadata mapping and body text.
88
+ :rtype: tuple[dict[str, Any], str]
89
+ """
90
+
91
+ parsed_document = parse_front_matter(path_text)
92
+ return parsed_document.metadata, parsed_document.body
biblicus/models.py ADDED
@@ -0,0 +1,307 @@
1
+ """
2
+ Pydantic models for Biblicus domain concepts.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from typing import Any, Dict, List, Optional
8
+
9
+ from pydantic import BaseModel, ConfigDict, Field, model_validator
10
+
11
+ from .constants import SCHEMA_VERSION
12
+
13
+
14
+ class CorpusConfig(BaseModel):
15
+ """
16
+ Canonical on-disk config for a local Biblicus corpus.
17
+
18
+ :ivar schema_version: Version of the corpus config schema.
19
+ :vartype schema_version: int
20
+ :ivar created_at: International Organization for Standardization 8601 timestamp for corpus creation.
21
+ :vartype created_at: str
22
+ :ivar corpus_uri: Canonical uniform resource identifier for the corpus root.
23
+ :vartype corpus_uri: str
24
+ :ivar raw_dir: Relative path to the raw items folder.
25
+ :vartype raw_dir: str
26
+ :ivar notes: Optional free-form notes for operators.
27
+ :vartype notes: dict[str, Any] or None
28
+ """
29
+
30
+ model_config = ConfigDict(extra="forbid")
31
+
32
+ schema_version: int = Field(ge=1)
33
+ created_at: str
34
+ corpus_uri: str
35
+ raw_dir: str = "raw"
36
+ notes: Optional[Dict[str, Any]] = None
37
+
38
+ @model_validator(mode="after")
39
+ def _enforce_schema_version(self) -> "CorpusConfig":
40
+ if self.schema_version != SCHEMA_VERSION:
41
+ raise ValueError(f"Unsupported corpus config schema version: {self.schema_version}")
42
+ return self
43
+
44
+
45
+ class IngestResult(BaseModel):
46
+ """
47
+ Minimal summary for an ingestion event.
48
+
49
+ :ivar item_id: Universally unique identifier assigned to the ingested item.
50
+ :vartype item_id: str
51
+ :ivar relpath: Relative path to the raw item file.
52
+ :vartype relpath: str
53
+ :ivar sha256: Secure Hash Algorithm 256 digest of the stored bytes.
54
+ :vartype sha256: str
55
+ """
56
+
57
+ model_config = ConfigDict(extra="forbid")
58
+
59
+ item_id: str
60
+ relpath: str
61
+ sha256: str
62
+
63
+
64
+ class CatalogItem(BaseModel):
65
+ """
66
+ Catalog entry derived from a raw corpus item.
67
+
68
+ :ivar id: Universally unique identifier of the item.
69
+ :vartype id: str
70
+ :ivar relpath: Relative path to the raw item file.
71
+ :vartype relpath: str
72
+ :ivar sha256: Secure Hash Algorithm 256 digest of the stored bytes.
73
+ :vartype sha256: str
74
+ :ivar bytes: Size of the raw item in bytes.
75
+ :vartype bytes: int
76
+ :ivar media_type: Internet Assigned Numbers Authority media type for the item.
77
+ :vartype media_type: str
78
+ :ivar title: Optional human title extracted from metadata.
79
+ :vartype title: str or None
80
+ :ivar tags: Tags extracted or supplied for the item.
81
+ :vartype tags: list[str]
82
+ :ivar metadata: Merged front matter or sidecar metadata.
83
+ :vartype metadata: dict[str, Any]
84
+ :ivar created_at: International Organization for Standardization 8601 timestamp when the item was first indexed.
85
+ :vartype created_at: str
86
+ :ivar source_uri: Optional source uniform resource identifier used at ingestion time.
87
+ :vartype source_uri: str or None
88
+ """
89
+
90
+ model_config = ConfigDict(extra="forbid")
91
+
92
+ id: str
93
+ relpath: str
94
+ sha256: str
95
+ bytes: int = Field(ge=0)
96
+ media_type: str
97
+ title: Optional[str] = None
98
+ tags: List[str] = Field(default_factory=list)
99
+ metadata: Dict[str, Any] = Field(default_factory=dict)
100
+ created_at: str
101
+ source_uri: Optional[str] = None
102
+
103
+
104
+ class CorpusCatalog(BaseModel):
105
+ """
106
+ Snapshot of the derived corpus catalog.
107
+
108
+ :ivar schema_version: Version of the catalog schema.
109
+ :vartype schema_version: int
110
+ :ivar generated_at: International Organization for Standardization 8601 timestamp of catalog generation.
111
+ :vartype generated_at: str
112
+ :ivar corpus_uri: Canonical uniform resource identifier for the corpus root.
113
+ :vartype corpus_uri: str
114
+ :ivar raw_dir: Relative path to the raw items folder.
115
+ :vartype raw_dir: str
116
+ :ivar latest_run_id: Latest retrieval run identifier, if any.
117
+ :vartype latest_run_id: str or None
118
+ :ivar items: Mapping of item IDs to catalog entries.
119
+ :vartype items: dict[str, CatalogItem]
120
+ :ivar order: Display order of item IDs (most recent first).
121
+ :vartype order: list[str]
122
+ """
123
+
124
+ model_config = ConfigDict(extra="forbid")
125
+
126
+ schema_version: int = Field(ge=1)
127
+ generated_at: str
128
+ corpus_uri: str
129
+ raw_dir: str = "raw"
130
+ latest_run_id: Optional[str] = None
131
+ items: Dict[str, CatalogItem] = Field(default_factory=dict)
132
+ order: List[str] = Field(default_factory=list)
133
+
134
+ @model_validator(mode="after")
135
+ def _enforce_schema_version(self) -> "CorpusCatalog":
136
+ if self.schema_version != SCHEMA_VERSION:
137
+ raise ValueError(f"Unsupported catalog schema version: {self.schema_version}")
138
+ return self
139
+
140
+
141
+ class QueryBudget(BaseModel):
142
+ """
143
+ Evidence selection budget for retrieval.
144
+
145
+ :ivar max_total_items: Maximum number of evidence items to return.
146
+ :vartype max_total_items: int
147
+ :ivar max_total_characters: Optional maximum total characters across evidence text.
148
+ :vartype max_total_characters: int or None
149
+ :ivar max_items_per_source: Optional cap per source uniform resource identifier.
150
+ :vartype max_items_per_source: int or None
151
+ """
152
+
153
+ model_config = ConfigDict(extra="forbid")
154
+
155
+ max_total_items: int = Field(ge=1)
156
+ max_total_characters: Optional[int] = Field(default=None, ge=1)
157
+ max_items_per_source: Optional[int] = Field(default=None, ge=1)
158
+
159
+
160
+ class Evidence(BaseModel):
161
+ """
162
+ Structured retrieval evidence returned from a backend.
163
+
164
+ :ivar item_id: Item identifier that produced the evidence.
165
+ :vartype item_id: str
166
+ :ivar source_uri: Source uniform resource identifier from ingestion metadata.
167
+ :vartype source_uri: str or None
168
+ :ivar media_type: Media type for the evidence item.
169
+ :vartype media_type: str
170
+ :ivar score: Retrieval score (higher is better).
171
+ :vartype score: float
172
+ :ivar rank: Rank within the final evidence list (1-based).
173
+ :vartype rank: int
174
+ :ivar text: Optional text payload for the evidence.
175
+ :vartype text: str or None
176
+ :ivar content_ref: Optional reference for non-text content.
177
+ :vartype content_ref: str or None
178
+ :ivar span_start: Optional start offset in the source text.
179
+ :vartype span_start: int or None
180
+ :ivar span_end: Optional end offset in the source text.
181
+ :vartype span_end: int or None
182
+ :ivar stage: Retrieval stage label (for example, scan, full-text search, rerank).
183
+ :vartype stage: str
184
+ :ivar recipe_id: Recipe identifier used to create the run.
185
+ :vartype recipe_id: str
186
+ :ivar run_id: Retrieval run identifier.
187
+ :vartype run_id: str
188
+ :ivar hash: Optional content hash for provenance.
189
+ :vartype hash: str or None
190
+ """
191
+
192
+ model_config = ConfigDict(extra="forbid")
193
+
194
+ item_id: str
195
+ source_uri: Optional[str] = None
196
+ media_type: str
197
+ score: float
198
+ rank: int = Field(ge=1)
199
+ text: Optional[str] = None
200
+ content_ref: Optional[str] = None
201
+ span_start: Optional[int] = None
202
+ span_end: Optional[int] = None
203
+ stage: str
204
+ recipe_id: str
205
+ run_id: str
206
+ hash: Optional[str] = None
207
+
208
+ @model_validator(mode="after")
209
+ def _require_text_or_reference(self) -> "Evidence":
210
+ has_text = isinstance(self.text, str) and self.text.strip()
211
+ has_ref = isinstance(self.content_ref, str) and self.content_ref.strip()
212
+ if not has_text and not has_ref:
213
+ raise ValueError("Evidence must include either text or content_ref")
214
+ return self
215
+
216
+
217
+ class RecipeManifest(BaseModel):
218
+ """
219
+ Reproducible configuration for a retrieval backend.
220
+
221
+ :ivar recipe_id: Deterministic recipe identifier.
222
+ :vartype recipe_id: str
223
+ :ivar backend_id: Backend identifier for the recipe.
224
+ :vartype backend_id: str
225
+ :ivar name: Human-readable name for the recipe.
226
+ :vartype name: str
227
+ :ivar created_at: International Organization for Standardization 8601 timestamp for recipe creation.
228
+ :vartype created_at: str
229
+ :ivar config: Backend-specific configuration values.
230
+ :vartype config: dict[str, Any]
231
+ :ivar description: Optional human description.
232
+ :vartype description: str or None
233
+ """
234
+
235
+ model_config = ConfigDict(extra="forbid")
236
+
237
+ recipe_id: str
238
+ backend_id: str
239
+ name: str
240
+ created_at: str
241
+ config: Dict[str, Any] = Field(default_factory=dict)
242
+ description: Optional[str] = None
243
+
244
+
245
+ class RetrievalRun(BaseModel):
246
+ """
247
+ Immutable record of a retrieval materialization or on-demand run.
248
+
249
+ :ivar run_id: Unique run identifier.
250
+ :vartype run_id: str
251
+ :ivar recipe: Recipe manifest for this run.
252
+ :vartype recipe: RecipeManifest
253
+ :ivar corpus_uri: Canonical uniform resource identifier for the corpus root.
254
+ :vartype corpus_uri: str
255
+ :ivar catalog_generated_at: Catalog timestamp used for the run.
256
+ :vartype catalog_generated_at: str
257
+ :ivar created_at: International Organization for Standardization 8601 timestamp for run creation.
258
+ :vartype created_at: str
259
+ :ivar artifact_paths: Relative paths to materialized artifacts.
260
+ :vartype artifact_paths: list[str]
261
+ :ivar stats: Backend-specific run statistics.
262
+ :vartype stats: dict[str, Any]
263
+ """
264
+
265
+ model_config = ConfigDict(extra="forbid")
266
+
267
+ run_id: str
268
+ recipe: RecipeManifest
269
+ corpus_uri: str
270
+ catalog_generated_at: str
271
+ created_at: str
272
+ artifact_paths: List[str] = Field(default_factory=list)
273
+ stats: Dict[str, Any] = Field(default_factory=dict)
274
+
275
+
276
+ class RetrievalResult(BaseModel):
277
+ """
278
+ Retrieval result bundle returned from a backend query.
279
+
280
+ :ivar query_text: Query text issued against the backend.
281
+ :vartype query_text: str
282
+ :ivar budget: Evidence selection budget applied to results.
283
+ :vartype budget: QueryBudget
284
+ :ivar run_id: Retrieval run identifier.
285
+ :vartype run_id: str
286
+ :ivar recipe_id: Recipe identifier used for this query.
287
+ :vartype recipe_id: str
288
+ :ivar backend_id: Backend identifier used for this query.
289
+ :vartype backend_id: str
290
+ :ivar generated_at: International Organization for Standardization 8601 timestamp for the query result.
291
+ :vartype generated_at: str
292
+ :ivar evidence: Evidence objects selected under the budget.
293
+ :vartype evidence: list[Evidence]
294
+ :ivar stats: Backend-specific query statistics.
295
+ :vartype stats: dict[str, Any]
296
+ """
297
+
298
+ model_config = ConfigDict(extra="forbid")
299
+
300
+ query_text: str
301
+ budget: QueryBudget
302
+ run_id: str
303
+ recipe_id: str
304
+ backend_id: str
305
+ generated_at: str
306
+ evidence: List[Evidence] = Field(default_factory=list)
307
+ stats: Dict[str, Any] = Field(default_factory=dict)