rag-blocks 0.6.0__tar.gz

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 (130) hide show
  1. rag_blocks-0.6.0/.github/workflows/ci.yml +24 -0
  2. rag_blocks-0.6.0/.gitignore +89 -0
  3. rag_blocks-0.6.0/AGENTS.md +563 -0
  4. rag_blocks-0.6.0/ARCHITECTURE.md +493 -0
  5. rag_blocks-0.6.0/CHANGELOG.md +74 -0
  6. rag_blocks-0.6.0/CLAUDE.md +11 -0
  7. rag_blocks-0.6.0/LICENSE +201 -0
  8. rag_blocks-0.6.0/PKG-INFO +308 -0
  9. rag_blocks-0.6.0/README.md +260 -0
  10. rag_blocks-0.6.0/conftest.py +7 -0
  11. rag_blocks-0.6.0/docs/DR-0001-chunk-index.md +799 -0
  12. rag_blocks-0.6.0/docs/GUIDE.md +173 -0
  13. rag_blocks-0.6.0/docs/guide/01-getting-started.md +179 -0
  14. rag_blocks-0.6.0/docs/guide/02-concepts-and-architecture.md +176 -0
  15. rag_blocks-0.6.0/docs/guide/03-data-contracts.md +189 -0
  16. rag_blocks-0.6.0/docs/guide/04-ingestion-and-chunking.md +154 -0
  17. rag_blocks-0.6.0/docs/guide/05-representations-and-storage.md +282 -0
  18. rag_blocks-0.6.0/docs/guide/06-retrieval-and-refinement.md +177 -0
  19. rag_blocks-0.6.0/docs/guide/07-generation-and-citations.md +120 -0
  20. rag_blocks-0.6.0/docs/guide/08-pipelines.md +152 -0
  21. rag_blocks-0.6.0/docs/guide/09-extending-and-testing.md +168 -0
  22. rag_blocks-0.6.0/docs/guide/10-recipes.md +345 -0
  23. rag_blocks-0.6.0/docs/guide/README.md +47 -0
  24. rag_blocks-0.6.0/examples/quickstart.py +65 -0
  25. rag_blocks-0.6.0/pyproject.toml +63 -0
  26. rag_blocks-0.6.0/rag_blocks/__init__.py +190 -0
  27. rag_blocks-0.6.0/rag_blocks/chunking/__init__.py +17 -0
  28. rag_blocks-0.6.0/rag_blocks/chunking/base.py +77 -0
  29. rag_blocks-0.6.0/rag_blocks/chunking/fixed.py +69 -0
  30. rag_blocks-0.6.0/rag_blocks/chunking/markdown.py +61 -0
  31. rag_blocks-0.6.0/rag_blocks/core/__init__.py +66 -0
  32. rag_blocks-0.6.0/rag_blocks/core/component.py +139 -0
  33. rag_blocks-0.6.0/rag_blocks/core/contracts.py +389 -0
  34. rag_blocks-0.6.0/rag_blocks/core/errors.py +84 -0
  35. rag_blocks-0.6.0/rag_blocks/core/registry.py +128 -0
  36. rag_blocks-0.6.0/rag_blocks/embedding/__init__.py +21 -0
  37. rag_blocks-0.6.0/rag_blocks/embedding/base.py +60 -0
  38. rag_blocks-0.6.0/rag_blocks/embedding/caching.py +116 -0
  39. rag_blocks-0.6.0/rag_blocks/embedding/hashing.py +74 -0
  40. rag_blocks-0.6.0/rag_blocks/embedding/sentence_transformer.py +99 -0
  41. rag_blocks-0.6.0/rag_blocks/embedding/sparse.py +48 -0
  42. rag_blocks-0.6.0/rag_blocks/enrichment/__init__.py +18 -0
  43. rag_blocks-0.6.0/rag_blocks/enrichment/base.py +50 -0
  44. rag_blocks-0.6.0/rag_blocks/enrichment/contextual.py +92 -0
  45. rag_blocks-0.6.0/rag_blocks/enrichment/heading.py +57 -0
  46. rag_blocks-0.6.0/rag_blocks/generation/__init__.py +16 -0
  47. rag_blocks-0.6.0/rag_blocks/generation/anthropic_generator.py +119 -0
  48. rag_blocks-0.6.0/rag_blocks/generation/base.py +53 -0
  49. rag_blocks-0.6.0/rag_blocks/generation/extractive.py +36 -0
  50. rag_blocks-0.6.0/rag_blocks/generation/packing.py +65 -0
  51. rag_blocks-0.6.0/rag_blocks/indexing/__init__.py +13 -0
  52. rag_blocks-0.6.0/rag_blocks/indexing/catalog.py +110 -0
  53. rag_blocks-0.6.0/rag_blocks/indexing/chunk_index.py +228 -0
  54. rag_blocks-0.6.0/rag_blocks/indexing/sink.py +31 -0
  55. rag_blocks-0.6.0/rag_blocks/ingestion/__init__.py +28 -0
  56. rag_blocks-0.6.0/rag_blocks/ingestion/detection.py +127 -0
  57. rag_blocks-0.6.0/rag_blocks/ingestion/ocr/__init__.py +12 -0
  58. rag_blocks-0.6.0/rag_blocks/ingestion/ocr/base.py +92 -0
  59. rag_blocks-0.6.0/rag_blocks/ingestion/ocr/google_docai.py +77 -0
  60. rag_blocks-0.6.0/rag_blocks/ingestion/ocr/mistral.py +86 -0
  61. rag_blocks-0.6.0/rag_blocks/ingestion/parsers/__init__.py +6 -0
  62. rag_blocks-0.6.0/rag_blocks/ingestion/parsers/auto.py +85 -0
  63. rag_blocks-0.6.0/rag_blocks/ingestion/parsers/base.py +55 -0
  64. rag_blocks-0.6.0/rag_blocks/ingestion/parsers/docling_parser.py +448 -0
  65. rag_blocks-0.6.0/rag_blocks/ingestion/parsers/plaintext.py +70 -0
  66. rag_blocks-0.6.0/rag_blocks/pipeline.py +476 -0
  67. rag_blocks-0.6.0/rag_blocks/refinement/__init__.py +26 -0
  68. rag_blocks-0.6.0/rag_blocks/refinement/base.py +43 -0
  69. rag_blocks-0.6.0/rag_blocks/refinement/cross_encoder.py +71 -0
  70. rag_blocks-0.6.0/rag_blocks/refinement/keyword.py +51 -0
  71. rag_blocks-0.6.0/rag_blocks/refinement/neighbor.py +147 -0
  72. rag_blocks-0.6.0/rag_blocks/refinement/threshold.py +35 -0
  73. rag_blocks-0.6.0/rag_blocks/retrieval/__init__.py +26 -0
  74. rag_blocks-0.6.0/rag_blocks/retrieval/base.py +44 -0
  75. rag_blocks-0.6.0/rag_blocks/retrieval/fusion.py +93 -0
  76. rag_blocks-0.6.0/rag_blocks/retrieval/fusion_retriever.py +82 -0
  77. rag_blocks-0.6.0/rag_blocks/retrieval/hybrid.py +79 -0
  78. rag_blocks-0.6.0/rag_blocks/retrieval/index_retriever.py +80 -0
  79. rag_blocks-0.6.0/rag_blocks/retrieval/query_shaping.py +136 -0
  80. rag_blocks-0.6.0/rag_blocks/storage/__init__.py +26 -0
  81. rag_blocks-0.6.0/rag_blocks/storage/base.py +94 -0
  82. rag_blocks-0.6.0/rag_blocks/storage/bm25_index.py +164 -0
  83. rag_blocks-0.6.0/rag_blocks/storage/lexical_index.py +45 -0
  84. rag_blocks-0.6.0/rag_blocks/storage/local.py +104 -0
  85. rag_blocks-0.6.0/rag_blocks/storage/memory_store.py +180 -0
  86. rag_blocks-0.6.0/rag_blocks/storage/minio_store.py +187 -0
  87. rag_blocks-0.6.0/rag_blocks/storage/qdrant_store.py +415 -0
  88. rag_blocks-0.6.0/rag_blocks/storage/vector_store.py +109 -0
  89. rag_blocks-0.6.0/requirements-gpu.txt +31 -0
  90. rag_blocks-0.6.0/scripts/mini_pytest.py +218 -0
  91. rag_blocks-0.6.0/tests/chunking/test_fixed_chunker.py +55 -0
  92. rag_blocks-0.6.0/tests/chunking/test_markdown_chunker.py +51 -0
  93. rag_blocks-0.6.0/tests/conftest.py +4 -0
  94. rag_blocks-0.6.0/tests/contract_checks.py +419 -0
  95. rag_blocks-0.6.0/tests/core/test_component.py +68 -0
  96. rag_blocks-0.6.0/tests/core/test_contracts.py +56 -0
  97. rag_blocks-0.6.0/tests/core/test_registry.py +63 -0
  98. rag_blocks-0.6.0/tests/embedding/test_caching_embedder.py +83 -0
  99. rag_blocks-0.6.0/tests/embedding/test_hashing_embedder.py +53 -0
  100. rag_blocks-0.6.0/tests/enrichment/test_enrichers.py +50 -0
  101. rag_blocks-0.6.0/tests/generation/test_anthropic_complete.py +47 -0
  102. rag_blocks-0.6.0/tests/generation/test_extractive_generator.py +63 -0
  103. rag_blocks-0.6.0/tests/helpers.py +43 -0
  104. rag_blocks-0.6.0/tests/indexing/test_chunk_index.py +105 -0
  105. rag_blocks-0.6.0/tests/indexing/test_document_catalog.py +101 -0
  106. rag_blocks-0.6.0/tests/ingestion/test_auto_parser.py +53 -0
  107. rag_blocks-0.6.0/tests/ingestion/test_detection.py +66 -0
  108. rag_blocks-0.6.0/tests/ingestion/test_docling_routing.py +142 -0
  109. rag_blocks-0.6.0/tests/ingestion/test_plaintext_parser.py +46 -0
  110. rag_blocks-0.6.0/tests/integration/test_anthropic_generator.py +52 -0
  111. rag_blocks-0.6.0/tests/integration/test_contextual_enricher.py +48 -0
  112. rag_blocks-0.6.0/tests/integration/test_cross_encoder_refiner.py +46 -0
  113. rag_blocks-0.6.0/tests/integration/test_docling_pdf.py +29 -0
  114. rag_blocks-0.6.0/tests/integration/test_minio_integration.py +37 -0
  115. rag_blocks-0.6.0/tests/integration/test_qdrant_store.py +76 -0
  116. rag_blocks-0.6.0/tests/integration/test_sentence_transformer_embedder.py +46 -0
  117. rag_blocks-0.6.0/tests/refinement/test_keyword_refiner.py +36 -0
  118. rag_blocks-0.6.0/tests/refinement/test_neighbor_expander.py +87 -0
  119. rag_blocks-0.6.0/tests/refinement/test_threshold.py +33 -0
  120. rag_blocks-0.6.0/tests/retrieval/test_fusion_retriever.py +82 -0
  121. rag_blocks-0.6.0/tests/retrieval/test_hybrid_retriever.py +58 -0
  122. rag_blocks-0.6.0/tests/retrieval/test_index_retriever.py +86 -0
  123. rag_blocks-0.6.0/tests/retrieval/test_query_shaping.py +82 -0
  124. rag_blocks-0.6.0/tests/storage/test_bm25_index.py +83 -0
  125. rag_blocks-0.6.0/tests/storage/test_local_blob_store.py +51 -0
  126. rag_blocks-0.6.0/tests/storage/test_memory_store.py +103 -0
  127. rag_blocks-0.6.0/tests/storage/test_minio_store.py +58 -0
  128. rag_blocks-0.6.0/tests/test_pipeline.py +112 -0
  129. rag_blocks-0.6.0/tests/test_query_pipeline.py +81 -0
  130. rag_blocks-0.6.0/tests/test_rag_pipeline.py +101 -0
@@ -0,0 +1,24 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ test:
10
+ runs-on: ubuntu-latest
11
+ strategy:
12
+ matrix:
13
+ python-version: ["3.10", "3.11", "3.12"]
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+ - uses: actions/setup-python@v5
17
+ with:
18
+ python-version: ${{ matrix.python-version }}
19
+ - name: Install (dev)
20
+ run: pip install -e ".[dev]"
21
+ - name: Lint
22
+ run: ruff check rag_blocks tests
23
+ - name: Test with coverage
24
+ run: pytest --cov=rag_blocks --cov-report=term-missing
@@ -0,0 +1,89 @@
1
+ # ---------------------------------------------------------------------------
2
+ # rag-blocks .gitignore (Python library, GitHub "Python" template + project)
3
+ # ---------------------------------------------------------------------------
4
+
5
+ # Local scratch / generated docs (not part of the repo)
6
+ tump_docs/
7
+
8
+ # Byte-compiled / optimized / DLL files
9
+ __pycache__/
10
+ *.py[cod]
11
+ *$py.class
12
+
13
+ # C extensions
14
+ *.so
15
+
16
+ # Distribution / packaging
17
+ .Python
18
+ build/
19
+ develop-eggs/
20
+ dist/
21
+ downloads/
22
+ eggs/
23
+ .eggs/
24
+ lib/
25
+ lib64/
26
+ parts/
27
+ sdist/
28
+ var/
29
+ wheels/
30
+ share/python-wheels/
31
+ *.egg-info/
32
+ .installed.cfg
33
+ *.egg
34
+ MANIFEST
35
+
36
+ # Unit test / coverage reports
37
+ htmlcov/
38
+ .tox/
39
+ .nox/
40
+ .coverage
41
+ .coverage.*
42
+ .cache
43
+ nosetests.xml
44
+ coverage.xml
45
+ *.cover
46
+ *.py,cover
47
+ .hypothesis/
48
+ .pytest_cache/
49
+ cover/
50
+
51
+ # Type checkers / linters
52
+ .mypy_cache/
53
+ .dmypy.json
54
+ dmypy.json
55
+ .pyre/
56
+ .pytype/
57
+ .ruff_cache/
58
+
59
+ # Environments
60
+ .env
61
+ .venv
62
+ env/
63
+ venv/
64
+ ENV/
65
+ env.bak/
66
+ venv.bak/
67
+
68
+ # Secrets (never commit) — see AGENTS.md §7.4
69
+ *.pem
70
+ *.key
71
+
72
+ # Jupyter Notebook
73
+ .ipynb_checkpoints
74
+
75
+ # IDEs / editors
76
+ .idea/
77
+ .vscode/
78
+ *.swp
79
+ *.swo
80
+ *~
81
+
82
+ # OS cruft
83
+ .DS_Store
84
+ Thumbs.db
85
+
86
+ # Project-local caches (blob store, parse cache, trial logs when run locally)
87
+ .rag_cache/
88
+ *.sqlite
89
+ *.sqlite3
@@ -0,0 +1,563 @@
1
+ # AGENTS.md — rag-blocks agent context
2
+
3
+ **Read this file completely before writing or modifying any code.** It is the
4
+ canonical knowledge transfer from the project's design phase. It contains the
5
+ philosophy, every design decision and its rationale, the semantics of the data
6
+ contracts, specs for components that are designed but NOT yet coded, and the
7
+ rules any contribution must follow. When this file conflicts with your general
8
+ habits, this file wins. When code conflicts with this file, flag it — do not
9
+ silently "fix" either side.
10
+
11
+ Recommended reading order before your first change:
12
+ `AGENTS.md` (this file) → `ARCHITECTURE.md` → `rag_blocks/core/contracts.py`
13
+ → `rag_blocks/core/component.py` → `rag_blocks/ingestion/parsers/base.py`
14
+ → one concrete parser (`plaintext.py`, then `docling_parser.py`) →
15
+ `tests/contract_checks.py`. Module docstrings are load-bearing documentation,
16
+ not decoration — they explain *why*, and you are expected to write in the
17
+ same style.
18
+
19
+ ---
20
+
21
+ ## 1. What this project is
22
+
23
+ `rag-blocks` is an open-source Python library of **composable building blocks
24
+ for production RAG pipelines**: every stage (parsing, chunking, embedding,
25
+ storage, retrieval, reranking, generation, evaluation) is a swappable
26
+ component behind a stable contract, every pipeline is a serializable config,
27
+ and an auto-tuning evaluation suite finds the best component combination for a
28
+ given dataset — with full trial logs and per-stage insights.
29
+
30
+ The differentiators over neighbors (AutoRAG is the closest competitor;
31
+ Haystack/LlamaIndex are the frameworks):
32
+
33
+ 1. **Swappability as the product.** Not a framework you live inside — blocks
34
+ you compose. "SWAPPABLE" is the owner's one-word summary of the project.
35
+ 2. **Streaming-first ingestion** with per-page OCR routing to *any* engine
36
+ (Mistral, Google Document AI, custom) — memory never scales with document
37
+ size.
38
+ 3. **Fingerprint-keyed cross-pipeline caching** that makes tuning tractable
39
+ (shared stage prefixes are computed once across all trial combinations).
40
+ 4. **Provenance end to end**: every chunk can answer "which pages of which
41
+ file", enabling citations.
42
+
43
+ Owner context that shapes decisions: this is the maintainer's **first
44
+ open-source project** and is explicitly a learning vehicle for clean design.
45
+ Code quality, pattern discipline, and explanatory docstrings are requirements,
46
+ not nice-to-haves. He is reading *Clean Code*; honor its spirit (small
47
+ functions, intention-revealing names, no clever tricks — there is a comment in
48
+ `plaintext.py` where a "clever" one-liner was deliberately rewritten as two
49
+ clear lines; that is the house style).
50
+
51
+ Current state: **v0.1 — core + ingestion subsystem implemented and tested**
52
+ (64 hermetic tests passing, 1 opt-in integration test). Everything else is
53
+ specified (here and in ARCHITECTURE.md) but not coded.
54
+
55
+ ---
56
+
57
+ ## 2. The prime directive: design principles are hard requirements
58
+
59
+ The owner's explicit instruction: *use design patterns and principles so the
60
+ code and architecture stay as clean as possible.* Every PR is judged against
61
+ these eight principles. They are not aspirational.
62
+
63
+ 1. **Contracts, not coupling.** Stages never import each other. They agree
64
+ only on the typed dataclasses in `core/contracts.py`
65
+ (`Source → Page → Document → Chunk → ScoredChunk → Answer`). A Chunker
66
+ must not know what a Parser is.
67
+ 2. **Composition over inheritance.** The only mandatory base is `Component`
68
+ plus the stage ABC. Never create deep hierarchies; a hybrid retriever
69
+ *contains* two retrievers.
70
+ 3. **Streaming-first.** Data-producing primitives are generators
71
+ (`iter_pages`). Materializing conveniences (`parse()`) are layered on top
72
+ via Template Method. No stage may hold more than one window/batch of data
73
+ at a time. Memory must not scale with input size.
74
+ 4. **Open/Closed via the registry.** New capability = new registered class.
75
+ Adding a parser/engine/chunker must require ZERO edits to existing files
76
+ (except an import in the subsystem `__init__.py` for built-ins).
77
+ 5. **Config-as-data.** Pipelines are serializable dicts/YAML. Behavior
78
+ differences come from config, never from subclassing-for-configuration.
79
+ 6. **Provenance from day one.** Every artifact must answer "where did this
80
+ come from". Never drop offsets, page numbers, or source references —
81
+ they cannot be reconstructed later.
82
+ 7. **Batteries optional.** `rag_blocks.core` has ZERO third-party
83
+ dependencies (stdlib dataclasses, not pydantic — deliberate). Every vendor
84
+ SDK is a pip extra, imported lazily *inside the method that uses it*,
85
+ with an actionable ImportError message naming the extra.
86
+ 8. **Everything measurable.** Every component has a deterministic
87
+ `fingerprint()` = sha256(kind, name, version, redacted-config)[:16].
88
+ Fingerprints are cache keys and trial identity. **If you change a
89
+ component's behavior, bump its `version`** — that is how caches
90
+ invalidate. Never change fingerprint semantics casually.
91
+
92
+ Corollary the owner has internalized and expects you to apply:
93
+ **"Testability is the first consumer of the architecture. If a change is hard
94
+ to test, the design is wrong"** — fix the design (extract a pure function,
95
+ inject through a seam), don't write a heroic test.
96
+
97
+ ---
98
+
99
+ ## 3. Architecture core: the two-layer class hierarchy
100
+
101
+ This confused the owner once; it is now settled and must not be redesigned.
102
+
103
+ ```
104
+ Component layer 1 — shared PLUMBING, no domain logic:
105
+ │ (kind, name, version) identity, config
106
+ │ dataclass merging, describe()/fingerprint()
107
+ │ with secret redaction
108
+ ├── Parser (abstract iter_pages) ┐
109
+ ├── OcrEngine (abstract recognize) │ layer 2 — one ABC per stage,
110
+ ├── Chunker (abstract iter_spans) │ carries the stage CONTRACT
111
+ ├── Embedder, VectorStore, Retriever, ... ┘ via @abstractmethod
112
+ └────── concrete implementations (DoclingParser, MistralOcrEngine, ...)
113
+ ```
114
+
115
+ Why one `Component` grandparent: the plumbing is identical for all stages
116
+ (DRY), and the registry/pipelines/tuner need a **common type** to hold
117
+ heterogeneous collections ("give me the fingerprints of all 6 components of
118
+ trial #14"). Why per-stage ABCs on top: each stage has its own contract.
119
+ In Java terms: stage ABC = `interface`, `Component` = shared `abstract class`.
120
+
121
+ Enforcement is three layers — implement all three for any new stage:
122
+ 1. `@abstractmethod` → `TypeError` at instantiation (runtime).
123
+ 2. Type hints + mypy in CI (Python's "compile time"; ABCs don't check
124
+ signatures, mypy does).
125
+ 3. **Behavioral contract tests** (`tests/contract_checks.py`) for what
126
+ neither can check (ordering, span validity, determinism). Every new stage
127
+ kind gets an `assert_<stage>_contract()` helper; every implementation's
128
+ tests must call it.
129
+
130
+ `typing.Protocol` was considered and rejected: we want inherited *behavior*
131
+ (config, fingerprint) and registration, not just structural shape. Do not
132
+ migrate to Protocols.
133
+
134
+ Registry mechanics (`core/registry.py`): `@registry.register` class decorator
135
+ reads `kind`/`name` from the class (single source of truth, decorator takes no
136
+ args). Re-registering the *same* class is idempotent; a *different* class
137
+ under an existing key raises. Third-party plugins load lazily via the
138
+ `rag_blocks.components` entry-point group; a broken plugin must never crash
139
+ core (exceptions are swallowed per entry point). `registry.create(kind, name,
140
+ **overrides)` is the Factory Method everything uses.
141
+
142
+ Config mechanics (`core/component.py`): each component optionally declares a
143
+ nested `@dataclass class Config`. `__init__(config=None, **overrides)` accepts
144
+ a ready Config, keyword overrides, or both (overrides win via
145
+ `dataclasses.replace`); unknown keys → `ConfigError` (fail fast).
146
+ `describe()` redacts any config field whose lowercase name contains one of
147
+ `("key", "token", "secret", "password", "credential")` and normalizes enums to
148
+ `.value`. `fingerprint()` hashes the *redacted* describe — consequence:
149
+ rotating an API key never invalidates caches, and secrets can never appear in
150
+ logs or trial records.
151
+
152
+ ---
153
+
154
+ ## 4. Data contract semantics (the subtle parts)
155
+
156
+ The dataclasses are in `core/contracts.py`; what follows is the *meaning* an
157
+ agent must preserve.
158
+
159
+ **`Source`** is a lazy pointer (path or small in-memory bytes), frozen.
160
+ Never eagerly read content; access via `open()` (streams), `head(n)` (sniff),
161
+ `content_hash()` (streaming sha256 — a future cache key). Derive variants
162
+ with `with_format()` / `dataclasses.replace`, never mutate.
163
+
164
+ **`Page`** is the streaming unit of ingestion. 1-based `number`.
165
+ `ocr_applied=True` only when we *know* OCR produced the text (external engine
166
+ or FORCE); docling AUTO OCRs bitmap regions selectively and doesn't tell us,
167
+ so we don't lie.
168
+
169
+ **`Document` is a fact; a chunking is an interpretation of it.** A Document is
170
+ parsed content + provenance, cached under (source hash × parser fingerprint).
171
+ It must NEVER hold chunks, know about chunkers, or grow a `get_chunk(i)`
172
+ method — the same document legitimately has many simultaneous chunkings (the
173
+ tuner depends on this). **Arrows point backward, like database foreign keys:**
174
+ `Chunk` carries `doc_id`; `Document` has no forward references. "Get chunk by
175
+ index" lives where chunks live — the vector store, via payload filter
176
+ (`doc_id == X AND index IN (...)`).
177
+
178
+ **`PageSpan`** records char offsets `[start, end)` of each page inside
179
+ `Document.markdown` (assembled with `PAGE_SEPARATOR = "\n\n"`). The invariant
180
+ tests enforce: `doc.markdown[span.start:span.end] == page.markdown`, spans
181
+ ordered and non-overlapping. **`Document.pages_for_span(start, end)` is the
182
+ designed bridge between parsing and chunking** — chunkers resolve page
183
+ provenance through it and through nothing else.
184
+
185
+ **`Chunk` field semantics:**
186
+ - `id`: deterministic, `f"{doc_id}:{index}"` → idempotent re-indexing
187
+ (re-running upserts overwrites instead of duplicating).
188
+ - `index`: **reading-order position within the document, contiguous 0-based,
189
+ NO holes** — even when whitespace-only spans are skipped, the counter must
190
+ not skip (use a manual counter, not `enumerate` over raw spans). Reason:
191
+ neighbor expansion at query time fetches `index ± 1` from the store to give
192
+ the generator surrounding context; relevance order (retrieval) and reading
193
+ order (index) are different orderings and both are needed.
194
+ - `page_start` / `page_end`: a *range* because chunks legitimately cross page
195
+ boundaries. They are `Optional` meaning **"not always applicable"** — for
196
+ any chunk sliced from a parsed document the base chunker ALWAYS fills them;
197
+ `None` is reserved for synthetic chunks (enricher-generated summaries,
198
+ synthesized Q/A) that never came from a document's markdown. A doc-derived
199
+ chunk with `None` pages is a bug.
200
+ - Committed contract change for v0.2: **promote `char_start`/`char_end` to
201
+ first-class `Chunk` fields** (currently they'd sit in metadata). Char
202
+ offsets are the primary provenance; pages are derived from them.
203
+ - `metadata: dict` exists on every contract as a pressure valve so extensions
204
+ never force schema changes.
205
+
206
+ ---
207
+
208
+ ## 5. Pattern glossary (use these names in docstrings and reviews)
209
+
210
+ | Pattern | Where it lives | Why |
211
+ |---|---|---|
212
+ | Strategy | every stage interface | swap algorithms without touching callers — the product thesis |
213
+ | Adapter | `DoclingParser`, `MistralOcrEngine`, `GoogleDocAiOcrEngine`; future `ChonkieChunker`, `RagasEvaluator`, `MinioBlobStore`, `QdrantStore` | vendor churn stays inside one file |
214
+ | Registry + Factory Method | `core/registry.py` | string → instance; pipelines become data; plugin ecosystem via entry points |
215
+ | Facade | `rk.ingest()`, `AutoParser`, future `RagPipeline` | one obvious call for the 90% case |
216
+ | Template Method | `Parser.parse()` over `iter_pages()`; committed for `Chunker.chunk()` over `iter_spans()` | bookkeeping written once, correctly; strategies implement ONE primitive |
217
+ | Iterator / generator pipeline | `iter_pages`, `recognize_batch`, future `iter_spans` | O(batch) memory, backpressure free |
218
+ | Composite | `AutoParser`, `HybridRetriever`, `FusionRetriever` | components made of components, uniform to callers |
219
+ | Null Object (as empty chain) | empty `refine=[]` / `enrich=[]` | optional stages without `if x is not None` litter; the empty chain *is* the null object (no `NoOp*` classes — DR-0001 v2) |
220
+ | Immutable value objects | `Source`, `PageSpan`, `PageImage` | safe across caches/threads |
221
+ | Lazy initialization | vendor imports, docling converter cache, OCR clients | zero-dep core; heavy models built once, reused |
222
+
223
+ **Forbidden anti-patterns:** god objects ("PipelineManager" that knows
224
+ everything), inheritance-for-configuration, eager whole-file reads, stages
225
+ importing sibling stages, `localStorage`-style hidden global state inside
226
+ components (components must be pure functions of (config, inputs) or
227
+ fingerprint caching becomes unsound), swallowing exceptions without context,
228
+ returning strings where offsets/spans are available.
229
+
230
+ ---
231
+
232
+ ## 6. Ingestion subsystem — operational knowledge
233
+
234
+ Flow: `detect_format` (magic bytes first — files lie about extensions; ZIP
235
+ family disambiguated by member paths `word/`→docx, `ppt/`→pptx, `xl/`→xlsx;
236
+ extension only as tiebreaker for signatureless text) → `AutoParser` routes via
237
+ its `routes` config dict (data, overridable) → delegate parser.
238
+
239
+ **OCR is two orthogonal axes — never merge them:**
240
+ - `OcrPolicy` = WHEN (a decision): `AUTO` probe each page's embedded text
241
+ layer, OCR only pages below `min_chars_digital` (32 chars — above stray
242
+ page-number noise, below real content); `FORCE` = OCR everything (rescues
243
+ scanner-generated garbage text layers); `NEVER` = text layer only.
244
+ - `OcrEngine` = HOW (a Strategy): tiny interface, `recognize(PageImage) →
245
+ OcrResult`. Engines know nothing about PDFs/pages/documents.
246
+
247
+ Dispatch matrix in `DoclingParser._iter_pdf` (tested exhaustively in
248
+ `test_docling_routing.py::test_pdf_dispatch_matrix` — keep that test green):
249
+ no external engine → delegate policy to docling's own OCR options; external
250
+ engine + NEVER → docling no-OCR; external engine + AUTO → hybrid per-page
251
+ routing (pdfium char-count probe, consecutive same-kind pages grouped into
252
+ segments so docling keeps efficient windows); external + FORCE → every page
253
+ rendered (200 dpi PNG) → engine.
254
+
255
+ Memory strategy: PDFs are random-access → processed in windows of
256
+ `page_batch_size` (8) pages via docling `page_range`; office formats have no
257
+ sub-file random access → converted whole (deliberate asymmetry — they're
258
+ rarely huge). The pdfium document opens ONCE per file for probe + rendering;
259
+ one page bitmap in memory at a time; images stream through
260
+ `recognize_batch()` (the parallelism hook engines may override).
261
+
262
+ Throughput rule: docling converters (layout models) and OCR HTTP clients are
263
+ expensive — **cache per option-set on the parser instance, reuse across all
264
+ windows and documents**. Never construct per file/page.
265
+
266
+ Known API-drift guards (do not remove; verify on dependency bumps):
267
+ docling `page_range` requires >= 2.15; per-page
268
+ `export_to_markdown(page_no=...)` is wrapped in `try/except TypeError` with a
269
+ window-level fallback (Page carries `metadata["page_span"]` — provenance
270
+ degrades honestly, never wrongly); pypdfium2 `count_chars()` falls back to
271
+ `len(get_text_bounded())`; the `mistralai` call signature
272
+ (`client.ocr.process(model=..., document={"type": "image_url", ...})`) was
273
+ written against SDK 1.x and must be re-verified against current docs before a
274
+ release. `GoogleDocAiOcrEngine` returns plain text (valid markdown);
275
+ reconstructing headings/tables from DocAI layout entities is a welcome
276
+ improvement that must stay entirely inside that adapter.
277
+
278
+ File-naming note: the docling module is `docling_parser.py`, not `docling.py`
279
+ — avoids tooling confusion with the real `docling` package. Follow the same
280
+ caution for future vendor adapters.
281
+
282
+ ---
283
+
284
+ ## 7. Committed designs NOT yet coded (implement to these specs)
285
+
286
+ These were decided in design discussion with the owner and exist only here.
287
+ Do not re-litigate them; do refine details that don't contradict them.
288
+
289
+ ### 7.1 Chunker (v0.2 — the next milestone)
290
+
291
+ ```python
292
+ class Chunker(Component):
293
+ kind = "chunker"
294
+
295
+ @abstractmethod
296
+ def iter_spans(self, document: Document) -> Iterator[tuple[int, int]]:
297
+ """The ONLY strategy decision: WHERE to cut, as half-open char
298
+ offsets [start, end) into document.markdown. Cut coordinates,
299
+ not copies."""
300
+
301
+ def chunk(self, document: Document) -> Iterator[Chunk]:
302
+ # Template Method — ALL bookkeeping lives here, once:
303
+ # - slice text = document.markdown[start:end]
304
+ # - skip whitespace-only slices WITHOUT advancing index
305
+ # (manual counter; index stays contiguous 0-based, no holes)
306
+ # - id = f"{document.id}:{index}" (deterministic)
307
+ # - pages = document.pages_for_span(start, end);
308
+ # page_start/page_end ALWAYS filled here
309
+ # - char_start/char_end stored as first-class Chunk fields
310
+ ```
311
+
312
+ Rules: spans yielded in reading order of `start`; **overlapping spans are
313
+ legal** (overlap strategies express naturally in coordinates — this is *why*
314
+ strategies emit spans, not strings: return strings and provenance,
315
+ overlap, and neighbor merging all die). Strategies are config-only.
316
+
317
+ Planned implementations: `fixed` (chunk_chars=1600, overlap_chars=200; prefer
318
+ cutting at `\n\n`, refuse a soft cut that would leave < size/2 — mirror the
319
+ newline-preference logic in `PlainTextParser._cut_point`), `markdown-aware`
320
+ (cut at heading positions — this is the payoff of normalizing ingestion to
321
+ markdown: structure survives to the cutting decision), `chonkie` (Adapter —
322
+ Chonkie chunks already expose `start_index`/`end_index`, map them straight to
323
+ spans; Chonkie is a preferred dependency, extra `[chonkie]`), `semantic`
324
+ (later). **No `chunk_stream` in v0.2** — deliberate YAGNI: markdown of even a
325
+ 2,000-page PDF is a few MB; keep the streaming hook documented for a future
326
+ version, don't build it now. Ship
327
+ `tests/contract_checks.py::assert_chunker_contract` (index contiguity, span
328
+ ordering/bounds, slices match text, page fields filled, determinism) alongside.
329
+
330
+ ### 7.2 BlobStore (with v0.3 storage)
331
+
332
+ New component kind `"blob_store"`: `put(key: str, data: bytes)`,
333
+ `get(key) -> bytes`, `exists(key) -> bool` (streaming variants may come
334
+ later). Implementations: `LocalBlobStore` (filesystem, zero-dep, default) and
335
+ `MinioBlobStore` (Adapter over the `minio` SDK — which is Apache-2.0 even
336
+ though the MinIO *server* is AGPL; extra `[minio]`, S3-compatible so it covers
337
+ AWS too).
338
+
339
+ Content-addressed layout — note the second key IS the tuner's parse cache
340
+ materialized (this is why `Source.content_hash()` and `fingerprint()` exist
341
+ since v0.1):
342
+
343
+ ```
344
+ raw/{sha256}/original{ext} immutable source of truth (dedup free)
345
+ parsed/{sha256}/{parser_fingerprint}.md the parse cache
346
+ parsed/{sha256}/{parser_fingerprint}.meta.json spans, ocr pages, doc metadata
347
+ ```
348
+
349
+ Principle: **blob store = truth; Qdrant = derived and rebuildable.** Chunk
350
+ text + `{doc_id, index, page_start, page_end}` are duplicated into the Qdrant
351
+ payload so query time never touches the blob store; re-embedding with a new
352
+ model reads markdown from the blob store and never re-parses. Trial logs are
353
+ NOT blobs — they go to JSONL + SQLite.
354
+
355
+ ### 7.3 Evaluation & tuning (v0.6–0.7)
356
+
357
+ `Evaluator` kind with `stage: "retrieval" | "generation"`. Two families by
358
+ cost: classic IR metrics (recall@k, MRR, nDCG — pure math, no LLM) and
359
+ LLM-judged. **RAGAS integrates as `RagasEvaluator`, an Adapter** translating
360
+ our trial data (question, retrieved contexts, answer, ground truth) into a
361
+ RAGAS `EvaluationDataset` (faithfulness, answer_relevancy,
362
+ context_precision/recall) and mapping scores back to our `MetricReport`.
363
+ Two-phase evaluation: phase 1 screens ALL combinations with IR metrics;
364
+ phase 2 runs RAGAS/LLM-judge on the top-N (default 5) only. Judge verdicts
365
+ cached by (question, answer, judge-model) hash.
366
+
367
+ Tuner is a Strategy (`grid`, `random`; `bayesian`/successive-halving later).
368
+ Stage-output cache key = `sha256(dataset/source hashes + fingerprint chain of
369
+ stages 1..N)` — shared pipeline prefixes across trials are computed once
370
+ (e.g., 24 combos → 1 parse, 2 chunk runs, 4 embed runs). `Trial` records:
371
+ trial_id, full `describe()` per stage (secrets already redacted by design),
372
+ fingerprints, metrics, cost (latency_ms, tokens, api_usd), cache_hits,
373
+ timestamps → JSONL + SQLite. Leaderboard computes **per-stage marginal
374
+ analysis** ("averaged over all else, the cross-encoder refiner adds +0.07 nDCG
375
+ for +180 ms/query") — quality AND cost attribution is the "deep insights"
376
+ deliverable.
377
+
378
+ ### 7.4 Secrets policy (applies to every adapter you write)
379
+
380
+ - Credential resolution pattern, exactly:
381
+ `self.config.api_key or os.environ.get("<VENDOR>_API_KEY")` — explicit
382
+ config wins (users with their own secret managers), env var is the default.
383
+ **Use the vendor-standard env name** (`MISTRAL_API_KEY`), never a
384
+ toolkit-prefixed one — least surprise.
385
+ - The library **NEVER** calls `load_dotenv()`, never writes secrets, never
386
+ logs them. Populating the environment is the application's job.
387
+ - **Pipeline specs / YAML / trial logs must never contain secrets.** Config
388
+ names *which* engine; the environment supplies *its* credentials. A future
389
+ config loader may support `${ENV_VAR}` interpolation resolved at load time.
390
+ - Custom-engine authors must name credential fields with a redaction marker
391
+ substring (`api_key`, `auth_token`, ...) to get automatic redaction —
392
+ document this in the extension guide.
393
+ - Google uses Application Default Credentials (no key field at all —
394
+ `GOOGLE_APPLICATION_CREDENTIALS` or ambient identity). Vendor-specific
395
+ credential mechanics belong inside the vendor's Adapter.
396
+ - Repo hygiene: `.env` gitignored; committed `.env.example` with placeholder
397
+ keys (`MISTRAL_API_KEY=`, `GOOGLE_APPLICATION_CREDENTIALS=`,
398
+ `rag_blocks_TEST_PDF=`); CI secrets via GitHub Actions secrets. Fork PRs
399
+ don't receive secrets — which is fine BY DESIGN because the default test
400
+ suite is hermetic; only `-m integration` needs keys (run on main/nightly).
401
+
402
+ ### 7.5 License
403
+
404
+ Apache-2.0 (decided; already in pyproject). Before first publish: add
405
+ verbatim `LICENSE` text (never edited), optional one-line `NOTICE`
406
+ (`rag-blocks — Copyright 2026 Mohamed Elamine Bentarzi`), prefer PEP 639
407
+ form `license = "Apache-2.0"` + `license-files = ["LICENSE"]` in pyproject.
408
+ Rationale: explicit patent grant, contribution licensing (§5), ecosystem norm
409
+ for RAG infra. Decided while sole-author — do not merge external PRs before
410
+ LICENSE lands.
411
+
412
+ ### 7.6 ChunkIndex, composition algebra & multi-representation retrieval (DR-0001 v2)
413
+
414
+ All retrieval representations of a corpus are owned by one `ChunkIndex`:
415
+ `add(chunks)` writes every representation; `search(name, TEXT, k, filters)`
416
+ encodes the query with the same encoder that encoded the corpus — never
417
+ reimplement query encoding elsewhere. Constructor uses progressive disclosure:
418
+ `dense=embedder` auto-names; mappings only for multiple representations.
419
+ **Standing design rule (progressive disclosure):** the common case reads like
420
+ English; the rare case is possible; the rare case's ceremony never leaks into
421
+ the common case. Chunks NEVER carry vectors. The composition algebra:
422
+ pre-retrieval variation = composite retrievers
423
+ (`Fusion`/`Hybrid`/`MultiQuery`/`Hyde` — never new pipeline slots);
424
+ post-retrieval variation = the `refine` chain
425
+ (`Refiner.refine(query, candidates, k)`; the `reranker` kind is retired into
426
+ it); write-side = `enrich` chain + `sinks` fan-out (`ChunkSink` — the one
427
+ sanctioned `typing.Protocol`: a capability seam, not a stage contract; stage
428
+ contracts remain ABCs). Fusion always: dedup by `chunk.id`, filters fan out to
429
+ every sub-search, per-source rank attribution in `metadata["sources"]`.
430
+ `VectorStore` is named+typed multi-vector with `ensure_schema` create-or-validate,
431
+ `fetch(filters, limit)` (list values = membership), `update_vectors`. Classic
432
+ BM25 stays a mounted corpus-stats `LexicalIndex`; SPLADE-style sparse is a
433
+ `SparseEncoder` representation. `CachingEmbedder` is fingerprint-transparent with
434
+ separate passage/query namespaces. Bare LLM completion is a `Callable[[str], str]`
435
+ seam (`generator.complete`); do not invent a `completer` kind until a third
436
+ independent consumer demands it. Empty chains are the null objects
437
+ (`NoOpReranker`/`NoOpEnricher` are deleted — do not recreate them). Do not
438
+ re-litigate: no retriever write-side, no `chunk.vectors`, no `QueryTransform`
439
+ kind, no DAG framework, no capability negotiation. The architecture's acceptance
440
+ test: the tuner must index once and enumerate retrieval/refinement strategies
441
+ with ZERO tuner-motivated parameters on `ChunkIndex` — if one appears, stop and
442
+ write DR-0002.
443
+
444
+ Kinds (v2): `vector_store` (renamed from `store`), `embedder`, `sparse_encoder`,
445
+ `lexical_index`, `index` (ChunkIndex — aggregate, wired from instances, not
446
+ registry-built), `retriever`, `refiner` (replaces `reranker`), `enricher`,
447
+ `generator`, `blob_store`, plus ingestion kinds. `ChunkSink` is a Protocol, not
448
+ a kind.
449
+
450
+ ---
451
+
452
+ ## 8. Coding conventions
453
+
454
+ - Python >= 3.10, `from __future__ import annotations` everywhere. Full type
455
+ hints. Line length 88 (ruff configured).
456
+ - **Core stays stdlib-only.** Contracts and configs are plain `@dataclass`
457
+ (pydantic was considered and rejected for the hot path; wrapping at app
458
+ edges is fine for users, not for us).
459
+ - Lazy vendor imports, exact idiom: import inside the method that needs it,
460
+ wrap `ImportError`, raise a toolkit error naming the extra:
461
+ `"... requires 'docling'. Install with: pip install 'rag-blocks[docling]'"`.
462
+ - Errors: single root `RagToolkitError`; raise narrow subclasses with context
463
+ (`ParseError(msg, source_uri=..., page_number=...)`) — "PDF failed" is
464
+ useless in a 10k-document batch. Fail fast at construction (unknown engine
465
+ name explodes in `__init__`, not on page 500). Never swallow exceptions
466
+ without normalizing them into toolkit errors with `from exc`.
467
+ - Docstrings explain **WHY and the decision/tradeoff**, not what the code
468
+ restates. Pattern names are cited explicitly. Every module opens with a
469
+ design-rationale docstring — match `contracts.py`/`docling_parser.py` style.
470
+ - Naming: stages are agent nouns (`Parser`, `Chunker`, `Embedder`), artifacts
471
+ plain nouns (`Source`, `Page`, `Chunk`), routers/facades prefixed `Auto`,
472
+ policies are str-Enums, test doubles prefixed `Fake`. `kind` is the stage
473
+ slot, `name` the implementation.
474
+ - Built-ins register via module import side effect; wire new modules into the
475
+ subsystem `__init__.py` and export in `__all__` (top-level `rag_blocks/
476
+ __init__.py` for user-facing names).
477
+ - Bump the component `version` on ANY behavioral change (cache invalidation).
478
+ - Public API discipline: pre-1.0, adding is cheap, removing is a breaking
479
+ event — keep surface small; when in doubt, keep it private (`_helper`).
480
+
481
+ ---
482
+
483
+ ## 9. Testing rules (non-negotiable)
484
+
485
+ - Framework: pytest; layout mirrors the package under `tests/`. Default run
486
+ is **fast and hermetic** — zero vendor deps, zero network, zero keys
487
+ (`addopts = "-m 'not integration'"`). Real-stack tests live in
488
+ `tests/integration/`, marked `integration`, opt-in
489
+ (`rag_blocks_TEST_PDF=... pytest -m integration`).
490
+ - **Tests ship WITH the feature, same PR.** A component without tests does
491
+ not exist.
492
+ - Test OUR logic, not vendors': extract pure functions (`_plan_segments`,
493
+ `_windows`) and test them directly; verify dispatch with `monkeypatch`;
494
+ inject `FakeOcrEngine` (`tests/helpers.py`) through the same registry seam
495
+ production uses. Never mock what you can fake through a designed seam.
496
+ - Every stage gets contract checks in `tests/contract_checks.py`; every
497
+ implementation calls them. Key existing invariants to never break:
498
+ `doc.markdown[span.start:span.end] == page.markdown`; secret redaction +
499
+ key-rotation keeps fingerprint stable; the PDF dispatch matrix; the UTF-8
500
+ multibyte-across-block-boundary regression in the plaintext parser.
501
+ - Style: table-driven `parametrize` for many-cases-one-behavior;
502
+ `pytest.raises(..., match=...)` for error paths; `tmp_path` for files;
503
+ fresh `Registry()` instances in registry tests (never assert exact contents
504
+ of the global registry — other test modules register fakes into it).
505
+ - `scripts/mini_pytest.py` is a fallback runner for offline environments
506
+ (emulates the pytest subset this suite uses). It is intentionally frozen:
507
+ **extend the tests, never the shim**; if a new pytest feature is needed and
508
+ the shim can't run it, the shim loses.
509
+ - CI (`.github/workflows/ci.yml`): ruff + pytest w/ coverage on 3.10–3.12.
510
+ Keep it green; run lint/type/tests locally before proposing changes.
511
+
512
+ ---
513
+
514
+ ## 10. Definition of Done — any new component
515
+
516
+ 1. Class with `kind`, `name`, `version`, optional nested `Config` dataclass;
517
+ registered with `@registry.register`; wired into subsystem `__init__.py`.
518
+ 2. Implements exactly the stage ABC primitive(s); depends only on
519
+ `core.contracts` + its own stage's abstractions (Dependency Inversion —
520
+ e.g., parsers depend on `OcrEngine`, never on Mistral).
521
+ 3. Vendor deps: lazy import + pyproject extra + actionable ImportError.
522
+ 4. Credential fields named for auto-redaction; env-var fallback per §7.4.
523
+ 5. Pure function of (config, inputs); heavy resources cached on the instance.
524
+ 6. Streaming discipline if data-producing; provenance fields populated.
525
+ 7. Hermetic tests incl. the stage contract check; integration test only if a
526
+ real vendor is involved (marked, env-gated).
527
+ 8. Docstrings state the pattern and the why; README/ARCHITECTURE touched if
528
+ user-visible.
529
+ 9. `ruff check` clean, `mypy` clean, full suite green.
530
+
531
+ ## 11. Roadmap (build in this order)
532
+
533
+ v0.2 chunking (`fixed`, `markdown-aware`, `chonkie` per §7.1) + `Chunk`
534
+ char-offset fields + chunker contract checks → v0.2+ thin
535
+ `IndexingPipeline`/`QueryPipeline`/`RagPipeline` (dumb for-loops over
536
+ generators + tracing hooks; intelligence in components, wiring in config) →
537
+ v0.3 embedding (`bge-m3` first, `embed_query` separate from `embed_texts` —
538
+ instruction-prefix asymmetry) + storage (`memory` store for tests/tuning,
539
+ `qdrant`, `LocalBlobStore`/`MinioBlobStore` per §7.2) → v0.4 retrieval
540
+ (`dense`, `bm25`, `hybrid` Composite w/ RRF) + reranking (`bge-reranker`,
541
+ `noop`) → v0.5 generation (context packing, token budget, citation markers
542
+ resolved through chunk→page provenance) → **v0.6 the DR-0001 v2 restructure
543
+ (§7.6): `ChunkIndex` aggregate + multi-vector `vector_store`; retrieval collapses
544
+ into the composition axis (`index`/`hybrid`/`fusion`/`multi-query`/`hyde`) and
545
+ reranking dissolves into the `refiner` chain; `CachingEmbedder`; `sparse_encoder`
546
+ interface** → v0.7 evaluation (IR metrics + `RagasEvaluator` per §7.3) + tuning
547
+ (SearchSpace, grid/random tuners, trial log, leaderboard w/ marginal analysis).
548
+ Each milestone ships **at least two interchangeable implementations per stage** —
549
+ swapping is the proof the library works.
550
+
551
+ ## 12. When uncertain, decide like this
552
+
553
+ Does it belong in `core`? Only if every stage needs it AND it's stdlib-only —
554
+ otherwise it's a component. New dependency? → optional extra + lazy import,
555
+ core stays clean. Two components share logic? → extract a helper module or a
556
+ value object; do NOT create an inheritance link between stages. Tempted to add
557
+ a parameter to a contract dataclass? → prefer `metadata` first; promote to a
558
+ field only when multiple stages rely on it (as done for char offsets).
559
+ Behavior change? → bump `version`, update tests, note it. Can't test it
560
+ cleanly? → the design is wrong; add a seam or extract a pure function.
561
+ Ambiguity between this file, ARCHITECTURE.md, and code? → flag it to the
562
+ owner; don't guess silently. And keep the owner's bar in mind: he is learning
563
+ from this codebase — every shortcut you take teaches him the wrong lesson.