rag-blocks 0.6.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 (67) hide show
  1. rag_blocks/__init__.py +190 -0
  2. rag_blocks/chunking/__init__.py +17 -0
  3. rag_blocks/chunking/base.py +77 -0
  4. rag_blocks/chunking/fixed.py +69 -0
  5. rag_blocks/chunking/markdown.py +61 -0
  6. rag_blocks/core/__init__.py +66 -0
  7. rag_blocks/core/component.py +139 -0
  8. rag_blocks/core/contracts.py +389 -0
  9. rag_blocks/core/errors.py +84 -0
  10. rag_blocks/core/registry.py +128 -0
  11. rag_blocks/embedding/__init__.py +21 -0
  12. rag_blocks/embedding/base.py +60 -0
  13. rag_blocks/embedding/caching.py +116 -0
  14. rag_blocks/embedding/hashing.py +74 -0
  15. rag_blocks/embedding/sentence_transformer.py +99 -0
  16. rag_blocks/embedding/sparse.py +48 -0
  17. rag_blocks/enrichment/__init__.py +18 -0
  18. rag_blocks/enrichment/base.py +50 -0
  19. rag_blocks/enrichment/contextual.py +92 -0
  20. rag_blocks/enrichment/heading.py +57 -0
  21. rag_blocks/generation/__init__.py +16 -0
  22. rag_blocks/generation/anthropic_generator.py +119 -0
  23. rag_blocks/generation/base.py +53 -0
  24. rag_blocks/generation/extractive.py +36 -0
  25. rag_blocks/generation/packing.py +65 -0
  26. rag_blocks/indexing/__init__.py +13 -0
  27. rag_blocks/indexing/catalog.py +110 -0
  28. rag_blocks/indexing/chunk_index.py +228 -0
  29. rag_blocks/indexing/sink.py +31 -0
  30. rag_blocks/ingestion/__init__.py +28 -0
  31. rag_blocks/ingestion/detection.py +127 -0
  32. rag_blocks/ingestion/ocr/__init__.py +12 -0
  33. rag_blocks/ingestion/ocr/base.py +92 -0
  34. rag_blocks/ingestion/ocr/google_docai.py +77 -0
  35. rag_blocks/ingestion/ocr/mistral.py +86 -0
  36. rag_blocks/ingestion/parsers/__init__.py +6 -0
  37. rag_blocks/ingestion/parsers/auto.py +85 -0
  38. rag_blocks/ingestion/parsers/base.py +55 -0
  39. rag_blocks/ingestion/parsers/docling_parser.py +448 -0
  40. rag_blocks/ingestion/parsers/plaintext.py +70 -0
  41. rag_blocks/pipeline.py +476 -0
  42. rag_blocks/refinement/__init__.py +26 -0
  43. rag_blocks/refinement/base.py +43 -0
  44. rag_blocks/refinement/cross_encoder.py +71 -0
  45. rag_blocks/refinement/keyword.py +51 -0
  46. rag_blocks/refinement/neighbor.py +147 -0
  47. rag_blocks/refinement/threshold.py +35 -0
  48. rag_blocks/retrieval/__init__.py +26 -0
  49. rag_blocks/retrieval/base.py +44 -0
  50. rag_blocks/retrieval/fusion.py +93 -0
  51. rag_blocks/retrieval/fusion_retriever.py +82 -0
  52. rag_blocks/retrieval/hybrid.py +79 -0
  53. rag_blocks/retrieval/index_retriever.py +80 -0
  54. rag_blocks/retrieval/query_shaping.py +136 -0
  55. rag_blocks/storage/__init__.py +26 -0
  56. rag_blocks/storage/base.py +94 -0
  57. rag_blocks/storage/bm25_index.py +164 -0
  58. rag_blocks/storage/lexical_index.py +45 -0
  59. rag_blocks/storage/local.py +104 -0
  60. rag_blocks/storage/memory_store.py +180 -0
  61. rag_blocks/storage/minio_store.py +187 -0
  62. rag_blocks/storage/qdrant_store.py +415 -0
  63. rag_blocks/storage/vector_store.py +109 -0
  64. rag_blocks-0.6.0.dist-info/METADATA +308 -0
  65. rag_blocks-0.6.0.dist-info/RECORD +67 -0
  66. rag_blocks-0.6.0.dist-info/WHEEL +4 -0
  67. rag_blocks-0.6.0.dist-info/licenses/LICENSE +201 -0
rag_blocks/__init__.py ADDED
@@ -0,0 +1,190 @@
1
+ """rag-blocks: composable building blocks for production RAG pipelines.
2
+
3
+ Design in one paragraph: stages are swappable Strategies registered under a
4
+ (kind, name) key; they communicate only through typed data contracts
5
+ (Source → Page → Document → Chunk → …); ingestion is streaming-first so
6
+ memory never scales with document size; every component carries a config
7
+ fingerprint that later powers the auto-tuning suite's cross-pipeline caching.
8
+
9
+ Quick start:
10
+
11
+ import rag_blocks as rk
12
+
13
+ doc = rk.ingest("report.pdf") # sane defaults
14
+ doc = rk.ingest("scan.pdf", ocr_engine="mistral", # cloud OCR
15
+ ocr_policy=rk.OcrPolicy.FORCE)
16
+
17
+ parser = rk.AutoParser() # streaming access
18
+ for page in parser.iter_pages(rk.Source.from_path("huge.pdf")):
19
+ ...
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from pathlib import Path
25
+
26
+ from .core import (
27
+ Answer,
28
+ Chunk,
29
+ Citation,
30
+ Component,
31
+ Document,
32
+ EmbeddingError,
33
+ GenerationError,
34
+ Page,
35
+ PageSpan,
36
+ Query,
37
+ RagToolkitError,
38
+ ScoredChunk,
39
+ Source,
40
+ SourceFormat,
41
+ SparseVector,
42
+ StorageError,
43
+ VectorSpec,
44
+ VectorValue,
45
+ registry,
46
+ )
47
+ from .embedding import (
48
+ CachingEmbedder,
49
+ Embedder,
50
+ HashingEmbedder,
51
+ SentenceTransformerEmbedder,
52
+ SparseEncoder,
53
+ )
54
+ from .indexing import ChunkIndex, ChunkSink, DocumentCatalog, DocumentRef
55
+ from .enrichment import (
56
+ ContextualEnricher,
57
+ Enricher,
58
+ HeadingEnricher,
59
+ )
60
+ from .generation import AnthropicGenerator, ExtractiveGenerator, Generator
61
+ from .retrieval import (
62
+ FusionRetriever,
63
+ HybridRetriever,
64
+ HydeRetriever,
65
+ IndexRetriever,
66
+ MultiQueryRetriever,
67
+ Retriever,
68
+ )
69
+ from .ingestion import (
70
+ AutoParser,
71
+ DoclingParser,
72
+ OcrEngine,
73
+ OcrPolicy,
74
+ OcrResult,
75
+ PageImage,
76
+ Parser,
77
+ PlainTextParser,
78
+ detect_format,
79
+ )
80
+ from .chunking import Chunker, FixedChunker, MarkdownChunker
81
+ from .refinement import (
82
+ CrossEncoderReranker,
83
+ KeywordRefiner,
84
+ NeighborExpander,
85
+ Refiner,
86
+ ScoreThreshold,
87
+ )
88
+ from .pipeline import IndexingPipeline, QueryPipeline, RagPipeline, TraceEvent
89
+ from .storage import (
90
+ BM25Index,
91
+ BlobStore,
92
+ LexicalIndex,
93
+ LocalBlobStore,
94
+ MemoryVectorStore,
95
+ MinioBlobStore,
96
+ QdrantVectorStore,
97
+ VectorStore,
98
+ )
99
+
100
+ __version__ = "0.6.0"
101
+
102
+ __all__ = [
103
+ "ingest",
104
+ "registry",
105
+ "Component",
106
+ "Source",
107
+ "SourceFormat",
108
+ "Page",
109
+ "PageSpan",
110
+ "Document",
111
+ "Chunk",
112
+ "Query",
113
+ "ScoredChunk",
114
+ "Citation",
115
+ "Answer",
116
+ "Parser",
117
+ "AutoParser",
118
+ "DoclingParser",
119
+ "PlainTextParser",
120
+ "OcrEngine",
121
+ "OcrPolicy",
122
+ "OcrResult",
123
+ "PageImage",
124
+ "detect_format",
125
+ "Chunker",
126
+ "FixedChunker",
127
+ "MarkdownChunker",
128
+ "Embedder",
129
+ "SparseEncoder",
130
+ "CachingEmbedder",
131
+ "HashingEmbedder",
132
+ "SentenceTransformerEmbedder",
133
+ "SparseVector",
134
+ "VectorSpec",
135
+ "VectorValue",
136
+ "ChunkIndex",
137
+ "ChunkSink",
138
+ "DocumentCatalog",
139
+ "DocumentRef",
140
+ "Enricher",
141
+ "HeadingEnricher",
142
+ "ContextualEnricher",
143
+ "BlobStore",
144
+ "LocalBlobStore",
145
+ "MinioBlobStore",
146
+ "VectorStore",
147
+ "MemoryVectorStore",
148
+ "QdrantVectorStore",
149
+ "LexicalIndex",
150
+ "BM25Index",
151
+ "Retriever",
152
+ "IndexRetriever",
153
+ "FusionRetriever",
154
+ "HybridRetriever",
155
+ "MultiQueryRetriever",
156
+ "HydeRetriever",
157
+ "Refiner",
158
+ "CrossEncoderReranker",
159
+ "KeywordRefiner",
160
+ "NeighborExpander",
161
+ "ScoreThreshold",
162
+ "Generator",
163
+ "ExtractiveGenerator",
164
+ "AnthropicGenerator",
165
+ "IndexingPipeline",
166
+ "QueryPipeline",
167
+ "RagPipeline",
168
+ "TraceEvent",
169
+ "RagToolkitError",
170
+ "StorageError",
171
+ "EmbeddingError",
172
+ "GenerationError",
173
+ "EnrichmentError",
174
+ ]
175
+
176
+
177
+ def ingest(path: str | Path, **docling_overrides) -> Document:
178
+ """One-call Facade: file path → markdown Document with provenance.
179
+
180
+ Keyword arguments are forwarded to the PDF/office parser (DoclingParser),
181
+ since that is where 95% of tuning happens:
182
+
183
+ ingest("scan.pdf", ocr_engine="mistral", page_batch_size=4)
184
+
185
+ For full control (custom routes, streaming, in-memory sources), drop one
186
+ level down to AutoParser / DoclingParser directly.
187
+ """
188
+ parser_configs = {"docling": docling_overrides} if docling_overrides else {}
189
+ parser = AutoParser(parser_configs=parser_configs)
190
+ return parser.parse(Source.from_path(path))
@@ -0,0 +1,17 @@
1
+ """Chunking subsystem: Document → stream of retrieval Chunks.
2
+
3
+ Importing this package registers the built-in chunkers (module import is the
4
+ registration side effect the registry relies on). Strategies decide only WHERE
5
+ to cut (char-offset spans); the Template Method in `base.Chunker.chunk` owns all
6
+ bookkeeping and provenance.
7
+ """
8
+
9
+ from .base import Chunker
10
+ from .fixed import FixedChunker
11
+ from .markdown import MarkdownChunker
12
+
13
+ __all__ = [
14
+ "Chunker",
15
+ "FixedChunker",
16
+ "MarkdownChunker",
17
+ ]
@@ -0,0 +1,77 @@
1
+ """Chunker: the retrieval-unit Strategy interface.
2
+
3
+ The single design decision mirrored from the Parser: the *primitive* a strategy
4
+ implements is `iter_spans(document) -> Iterator[(start, end)]` — WHERE to cut,
5
+ as half-open character offsets into `document.markdown`. `chunk()` is a Template
6
+ Method layered on top that does every piece of bookkeeping once, correctly, for
7
+ every strategy.
8
+
9
+ Why spans (coordinates), not strings (copies)?
10
+ Return strings and you throw away everything that makes RAG citations work:
11
+ provenance (which chars → which pages), overlap (two chunks sharing a
12
+ region), and neighbor merging (fetch index±1 at query time). All three fall
13
+ out naturally when a strategy only decides *coordinates* and the base class
14
+ owns the slicing. This is the whole reason the interface emits offsets.
15
+
16
+ What the Template Method guarantees (so strategies never re-implement it):
17
+ - `text = document.markdown[start:end]` — the base slices, not the strategy.
18
+ - whitespace-only slices are skipped WITHOUT advancing the index, so
19
+ `index` stays contiguous 0-based with NO holes (a manual counter, never
20
+ `enumerate` over raw spans). Query-time neighbor expansion fetches
21
+ `index ± 1`, so a hole would silently drop a real neighbor.
22
+ - `id = f"{document.id}:{index}"` — deterministic ⇒ re-indexing overwrites
23
+ instead of duplicating.
24
+ - `char_start`/`char_end` are the primary provenance; `page_start`/
25
+ `page_end` are derived from them via `Document.pages_for_span` and are
26
+ ALWAYS filled for a doc-derived chunk.
27
+
28
+ Contract for strategies: yield spans in reading order of `start`. Overlapping
29
+ spans are LEGAL (that is how overlap strategies express themselves — in
30
+ coordinates). Strategies are configured by their `Config`, never by subclassing.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ from abc import abstractmethod
36
+ from typing import Iterator
37
+
38
+ from ..core.component import Component
39
+ from ..core.contracts import Chunk, Document
40
+
41
+ __all__ = ["Chunker"]
42
+
43
+
44
+ class Chunker(Component):
45
+ """Turns a Document into a stream of retrieval Chunks."""
46
+
47
+ kind = "chunker"
48
+
49
+ @abstractmethod
50
+ def iter_spans(self, document: Document) -> Iterator[tuple[int, int]]:
51
+ """Yield half-open `(start, end)` char offsets into
52
+ `document.markdown`, in reading order of `start`. Overlaps allowed."""
53
+
54
+ def chunk(self, document: Document) -> Iterator[Chunk]:
55
+ """Template Method: turn spans into Chunks, owning all bookkeeping.
56
+
57
+ Strategies implement `iter_spans`; this method — written once — is the
58
+ only place chunk ids, index contiguity, and provenance are decided.
59
+ """
60
+ index = 0
61
+ for start, end in self.iter_spans(document):
62
+ text = document.markdown[start:end]
63
+ if not text.strip():
64
+ # Skip empties WITHOUT advancing index (contiguity, no holes).
65
+ continue
66
+ pages = document.pages_for_span(start, end)
67
+ yield Chunk(
68
+ id=f"{document.id}:{index}",
69
+ doc_id=document.id,
70
+ text=text,
71
+ index=index,
72
+ char_start=start,
73
+ char_end=end,
74
+ page_start=pages[0] if pages else None,
75
+ page_end=pages[-1] if pages else None,
76
+ )
77
+ index += 1
@@ -0,0 +1,69 @@
1
+ """FixedChunker: fixed-size character windows with overlap.
2
+
3
+ The workhorse baseline: walk the markdown in windows of ~`chunk_chars`, with
4
+ `overlap_chars` of trailing context repeated into the next window so a fact
5
+ split across a boundary still lands whole in at least one chunk.
6
+
7
+ The one bit of finesse — cut on a boundary, not mid-sentence — reuses the exact
8
+ logic proven in `PlainTextParser._cut_point`: prefer to end a window at a
9
+ paragraph break (`\n\n`), else a line break (`\n`), but REFUSE a soft cut that
10
+ would leave a chunk shorter than half the target (otherwise a document full of
11
+ short lines would produce a swarm of tiny chunks). No boundary in range ⇒ a
12
+ hard cut at `chunk_chars`.
13
+
14
+ Overlap is expressed purely in coordinates (the next window starts
15
+ `overlap_chars` before the previous end), which is exactly why the Chunker
16
+ interface emits spans rather than strings — overlap and provenance survive.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from dataclasses import dataclass
22
+ from typing import Iterator
23
+
24
+ from ..core.contracts import Document
25
+ from ..core.registry import registry
26
+ from .base import Chunker
27
+
28
+ __all__ = ["FixedChunker"]
29
+
30
+
31
+ @registry.register
32
+ class FixedChunker(Chunker):
33
+ name = "fixed"
34
+ version = "0.1.0"
35
+
36
+ @dataclass
37
+ class Config:
38
+ chunk_chars: int = 1600
39
+ overlap_chars: int = 200
40
+
41
+ def iter_spans(self, document: Document) -> Iterator[tuple[int, int]]:
42
+ text = document.markdown
43
+ n = len(text)
44
+ start = 0
45
+ while start < n:
46
+ end = self._cut_end(text, start, n)
47
+ yield start, end
48
+ if end >= n:
49
+ break
50
+ # Step back by the overlap for the next window; guarantee forward
51
+ # progress even under a pathological overlap >= window size.
52
+ next_start = end - self.config.overlap_chars
53
+ start = next_start if next_start > start else end
54
+
55
+ def _cut_end(self, text: str, start: int, n: int) -> int:
56
+ """Where to end the window that begins at `start` (mirror of
57
+ PlainTextParser._cut_point, adapted to absolute offsets)."""
58
+ hard_end = start + self.config.chunk_chars
59
+ if hard_end >= n:
60
+ return n
61
+ # Only accept a soft cut in the back half of the window.
62
+ floor = start + self.config.chunk_chars // 2
63
+ para = text.rfind("\n\n", floor, hard_end)
64
+ if para != -1:
65
+ return para + 2
66
+ line = text.rfind("\n", floor, hard_end)
67
+ if line != -1:
68
+ return line + 1
69
+ return hard_end
@@ -0,0 +1,61 @@
1
+ """MarkdownChunker: cut on heading boundaries.
2
+
3
+ This is the payoff of normalizing every input to markdown during ingestion:
4
+ document *structure* survives all the way to the cutting decision. Instead of
5
+ slicing blindly every N characters, we cut at ATX headings (`#`..`######`), so
6
+ each chunk is a coherent section that starts with its own heading — far better
7
+ retrieval units than arbitrary windows.
8
+
9
+ Coordinates only (like every Chunker): we locate heading line offsets and emit
10
+ the spans *between* them. Content before the first heading is its own span; a
11
+ document with no headings yields a single span covering the whole thing. The
12
+ base Template Method then skips any whitespace-only section without disturbing
13
+ index contiguity.
14
+
15
+ Deliberately simple for v0.2: no secondary size cap on a very long section yet
16
+ (a `max_chars` split is an easy, non-breaking add later). Structure first.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import re
22
+ from typing import Iterator
23
+
24
+ from ..core.contracts import Document
25
+ from ..core.registry import registry
26
+ from .base import Chunker
27
+
28
+ __all__ = ["MarkdownChunker"]
29
+
30
+ #: An ATX heading: 1–6 '#', then whitespace, then the title. Up to three
31
+ #: leading spaces are allowed by CommonMark; `# no-space` is NOT a heading.
32
+ _HEADING = re.compile(r"^ {0,3}#{1,6}\s")
33
+
34
+
35
+ def _is_heading(line: str) -> bool:
36
+ return _HEADING.match(line) is not None
37
+
38
+
39
+ @registry.register
40
+ class MarkdownChunker(Chunker):
41
+ name = "markdown-aware"
42
+ version = "0.1.0"
43
+
44
+ def iter_spans(self, document: Document) -> Iterator[tuple[int, int]]:
45
+ text = document.markdown
46
+ n = len(text)
47
+ if n == 0:
48
+ return
49
+
50
+ # Collect cut points: the document start, plus every heading's offset.
51
+ cut_points = [0]
52
+ offset = 0
53
+ for line in text.splitlines(keepends=True):
54
+ if offset != 0 and _is_heading(line):
55
+ cut_points.append(offset)
56
+ offset += len(line)
57
+ cut_points.append(n)
58
+
59
+ for start, end in zip(cut_points, cut_points[1:]):
60
+ if end > start: # consecutive headings can coincide; skip empties
61
+ yield start, end
@@ -0,0 +1,66 @@
1
+ """Core layer: contracts, component model, registry, errors.
2
+
3
+ Nothing in here knows about RAG specifics — it is the framework the stages
4
+ are built on. Zero third-party dependencies by design.
5
+ """
6
+
7
+ from .component import Component
8
+ from .contracts import (
9
+ Answer,
10
+ Chunk,
11
+ Citation,
12
+ Document,
13
+ Page,
14
+ PageSpan,
15
+ Query,
16
+ ScoredChunk,
17
+ Source,
18
+ SourceFormat,
19
+ SparseVector,
20
+ VectorSpec,
21
+ VectorValue,
22
+ )
23
+ from .errors import (
24
+ ComponentNotFoundError,
25
+ ConfigError,
26
+ DuplicateComponentError,
27
+ EmbeddingError,
28
+ EnrichmentError,
29
+ GenerationError,
30
+ OcrError,
31
+ ParseError,
32
+ RagToolkitError,
33
+ StorageError,
34
+ UnsupportedFormatError,
35
+ )
36
+ from .registry import Registry, registry
37
+
38
+ __all__ = [
39
+ "Component",
40
+ "Registry",
41
+ "registry",
42
+ "Source",
43
+ "SourceFormat",
44
+ "Page",
45
+ "PageSpan",
46
+ "Document",
47
+ "Chunk",
48
+ "Query",
49
+ "ScoredChunk",
50
+ "Citation",
51
+ "Answer",
52
+ "SparseVector",
53
+ "VectorSpec",
54
+ "VectorValue",
55
+ "RagToolkitError",
56
+ "ComponentNotFoundError",
57
+ "DuplicateComponentError",
58
+ "ConfigError",
59
+ "UnsupportedFormatError",
60
+ "ParseError",
61
+ "OcrError",
62
+ "StorageError",
63
+ "EmbeddingError",
64
+ "GenerationError",
65
+ "EnrichmentError",
66
+ ]
@@ -0,0 +1,139 @@
1
+ """Component: the common base of every swappable building block.
2
+
3
+ Why one base class?
4
+ -------------------
5
+ Every stage implementation (a parser, an OCR engine, a chunker, a reranker…)
6
+ needs the same plumbing: a (kind, name) identity for the registry, a typed
7
+ config, and a stable *fingerprint*. Centralizing that here keeps each concrete
8
+ component focused purely on its algorithm (Single Responsibility), and it
9
+ gives the evaluation suite a uniform way to describe/cache/compare components
10
+ without knowing anything about them (Dependency Inversion: the tuner depends
11
+ on `Component`, never on `DoclingParser`).
12
+
13
+ The fingerprint is the quiet superpower: sha256(kind | name | version |
14
+ canonical-config). Two pipeline variants that share the same parser config
15
+ share the same fingerprint ⇒ the tuner can reuse the cached parse output
16
+ instead of re-parsing 5 GB of PDFs per combination. Bump `version` whenever
17
+ you change a component's behavior so stale caches invalidate themselves.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import dataclasses
23
+ import hashlib
24
+ import json
25
+ from abc import ABC
26
+ from enum import Enum
27
+ from typing import Any, ClassVar, Optional, Type
28
+
29
+ from .errors import ConfigError
30
+
31
+
32
+ def _plain(value: Any) -> Any:
33
+ """Normalize config values to log/JSON-friendly primitives.
34
+
35
+ Enums become their `.value` (so `OcrPolicy.AUTO` logs as "auto"), and
36
+ containers are walked recursively. Keeps describe() output — and thus
37
+ trial logs — clean and diffable.
38
+ """
39
+ if isinstance(value, Enum):
40
+ return value.value
41
+ if isinstance(value, dict):
42
+ return {k: _plain(v) for k, v in value.items()}
43
+ if isinstance(value, (list, tuple)):
44
+ return [_plain(v) for v in value]
45
+ return value
46
+
47
+ __all__ = ["Component"]
48
+
49
+ # Config field names containing these substrings are redacted from
50
+ # describe()/fingerprint(): secrets must never leak into logs or cache keys,
51
+ # and rotating an API key must not invalidate caches.
52
+ _SECRET_MARKERS = ("key", "token", "secret", "password", "credential")
53
+
54
+
55
+ class Component(ABC):
56
+ """Base class for every pluggable building block.
57
+
58
+ Subclass contract:
59
+ kind: the stage slot this fills ("parser", "ocr", "chunker", ...)
60
+ name: unique name within the kind ("docling", "mistral", ...)
61
+ version: bump on behavior changes (cache invalidation)
62
+ Config: optional dataclass type describing the configuration
63
+ """
64
+
65
+ kind: ClassVar[str]
66
+ name: ClassVar[str]
67
+ version: ClassVar[str] = "0.1.0"
68
+ Config: ClassVar[Optional[Type[Any]]] = None
69
+
70
+ #: The resolved config instance (or None when Config is undeclared).
71
+ #: Declared as Any so subclasses read `self.config.<field>` without mypy
72
+ #: narrowing it to `Any | None` at every access — the config's real shape
73
+ #: is the nested `Config` dataclass, checked where it is constructed.
74
+ config: Any
75
+
76
+ def __init__(self, config: Any = None, **overrides: Any) -> None:
77
+ if self.Config is None:
78
+ if config is not None or overrides:
79
+ raise ConfigError(
80
+ f"{type(self).__name__} takes no configuration"
81
+ )
82
+ self.config = None
83
+ return
84
+
85
+ if config is not None and not isinstance(config, self.Config):
86
+ raise ConfigError(
87
+ f"{type(self).__name__} expected config of type "
88
+ f"{self.Config.__name__}, got {type(config).__name__}"
89
+ )
90
+
91
+ # Ergonomics: accept a ready Config, keyword overrides, or both.
92
+ # `MyParser(page_batch_size=4)` beats building a config object by hand.
93
+ base = config if config is not None else self._default_config()
94
+ try:
95
+ self.config = (
96
+ dataclasses.replace(base, **overrides) if overrides else base
97
+ )
98
+ except TypeError as exc: # unknown field name → clear error, fail fast
99
+ raise ConfigError(f"{type(self).__name__}: {exc}") from exc
100
+
101
+ def _default_config(self) -> Any:
102
+ try:
103
+ return self.Config() # type: ignore[misc]
104
+ except TypeError as exc:
105
+ raise ConfigError(
106
+ f"{type(self).__name__}.Config has required fields; "
107
+ f"pass them explicitly: {exc}"
108
+ ) from exc
109
+
110
+ # -- identity ------------------------------------------------------------
111
+
112
+ def describe(self) -> dict:
113
+ """Loggable, secret-free description of this exact component setup.
114
+
115
+ This dict is what lands in every evaluation Trial record, so a result
116
+ is always reproducible from its log line alone.
117
+ """
118
+ cfg: dict[str, Any] = {}
119
+ if self.config is not None:
120
+ for k, v in dataclasses.asdict(self.config).items():
121
+ lowered = k.lower()
122
+ if any(m in lowered for m in _SECRET_MARKERS):
123
+ cfg[k] = "<redacted>"
124
+ else:
125
+ cfg[k] = _plain(v)
126
+ return {
127
+ "kind": self.kind,
128
+ "name": self.name,
129
+ "version": self.version,
130
+ "config": cfg,
131
+ }
132
+
133
+ def fingerprint(self) -> str:
134
+ """Stable hash of (kind, name, version, config) — the cache key."""
135
+ canonical = json.dumps(self.describe(), sort_keys=True, default=str)
136
+ return hashlib.sha256(canonical.encode()).hexdigest()[:16]
137
+
138
+ def __repr__(self) -> str: # helpful in logs and notebooks
139
+ return f"<{type(self).__name__} {self.kind}:{self.name} v{self.version}>"