Angshul commited on
Commit
ce3f6c1
·
verified ·
1 Parent(s): c87d6ef

Upload 22 files

Browse files
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2026
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
MIGRATION_NOTES.md ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ # Migration notes
2
+
3
+ The source material contained two different concepts under the same Bayesian/Probabilistic LangChain umbrella: an SMC tool runtime and a heuristic beam framework (`p_langchain`). The beam framework was not retained as Bayesian code because its softmax score was not a posterior and its multi-proposer executor overwrote earlier proposers. The useful SMC idea is retained in `smc.py` with proposal correction and safe final weighting. The Colab RAG export was replaced by a package-native RAG implementation and reproducible benchmark.
README.md CHANGED
@@ -1,3 +1,113 @@
1
  ---
2
- license: fair-noncommercial-research-license
 
 
 
 
 
 
 
3
  ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ license: mit
3
+ tags:
4
+ - langchain
5
+ - rag
6
+ - bayesian
7
+ - probabilistic
8
+ - uncertainty
9
+ - retrieval
10
  ---
11
+
12
+ # Bayesian LangChain RAG
13
+
14
+ A corrected, Hugging Face-ready implementation of uncertainty-aware retrieval augmented generation built on **LangChain Core**.
15
+
16
+ This repository replaces the earlier heuristic "tool succeeded = high likelihood" and beam-style scoring with an explicit generalized-Bayesian retrieval posterior. For a query `q` and candidate document `d`,
17
+
18
+ `p(d | q) ∝ p(d) (similarity(q,d) + eps)^tau`
19
+
20
+ where the prior `p(d)` is built from a Beta source-reliability mean, source authority, and freshness metadata. The similarity term is deliberately described as a **generalized-Bayes/Gibbs likelihood**, not as a literal language-model likelihood.
21
+
22
+ ## What was corrected
23
+
24
+ - Removed notebook/Colab shell commands and hard-coded paths from library code.
25
+ - Removed the non-Bayesian `p_langchain` beam scorer from the Bayesian runtime.
26
+ - Replaced deterministic identical-particle behavior with an importance-corrected SMC utility for workflows that need particles.
27
+ - Added proposal-density correction `log target - log q` to SMC weights.
28
+ - Fixed all-`-inf` log-weight normalization.
29
+ - Preserved final posterior weights by forbidding final-step resampling.
30
+ - Added a real `BaseRetriever` implementation and LangChain `Runnable` entry point.
31
+ - RAG now reasons over retrieved evidence quality rather than rewarding any successful tool call.
32
+ - Added explicit abstention for missing evidence and unresolved near-equal conflicts.
33
+ - Added citation-grounded answer synthesis and a reproducible end-to-end RAG benchmark.
34
+
35
+ ## Install
36
+
37
+ ```bash
38
+ pip install -e .
39
+ ```
40
+
41
+ No GPU is required. The default evaluation generator is extractive and grounded. Any LangChain-compatible model can be supplied for generative answering.
42
+
43
+ ## Quick start
44
+
45
+ ```python
46
+ from bayesian_langchain import DocumentRecord, BayesianIndex, BayesianRAG
47
+
48
+ docs = [
49
+ DocumentRecord(
50
+ id="official",
51
+ title="Current portal policy",
52
+ text="The current HR portal is PeopleHub.",
53
+ source="official",
54
+ metadata={"reliability_alpha": 18, "reliability_beta": 2, "authority": 0.98, "freshness": 1.0},
55
+ ),
56
+ DocumentRecord(
57
+ id="legacy",
58
+ title="HR portal guide",
59
+ text="The HR portal is WorkSphere.",
60
+ source="legacy-cache",
61
+ metadata={"reliability_alpha": 6, "reliability_beta": 6, "authority": 0.45, "freshness": 0.18},
62
+ ),
63
+ ]
64
+
65
+ rag = BayesianRAG(BayesianIndex(docs))
66
+ print(rag.invoke("What is the current HR portal?"))
67
+ ```
68
+
69
+ `BayesianRAG.as_runnable()` returns a standard LangChain `RunnableLambda`.
70
+
71
+ ## RAG validation
72
+
73
+ Run:
74
+
75
+ ```bash
76
+ PYTHONPATH=src:. python evaluation/rag_benchmark.py
77
+ ```
78
+
79
+ The committed result is in `artifacts/rag_benchmark_results.json`.
80
+
81
+ | Suite | System | Recall@1 | MRR | Answer accuracy | Abstention accuracy | End-to-end accuracy |
82
+ |---|---|---:|---:|---:|---:|---:|
83
+ | Clean RAG (8 QA) | Bayesian | 1.000 | 1.000 | 1.000 | n/a | 1.000 |
84
+ | Clean RAG (8 QA) | Plain TF-IDF | 1.000 | 1.000 | 1.000 | n/a | 1.000 |
85
+ | Conflict/missing RAG (18 QA) | Bayesian | 1.000 | 1.000 | 1.000 | 1.000 | 1.000 |
86
+ | Conflict/missing RAG (18 QA) | Plain TF-IDF | 0.250 | 0.611 | 0.250 | 0.333 | 0.278 |
87
+
88
+ The conflict suite deliberately contains stale high-overlap documents, current authoritative documents, questions with no supporting evidence, and equal-authority contradictions. The results are **controlled repository tests**, not a claim of universal RAG accuracy.
89
+
90
+ ### Framework-level LangChain RAG test
91
+
92
+ The repository also contains `evaluation/framework_rag_benchmark.py`, which routes every case through the real LangChain path:
93
+
94
+ `BayesianRetriever(BaseRetriever) -> BayesianRAG.as_runnable() -> grounded generation/abstention`.
95
+
96
+ It was validated against **langchain-core 1.6.11**. Results: **8/8 clean cases** and **18/18 conflict/missing-evidence cases** passed end-to-end. The committed output is `artifacts/framework_rag_benchmark_results.json`.
97
+
98
+ Run it with:
99
+
100
+ ```bash
101
+ PYTHONPATH=src:. python evaluation/framework_rag_benchmark.py
102
+ ```
103
+
104
+ ## Layout
105
+
106
+ - `src/bayesian_langchain/core.py` — posterior retrieval, abstention, grounded answerer
107
+ - `src/bayesian_langchain/retriever.py` — LangChain `BaseRetriever`
108
+ - `src/bayesian_langchain/rag.py` — end-to-end LangChain RAG and Runnable
109
+ - `src/bayesian_langchain/smc.py` — corrected importance-weighted SMC utility
110
+ - `evaluation/rag_benchmark.py` — controlled retrieval/grounding benchmark
111
+ - `evaluation/framework_rag_benchmark.py` — real LangChain `BaseRetriever`/Runnable RAG benchmark
112
+ - `tests/` — regression and integration tests
113
+ - `VALIDATION.md` — exact framework/version validation record
VALIDATION.md ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Validation
2
+
3
+ Validated on Python 3.13.5 against the supplied `langchain-core 1.6.11` source archive.
4
+
5
+ - Repository tests: 8 passed.
6
+ - Framework RAG suite: 8/8 clean cases passed.
7
+ - Framework RAG suite: 18/18 stale/conflicting/missing-evidence cases passed.
8
+ - Package wheel build: passed.
9
+ - `BayesianRetriever` is a real `langchain_core.retrievers.BaseRetriever` subclass.
10
+ - End-to-end evaluation invokes the retriever through LangChain and the RAG system through `RunnableLambda`; retrieval is not recomputed outside the LangChain result path.
11
+
12
+ The sandbox cannot resolve PyPI. For framework-source validation only, a test harness supplied minimal compatibility modules for unavailable peripheral dependencies such as LangSmith tracing. Those test shims are not part of this repository and do not replace the RAG implementation.
artifacts/framework_rag_benchmark_results.json ADDED
@@ -0,0 +1,266 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "framework": "langchain-core",
3
+ "execution": "BayesianRetriever(BaseRetriever) -> BayesianRAG RunnableLambda -> grounded answer/abstention",
4
+ "clean": {
5
+ "n_cases": 8,
6
+ "passed": 8,
7
+ "accuracy": 1.0,
8
+ "rows": [
9
+ {
10
+ "id": "c1",
11
+ "ok": true,
12
+ "answer": "The observatory opens to visitors at 9 AM every Saturday. [C1]",
13
+ "citations": [
14
+ "C1"
15
+ ],
16
+ "abstained": false,
17
+ "top_doc": "C1"
18
+ },
19
+ {
20
+ "id": "c2",
21
+ "ok": true,
22
+ "answer": "The chemistry safety briefing is held in Hall 7. [C2]",
23
+ "citations": [
24
+ "C2"
25
+ ],
26
+ "abstained": false,
27
+ "top_doc": "C2"
28
+ },
29
+ {
30
+ "id": "c3",
31
+ "ok": true,
32
+ "answer": "Library books may be borrowed for 21 days. [C3]",
33
+ "citations": [
34
+ "C3"
35
+ ],
36
+ "abstained": false,
37
+ "top_doc": "C3"
38
+ },
39
+ {
40
+ "id": "c4",
41
+ "ok": true,
42
+ "answer": "The robotics club meets on Thursday evenings. [C4]",
43
+ "citations": [
44
+ "C4"
45
+ ],
46
+ "abstained": false,
47
+ "top_doc": "C4"
48
+ },
49
+ {
50
+ "id": "c5",
51
+ "ok": true,
52
+ "answer": "Visitor parking is located beside Gate 3. [C5]",
53
+ "citations": [
54
+ "C5"
55
+ ],
56
+ "abstained": false,
57
+ "top_doc": "C5"
58
+ },
59
+ {
60
+ "id": "c6",
61
+ "ok": true,
62
+ "answer": "The campus shuttle begins service at 6:30 AM. [C6]",
63
+ "citations": [
64
+ "C6"
65
+ ],
66
+ "abstained": false,
67
+ "top_doc": "C6"
68
+ },
69
+ {
70
+ "id": "c7",
71
+ "ok": true,
72
+ "answer": "The data science seminar carries two academic credits. [C7]",
73
+ "citations": [
74
+ "C7"
75
+ ],
76
+ "abstained": false,
77
+ "top_doc": "C7"
78
+ },
79
+ {
80
+ "id": "c8",
81
+ "ok": true,
82
+ "answer": "The emergency assembly point is the North Lawn. [C8]",
83
+ "citations": [
84
+ "C8"
85
+ ],
86
+ "abstained": false,
87
+ "top_doc": "C8"
88
+ }
89
+ ]
90
+ },
91
+ "conflict": {
92
+ "n_cases": 18,
93
+ "passed": 18,
94
+ "accuracy": 1.0,
95
+ "rows": [
96
+ {
97
+ "id": "q1",
98
+ "ok": true,
99
+ "answer": "All employee self-service tasks, including leave requests and payslips, are now handled in PeopleHub. [OFF-HR-PORTAL]",
100
+ "citations": [
101
+ "OFF-HR-PORTAL"
102
+ ],
103
+ "abstained": false,
104
+ "top_doc": "OFF-HR-PORTAL"
105
+ },
106
+ {
107
+ "id": "q2",
108
+ "ok": true,
109
+ "answer": "The 2026 employee benefits policy grants 18 paid leave days per calendar year. [OFF-LEAVE]",
110
+ "citations": [
111
+ "OFF-LEAVE"
112
+ ],
113
+ "abstained": false,
114
+ "top_doc": "OFF-LEAVE"
115
+ },
116
+ {
117
+ "id": "q3",
118
+ "ok": true,
119
+ "answer": "Under the active flexible-work policy, employees may work remotely up to two days per week with manager approval. [OFF-REMOTE]",
120
+ "citations": [
121
+ "OFF-REMOTE"
122
+ ],
123
+ "abstained": false,
124
+ "top_doc": "OFF-REMOTE"
125
+ },
126
+ {
127
+ "id": "q4",
128
+ "ok": true,
129
+ "answer": "Payroll publishes salary slips by the fifth working day of each month. [OFF-PAYSLIP]",
130
+ "citations": [
131
+ "OFF-PAYSLIP"
132
+ ],
133
+ "abstained": false,
134
+ "top_doc": "OFF-PAYSLIP"
135
+ },
136
+ {
137
+ "id": "q5",
138
+ "ok": true,
139
+ "answer": "The security baseline now requires WireGuard for the standard corporate VPN profile. [OFF-VPN]",
140
+ "citations": [
141
+ "OFF-VPN"
142
+ ],
143
+ "abstained": false,
144
+ "top_doc": "OFF-VPN"
145
+ },
146
+ {
147
+ "id": "q6",
148
+ "ok": true,
149
+ "answer": "Production database backups have a retention period of 30 days. [OFF-BACKUP]",
150
+ "citations": [
151
+ "OFF-BACKUP"
152
+ ],
153
+ "abstained": false,
154
+ "top_doc": "OFF-BACKUP"
155
+ },
156
+ {
157
+ "id": "q7",
158
+ "ok": true,
159
+ "answer": "The current gateway quota permits 120 requests per minute per token. [OFF-RATE]",
160
+ "citations": [
161
+ "OFF-RATE"
162
+ ],
163
+ "abstained": false,
164
+ "top_doc": "OFF-RATE"
165
+ },
166
+ {
167
+ "id": "q8",
168
+ "ok": true,
169
+ "answer": "The production primary region is ap-south-1; disaster recovery remains in a separate region. [OFF-REGION]",
170
+ "citations": [
171
+ "OFF-REGION"
172
+ ],
173
+ "abstained": false,
174
+ "top_doc": "OFF-REGION"
175
+ },
176
+ {
177
+ "id": "q9",
178
+ "ok": true,
179
+ "answer": "For domestic business travel, reimbursable meals are capped at INR 4,500 per day. [OFF-EXPENSE]",
180
+ "citations": [
181
+ "OFF-EXPENSE"
182
+ ],
183
+ "abstained": false,
184
+ "top_doc": "OFF-EXPENSE"
185
+ },
186
+ {
187
+ "id": "q10",
188
+ "ok": true,
189
+ "answer": "Expense claims must be submitted within 15 days of the transaction date. [OFF-REIMB]",
190
+ "citations": [
191
+ "OFF-REIMB"
192
+ ],
193
+ "abstained": false,
194
+ "top_doc": "OFF-REIMB"
195
+ },
196
+ {
197
+ "id": "q11",
198
+ "ok": true,
199
+ "answer": "Approved business travel must be booked through TripDesk. [OFF-TRAVEL]",
200
+ "citations": [
201
+ "OFF-TRAVEL"
202
+ ],
203
+ "abstained": false,
204
+ "top_doc": "OFF-TRAVEL"
205
+ },
206
+ {
207
+ "id": "q12",
208
+ "ok": true,
209
+ "answer": "Procurement review is mandatory for purchases above INR 50,000. [OFF-PROC]",
210
+ "citations": [
211
+ "OFF-PROC"
212
+ ],
213
+ "abstained": false,
214
+ "top_doc": "OFF-PROC"
215
+ },
216
+ {
217
+ "id": "q13",
218
+ "ok": true,
219
+ "answer": "I do not know based on the provided documents.",
220
+ "citations": [],
221
+ "abstained": true,
222
+ "top_doc": "LEG-RATE"
223
+ },
224
+ {
225
+ "id": "q14",
226
+ "ok": true,
227
+ "answer": "I do not know based on the provided documents.",
228
+ "citations": [],
229
+ "abstained": true,
230
+ "top_doc": "OFF-HR-PORTAL"
231
+ },
232
+ {
233
+ "id": "q15",
234
+ "ok": true,
235
+ "answer": "I do not know based on the provided documents.",
236
+ "citations": [],
237
+ "abstained": true,
238
+ "top_doc": "OFF-HR-PORTAL"
239
+ },
240
+ {
241
+ "id": "q16",
242
+ "ok": true,
243
+ "answer": "I do not know based on the provided documents.",
244
+ "citations": [],
245
+ "abstained": true,
246
+ "top_doc": "OFF-REIMB"
247
+ },
248
+ {
249
+ "id": "q17",
250
+ "ok": true,
251
+ "answer": "I do not know based on the provided documents.",
252
+ "citations": [],
253
+ "abstained": true,
254
+ "top_doc": "OFF-LAB-A"
255
+ },
256
+ {
257
+ "id": "q18",
258
+ "ok": true,
259
+ "answer": "I do not know based on the provided documents.",
260
+ "citations": [],
261
+ "abstained": true,
262
+ "top_doc": "OFF-OFFICE-A"
263
+ }
264
+ ]
265
+ }
266
+ }
artifacts/rag_benchmark_results.json ADDED
@@ -0,0 +1,784 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "benchmark": "controlled_rag_v2",
3
+ "methodology": "Two end-to-end suites. CLEAN checks ordinary retrieval+grounded answer synthesis. CONFLICT adds stale authoritative conflicts, missing evidence, and unresolved equal-authority contradictions. The generator is extractive and citation-grounded so the evaluation isolates retrieval and uncertainty behavior without an external LLM confound.",
4
+ "clean": {
5
+ "bayesian": {
6
+ "system": "bayesian",
7
+ "n_cases": 8,
8
+ "answerable_cases": 8,
9
+ "abstention_cases": 0,
10
+ "recall_at_1": 1.0,
11
+ "recall_at_3": 1.0,
12
+ "mrr": 1.0,
13
+ "end_to_end_accuracy": 1.0,
14
+ "grounding_accuracy": 1.0,
15
+ "abstention_accuracy": null,
16
+ "answer_accuracy_on_answerable": 1.0,
17
+ "top_posterior_brier": 2.8128197163138293e-05
18
+ },
19
+ "baseline": {
20
+ "system": "plain_tfidf",
21
+ "n_cases": 8,
22
+ "answerable_cases": 8,
23
+ "abstention_cases": 0,
24
+ "recall_at_1": 1.0,
25
+ "recall_at_3": 1.0,
26
+ "mrr": 1.0,
27
+ "end_to_end_accuracy": 1.0,
28
+ "grounding_accuracy": 1.0,
29
+ "abstention_accuracy": null,
30
+ "answer_accuracy_on_answerable": 1.0,
31
+ "top_posterior_brier": 0.0
32
+ },
33
+ "bayesian_cases": [
34
+ {
35
+ "id": "c1",
36
+ "query": "When does the observatory open on Saturday?",
37
+ "expected_abstain": false,
38
+ "top_doc": "C1",
39
+ "retrieval_score": 0.552814,
40
+ "top_posterior": 0.991539,
41
+ "answer": "The observatory opens to visitors at 9 AM every Saturday. [C1]",
42
+ "citations": [
43
+ "C1"
44
+ ],
45
+ "correct": true,
46
+ "grounded": true
47
+ },
48
+ {
49
+ "id": "c2",
50
+ "query": "Where is the chemistry safety briefing held?",
51
+ "expected_abstain": false,
52
+ "top_doc": "C2",
53
+ "retrieval_score": 0.825787,
54
+ "top_posterior": 0.996101,
55
+ "answer": "The chemistry safety briefing is held in Hall 7. [C2]",
56
+ "citations": [
57
+ "C2"
58
+ ],
59
+ "correct": true,
60
+ "grounded": true
61
+ },
62
+ {
63
+ "id": "c3",
64
+ "query": "How long may library books be borrowed?",
65
+ "expected_abstain": false,
66
+ "top_doc": "C3",
67
+ "retrieval_score": 0.683723,
68
+ "top_posterior": 0.994378,
69
+ "answer": "Library books may be borrowed for 21 days. [C3]",
70
+ "citations": [
71
+ "C3"
72
+ ],
73
+ "correct": true,
74
+ "grounded": true
75
+ },
76
+ {
77
+ "id": "c4",
78
+ "query": "When does the robotics club meet?",
79
+ "expected_abstain": false,
80
+ "top_doc": "C4",
81
+ "retrieval_score": 0.742488,
82
+ "top_posterior": 0.995207,
83
+ "answer": "The robotics club meets on Thursday evenings. [C4]",
84
+ "citations": [
85
+ "C4"
86
+ ],
87
+ "correct": true,
88
+ "grounded": true
89
+ },
90
+ {
91
+ "id": "c5",
92
+ "query": "Where is visitor parking?",
93
+ "expected_abstain": false,
94
+ "top_doc": "C5",
95
+ "retrieval_score": 0.79521,
96
+ "top_posterior": 0.995804,
97
+ "answer": "Visitor parking is located beside Gate 3. [C5]",
98
+ "citations": [
99
+ "C5"
100
+ ],
101
+ "correct": true,
102
+ "grounded": true
103
+ },
104
+ {
105
+ "id": "c6",
106
+ "query": "What time does the campus shuttle begin service?",
107
+ "expected_abstain": false,
108
+ "top_doc": "C6",
109
+ "retrieval_score": 0.769605,
110
+ "top_posterior": 0.995529,
111
+ "answer": "The campus shuttle begins service at 6:30 AM. [C6]",
112
+ "citations": [
113
+ "C6"
114
+ ],
115
+ "correct": true,
116
+ "grounded": true
117
+ },
118
+ {
119
+ "id": "c7",
120
+ "query": "How many credits is the data science seminar worth?",
121
+ "expected_abstain": false,
122
+ "top_doc": "C7",
123
+ "retrieval_score": 0.836653,
124
+ "top_posterior": 0.996199,
125
+ "answer": "The data science seminar carries two academic credits. [C7]",
126
+ "citations": [
127
+ "C7"
128
+ ],
129
+ "correct": true,
130
+ "grounded": true
131
+ },
132
+ {
133
+ "id": "c8",
134
+ "query": "Where is the emergency assembly point?",
135
+ "expected_abstain": false,
136
+ "top_doc": "C8",
137
+ "retrieval_score": 0.683723,
138
+ "top_posterior": 0.994378,
139
+ "answer": "The emergency assembly point is the North Lawn. [C8]",
140
+ "citations": [
141
+ "C8"
142
+ ],
143
+ "correct": true,
144
+ "grounded": true
145
+ }
146
+ ],
147
+ "baseline_cases": [
148
+ {
149
+ "id": "c1",
150
+ "query": "When does the observatory open on Saturday?",
151
+ "expected_abstain": false,
152
+ "top_doc": "C1",
153
+ "retrieval_score": 0.552814,
154
+ "top_posterior": 1.0,
155
+ "answer": "The observatory opens to visitors at 9 AM every Saturday. [C1]",
156
+ "citations": [
157
+ "C1"
158
+ ],
159
+ "correct": true,
160
+ "grounded": true
161
+ },
162
+ {
163
+ "id": "c2",
164
+ "query": "Where is the chemistry safety briefing held?",
165
+ "expected_abstain": false,
166
+ "top_doc": "C2",
167
+ "retrieval_score": 0.825787,
168
+ "top_posterior": 1.0,
169
+ "answer": "The chemistry safety briefing is held in Hall 7. [C2]",
170
+ "citations": [
171
+ "C2"
172
+ ],
173
+ "correct": true,
174
+ "grounded": true
175
+ },
176
+ {
177
+ "id": "c3",
178
+ "query": "How long may library books be borrowed?",
179
+ "expected_abstain": false,
180
+ "top_doc": "C3",
181
+ "retrieval_score": 0.683723,
182
+ "top_posterior": 1.0,
183
+ "answer": "Library books may be borrowed for 21 days. [C3]",
184
+ "citations": [
185
+ "C3"
186
+ ],
187
+ "correct": true,
188
+ "grounded": true
189
+ },
190
+ {
191
+ "id": "c4",
192
+ "query": "When does the robotics club meet?",
193
+ "expected_abstain": false,
194
+ "top_doc": "C4",
195
+ "retrieval_score": 0.742488,
196
+ "top_posterior": 1.0,
197
+ "answer": "The robotics club meets on Thursday evenings. [C4]",
198
+ "citations": [
199
+ "C4"
200
+ ],
201
+ "correct": true,
202
+ "grounded": true
203
+ },
204
+ {
205
+ "id": "c5",
206
+ "query": "Where is visitor parking?",
207
+ "expected_abstain": false,
208
+ "top_doc": "C5",
209
+ "retrieval_score": 0.79521,
210
+ "top_posterior": 1.0,
211
+ "answer": "Visitor parking is located beside Gate 3. [C5]",
212
+ "citations": [
213
+ "C5"
214
+ ],
215
+ "correct": true,
216
+ "grounded": true
217
+ },
218
+ {
219
+ "id": "c6",
220
+ "query": "What time does the campus shuttle begin service?",
221
+ "expected_abstain": false,
222
+ "top_doc": "C6",
223
+ "retrieval_score": 0.769605,
224
+ "top_posterior": 1.0,
225
+ "answer": "The campus shuttle begins service at 6:30 AM. [C6]",
226
+ "citations": [
227
+ "C6"
228
+ ],
229
+ "correct": true,
230
+ "grounded": true
231
+ },
232
+ {
233
+ "id": "c7",
234
+ "query": "How many credits is the data science seminar worth?",
235
+ "expected_abstain": false,
236
+ "top_doc": "C7",
237
+ "retrieval_score": 0.836653,
238
+ "top_posterior": 1.0,
239
+ "answer": "The data science seminar carries two academic credits. [C7]",
240
+ "citations": [
241
+ "C7"
242
+ ],
243
+ "correct": true,
244
+ "grounded": true
245
+ },
246
+ {
247
+ "id": "c8",
248
+ "query": "Where is the emergency assembly point?",
249
+ "expected_abstain": false,
250
+ "top_doc": "C8",
251
+ "retrieval_score": 0.683723,
252
+ "top_posterior": 1.0,
253
+ "answer": "The emergency assembly point is the North Lawn. [C8]",
254
+ "citations": [
255
+ "C8"
256
+ ],
257
+ "correct": true,
258
+ "grounded": true
259
+ }
260
+ ]
261
+ },
262
+ "conflict": {
263
+ "bayesian": {
264
+ "system": "bayesian",
265
+ "n_cases": 18,
266
+ "answerable_cases": 12,
267
+ "abstention_cases": 6,
268
+ "recall_at_1": 1.0,
269
+ "recall_at_3": 1.0,
270
+ "mrr": 1.0,
271
+ "end_to_end_accuracy": 1.0,
272
+ "grounding_accuracy": 1.0,
273
+ "abstention_accuracy": 1.0,
274
+ "answer_accuracy_on_answerable": 1.0,
275
+ "top_posterior_brier": 0.09485010369519668
276
+ },
277
+ "baseline": {
278
+ "system": "plain_tfidf",
279
+ "n_cases": 18,
280
+ "answerable_cases": 12,
281
+ "abstention_cases": 6,
282
+ "recall_at_1": 0.25,
283
+ "recall_at_3": 1.0,
284
+ "mrr": 0.611111111111111,
285
+ "end_to_end_accuracy": 0.2777777777777778,
286
+ "grounding_accuracy": 1.0,
287
+ "abstention_accuracy": 0.3333333333333333,
288
+ "answer_accuracy_on_answerable": 0.25,
289
+ "top_posterior_brier": 0.26943925109630584
290
+ },
291
+ "bayesian_cases": [
292
+ {
293
+ "id": "q1",
294
+ "query": "What is the current HR portal for leave requests and payslips?",
295
+ "expected_abstain": false,
296
+ "top_doc": "OFF-HR-PORTAL",
297
+ "retrieval_score": 0.221707,
298
+ "top_posterior": 0.419857,
299
+ "answer": "All employee self-service tasks, including leave requests and payslips, are now handled in PeopleHub. [OFF-HR-PORTAL]",
300
+ "citations": [
301
+ "OFF-HR-PORTAL"
302
+ ],
303
+ "correct": true,
304
+ "grounded": true
305
+ },
306
+ {
307
+ "id": "q2",
308
+ "query": "How many paid leave days do employees get per year under the current policy?",
309
+ "expected_abstain": false,
310
+ "top_doc": "OFF-LEAVE",
311
+ "retrieval_score": 0.366908,
312
+ "top_posterior": 0.675297,
313
+ "answer": "The 2026 employee benefits policy grants 18 paid leave days per calendar year. [OFF-LEAVE]",
314
+ "citations": [
315
+ "OFF-LEAVE"
316
+ ],
317
+ "correct": true,
318
+ "grounded": true
319
+ },
320
+ {
321
+ "id": "q3",
322
+ "query": "How many remote work days per week are currently allowed with manager approval?",
323
+ "expected_abstain": false,
324
+ "top_doc": "OFF-REMOTE",
325
+ "retrieval_score": 0.383162,
326
+ "top_posterior": 0.598358,
327
+ "answer": "Under the active flexible-work policy, employees may work remotely up to two days per week with manager approval. [OFF-REMOTE]",
328
+ "citations": [
329
+ "OFF-REMOTE"
330
+ ],
331
+ "correct": true,
332
+ "grounded": true
333
+ },
334
+ {
335
+ "id": "q4",
336
+ "query": "By which working day are salary slips published each month?",
337
+ "expected_abstain": false,
338
+ "top_doc": "OFF-PAYSLIP",
339
+ "retrieval_score": 0.457118,
340
+ "top_posterior": 0.8609,
341
+ "answer": "Payroll publishes salary slips by the fifth working day of each month. [OFF-PAYSLIP]",
342
+ "citations": [
343
+ "OFF-PAYSLIP"
344
+ ],
345
+ "correct": true,
346
+ "grounded": true
347
+ },
348
+ {
349
+ "id": "q5",
350
+ "query": "Which VPN protocol is required by the current corporate security baseline?",
351
+ "expected_abstain": false,
352
+ "top_doc": "OFF-VPN",
353
+ "retrieval_score": 0.514507,
354
+ "top_posterior": 0.904463,
355
+ "answer": "The security baseline now requires WireGuard for the standard corporate VPN profile. [OFF-VPN]",
356
+ "citations": [
357
+ "OFF-VPN"
358
+ ],
359
+ "correct": true,
360
+ "grounded": true
361
+ },
362
+ {
363
+ "id": "q6",
364
+ "query": "What is the production database backup retention period?",
365
+ "expected_abstain": false,
366
+ "top_doc": "OFF-BACKUP",
367
+ "retrieval_score": 0.424384,
368
+ "top_posterior": 0.835659,
369
+ "answer": "Production database backups have a retention period of 30 days. [OFF-BACKUP]",
370
+ "citations": [
371
+ "OFF-BACKUP"
372
+ ],
373
+ "correct": true,
374
+ "grounded": true
375
+ },
376
+ {
377
+ "id": "q7",
378
+ "query": "What is the current public API rate limit per minute per token?",
379
+ "expected_abstain": false,
380
+ "top_doc": "OFF-RATE",
381
+ "retrieval_score": 0.260415,
382
+ "top_posterior": 0.600144,
383
+ "answer": "The current gateway quota permits 120 requests per minute per token. [OFF-RATE]",
384
+ "citations": [
385
+ "OFF-RATE"
386
+ ],
387
+ "correct": true,
388
+ "grounded": true
389
+ },
390
+ {
391
+ "id": "q8",
392
+ "query": "Which cloud region is primary for production?",
393
+ "expected_abstain": false,
394
+ "top_doc": "OFF-REGION",
395
+ "retrieval_score": 0.400734,
396
+ "top_posterior": 0.819994,
397
+ "answer": "The production primary region is ap-south-1; disaster recovery remains in a separate region. [OFF-REGION]",
398
+ "citations": [
399
+ "OFF-REGION"
400
+ ],
401
+ "correct": true,
402
+ "grounded": true
403
+ },
404
+ {
405
+ "id": "q9",
406
+ "query": "What is the domestic business travel meal expense cap per day?",
407
+ "expected_abstain": false,
408
+ "top_doc": "OFF-EXPENSE",
409
+ "retrieval_score": 0.391575,
410
+ "top_posterior": 0.520438,
411
+ "answer": "For domestic business travel, reimbursable meals are capped at INR 4,500 per day. [OFF-EXPENSE]",
412
+ "citations": [
413
+ "OFF-EXPENSE"
414
+ ],
415
+ "correct": true,
416
+ "grounded": true
417
+ },
418
+ {
419
+ "id": "q10",
420
+ "query": "Within how many days must expense reimbursement claims be submitted?",
421
+ "expected_abstain": false,
422
+ "top_doc": "OFF-REIMB",
423
+ "retrieval_score": 0.513156,
424
+ "top_posterior": 0.907517,
425
+ "answer": "Expense claims must be submitted within 15 days of the transaction date. [OFF-REIMB]",
426
+ "citations": [
427
+ "OFF-REIMB"
428
+ ],
429
+ "correct": true,
430
+ "grounded": true
431
+ },
432
+ {
433
+ "id": "q11",
434
+ "query": "Which portal must be used to book approved business travel?",
435
+ "expected_abstain": false,
436
+ "top_doc": "OFF-TRAVEL",
437
+ "retrieval_score": 0.580663,
438
+ "top_posterior": 0.832603,
439
+ "answer": "Approved business travel must be booked through TripDesk. [OFF-TRAVEL]",
440
+ "citations": [
441
+ "OFF-TRAVEL"
442
+ ],
443
+ "correct": true,
444
+ "grounded": true
445
+ },
446
+ {
447
+ "id": "q12",
448
+ "query": "Above what amount is procurement review mandatory?",
449
+ "expected_abstain": false,
450
+ "top_doc": "OFF-PROC",
451
+ "retrieval_score": 0.512226,
452
+ "top_posterior": 0.856608,
453
+ "answer": "Procurement review is mandatory for purchases above INR 50,000. [OFF-PROC]",
454
+ "citations": [
455
+ "OFF-PROC"
456
+ ],
457
+ "correct": true,
458
+ "grounded": true
459
+ },
460
+ {
461
+ "id": "q13",
462
+ "query": "What is the company dental insurance annual limit?",
463
+ "expected_abstain": true,
464
+ "top_doc": "LEG-RATE",
465
+ "retrieval_score": 0.325883,
466
+ "top_posterior": 0.829873,
467
+ "answer": "I do not know based on the provided documents.",
468
+ "citations": [],
469
+ "correct": true,
470
+ "grounded": true
471
+ },
472
+ {
473
+ "id": "q14",
474
+ "query": "What is the cafeteria breakfast price?",
475
+ "expected_abstain": true,
476
+ "top_doc": "OFF-HR-PORTAL",
477
+ "retrieval_score": 0.0,
478
+ "top_posterior": 0.248018,
479
+ "answer": "I do not know based on the provided documents.",
480
+ "citations": [],
481
+ "correct": true,
482
+ "grounded": true
483
+ },
484
+ {
485
+ "id": "q15",
486
+ "query": "What is the emergency satellite phone number?",
487
+ "expected_abstain": true,
488
+ "top_doc": "OFF-HR-PORTAL",
489
+ "retrieval_score": 0.0,
490
+ "top_posterior": 0.248018,
491
+ "answer": "I do not know based on the provided documents.",
492
+ "citations": [],
493
+ "correct": true,
494
+ "grounded": true
495
+ },
496
+ {
497
+ "id": "q16",
498
+ "query": "What is the annual gym membership reimbursement?",
499
+ "expected_abstain": true,
500
+ "top_doc": "OFF-REIMB",
501
+ "retrieval_score": 0.178672,
502
+ "top_posterior": 0.385158,
503
+ "answer": "I do not know based on the provided documents.",
504
+ "citations": [],
505
+ "correct": true,
506
+ "grounded": true
507
+ },
508
+ {
509
+ "id": "q17",
510
+ "query": "Which room is the graduate AI lab currently in?",
511
+ "expected_abstain": true,
512
+ "top_doc": "OFF-LAB-A",
513
+ "retrieval_score": 0.752053,
514
+ "top_posterior": 0.499378,
515
+ "answer": "I do not know based on the provided documents.",
516
+ "citations": [],
517
+ "correct": true,
518
+ "grounded": true
519
+ },
520
+ {
521
+ "id": "q18",
522
+ "query": "When are Professor Sen's office hours?",
523
+ "expected_abstain": true,
524
+ "top_doc": "OFF-OFFICE-A",
525
+ "retrieval_score": 0.702454,
526
+ "top_posterior": 0.49929,
527
+ "answer": "I do not know based on the provided documents.",
528
+ "citations": [],
529
+ "correct": true,
530
+ "grounded": true
531
+ }
532
+ ],
533
+ "baseline_cases": [
534
+ {
535
+ "id": "q1",
536
+ "query": "What is the current HR portal for leave requests and payslips?",
537
+ "expected_abstain": false,
538
+ "top_doc": "LEG-HR-PORTAL",
539
+ "retrieval_score": 0.807312,
540
+ "top_posterior": 0.592469,
541
+ "answer": "The current HR portal for leave requests and payslips is WorkSphere. [LEG-HR-PORTAL]",
542
+ "citations": [
543
+ "LEG-HR-PORTAL"
544
+ ],
545
+ "correct": false,
546
+ "grounded": true
547
+ },
548
+ {
549
+ "id": "q2",
550
+ "query": "How many paid leave days do employees get per year under the current policy?",
551
+ "expected_abstain": false,
552
+ "top_doc": "LEG-LEAVE",
553
+ "retrieval_score": 0.602126,
554
+ "top_posterior": 0.462903,
555
+ "answer": "Employees receive 15 paid leave days per year under the leave policy. [LEG-LEAVE]",
556
+ "citations": [
557
+ "LEG-LEAVE"
558
+ ],
559
+ "correct": false,
560
+ "grounded": true
561
+ },
562
+ {
563
+ "id": "q3",
564
+ "query": "How many remote work days per week are currently allowed with manager approval?",
565
+ "expected_abstain": false,
566
+ "top_doc": "LEG-REMOTE",
567
+ "retrieval_score": 0.699429,
568
+ "top_posterior": 0.435096,
569
+ "answer": "The current remote work policy permits three remote days per week with manager approval. [LEG-REMOTE]",
570
+ "citations": [
571
+ "LEG-REMOTE"
572
+ ],
573
+ "correct": false,
574
+ "grounded": true
575
+ },
576
+ {
577
+ "id": "q4",
578
+ "query": "By which working day are salary slips published each month?",
579
+ "expected_abstain": false,
580
+ "top_doc": "LEG-PAYSLIP",
581
+ "retrieval_score": 0.672029,
582
+ "top_posterior": 0.550648,
583
+ "answer": "Salary slips are published on the tenth working day of each month. [LEG-PAYSLIP]",
584
+ "citations": [
585
+ "LEG-PAYSLIP"
586
+ ],
587
+ "correct": false,
588
+ "grounded": true
589
+ },
590
+ {
591
+ "id": "q5",
592
+ "query": "Which VPN protocol is required by the current corporate security baseline?",
593
+ "expected_abstain": false,
594
+ "top_doc": "LEG-VPN",
595
+ "retrieval_score": 0.524301,
596
+ "top_posterior": 0.420244,
597
+ "answer": "The supported corporate VPN protocol is OpenVPN. [LEG-VPN]",
598
+ "citations": [
599
+ "LEG-VPN"
600
+ ],
601
+ "correct": false,
602
+ "grounded": true
603
+ },
604
+ {
605
+ "id": "q6",
606
+ "query": "What is the production database backup retention period?",
607
+ "expected_abstain": false,
608
+ "top_doc": "LEG-BACKUP",
609
+ "retrieval_score": 0.556814,
610
+ "top_posterior": 0.467937,
611
+ "answer": "Production database backups are retained for 14 days. [LEG-BACKUP]",
612
+ "citations": [
613
+ "LEG-BACKUP"
614
+ ],
615
+ "correct": false,
616
+ "grounded": true
617
+ },
618
+ {
619
+ "id": "q7",
620
+ "query": "What is the current public API rate limit per minute per token?",
621
+ "expected_abstain": false,
622
+ "top_doc": "LEG-RATE",
623
+ "retrieval_score": 0.845222,
624
+ "top_posterior": 0.672178,
625
+ "answer": "The public API rate limit is 60 requests per minute per token. [LEG-RATE]",
626
+ "citations": [
627
+ "LEG-RATE"
628
+ ],
629
+ "correct": false,
630
+ "grounded": true
631
+ },
632
+ {
633
+ "id": "q8",
634
+ "query": "Which cloud region is primary for production?",
635
+ "expected_abstain": false,
636
+ "top_doc": "LEG-REGION",
637
+ "retrieval_score": 0.542439,
638
+ "top_posterior": 0.460259,
639
+ "answer": "The primary deployment region is us-east-1. [LEG-REGION]",
640
+ "citations": [
641
+ "LEG-REGION"
642
+ ],
643
+ "correct": false,
644
+ "grounded": true
645
+ },
646
+ {
647
+ "id": "q9",
648
+ "query": "What is the domestic business travel meal expense cap per day?",
649
+ "expected_abstain": false,
650
+ "top_doc": "LEG-EXPENSE",
651
+ "retrieval_score": 0.671336,
652
+ "top_posterior": 0.451066,
653
+ "answer": "The meal expense cap is INR 3,000 per day on domestic travel. [LEG-EXPENSE]",
654
+ "citations": [
655
+ "LEG-EXPENSE"
656
+ ],
657
+ "correct": false,
658
+ "grounded": true
659
+ },
660
+ {
661
+ "id": "q10",
662
+ "query": "Within how many days must expense reimbursement claims be submitted?",
663
+ "expected_abstain": false,
664
+ "top_doc": "OFF-REIMB",
665
+ "retrieval_score": 0.513156,
666
+ "top_posterior": 0.45338,
667
+ "answer": "Expense claims must be submitted within 15 days of the transaction date. [OFF-REIMB]",
668
+ "citations": [
669
+ "OFF-REIMB"
670
+ ],
671
+ "correct": true,
672
+ "grounded": true
673
+ },
674
+ {
675
+ "id": "q11",
676
+ "query": "Which portal must be used to book approved business travel?",
677
+ "expected_abstain": false,
678
+ "top_doc": "OFF-TRAVEL",
679
+ "retrieval_score": 0.580663,
680
+ "top_posterior": 0.514293,
681
+ "answer": "Approved business travel must be booked through TripDesk. [OFF-TRAVEL]",
682
+ "citations": [
683
+ "OFF-TRAVEL"
684
+ ],
685
+ "correct": true,
686
+ "grounded": true
687
+ },
688
+ {
689
+ "id": "q12",
690
+ "query": "Above what amount is procurement review mandatory?",
691
+ "expected_abstain": false,
692
+ "top_doc": "OFF-PROC",
693
+ "retrieval_score": 0.512226,
694
+ "top_posterior": 0.38514,
695
+ "answer": "Procurement review is mandatory for purchases above INR 50,000. [OFF-PROC]",
696
+ "citations": [
697
+ "OFF-PROC"
698
+ ],
699
+ "correct": true,
700
+ "grounded": true
701
+ },
702
+ {
703
+ "id": "q13",
704
+ "query": "What is the company dental insurance annual limit?",
705
+ "expected_abstain": true,
706
+ "top_doc": "LEG-RATE",
707
+ "retrieval_score": 0.325883,
708
+ "top_posterior": 1.0,
709
+ "answer": "The public API rate limit is 60 requests per minute per token. [LEG-RATE]",
710
+ "citations": [
711
+ "LEG-RATE"
712
+ ],
713
+ "correct": false,
714
+ "grounded": true
715
+ },
716
+ {
717
+ "id": "q14",
718
+ "query": "What is the cafeteria breakfast price?",
719
+ "expected_abstain": true,
720
+ "top_doc": "LEG-HR-PORTAL",
721
+ "retrieval_score": 0.0,
722
+ "top_posterior": 0.0,
723
+ "answer": "I do not know based on the provided documents.",
724
+ "citations": [],
725
+ "correct": true,
726
+ "grounded": true
727
+ },
728
+ {
729
+ "id": "q15",
730
+ "query": "What is the emergency satellite phone number?",
731
+ "expected_abstain": true,
732
+ "top_doc": "LEG-HR-PORTAL",
733
+ "retrieval_score": 0.0,
734
+ "top_posterior": 0.0,
735
+ "answer": "I do not know based on the provided documents.",
736
+ "citations": [],
737
+ "correct": true,
738
+ "grounded": true
739
+ },
740
+ {
741
+ "id": "q16",
742
+ "query": "What is the annual gym membership reimbursement?",
743
+ "expected_abstain": true,
744
+ "top_doc": "COM-REIMB",
745
+ "retrieval_score": 0.333617,
746
+ "top_posterior": 0.327295,
747
+ "answer": "Finance may request additional receipts during reimbursement review. [COM-REIMB]",
748
+ "citations": [
749
+ "COM-REIMB"
750
+ ],
751
+ "correct": false,
752
+ "grounded": true
753
+ },
754
+ {
755
+ "id": "q17",
756
+ "query": "Which room is the graduate AI lab currently in?",
757
+ "expected_abstain": true,
758
+ "top_doc": "OFF-LAB-A",
759
+ "retrieval_score": 0.752053,
760
+ "top_posterior": 0.5,
761
+ "answer": "The graduate AI lab has moved to room B-204. [OFF-LAB-A]",
762
+ "citations": [
763
+ "OFF-LAB-A"
764
+ ],
765
+ "correct": false,
766
+ "grounded": true
767
+ },
768
+ {
769
+ "id": "q18",
770
+ "query": "When are Professor Sen's office hours?",
771
+ "expected_abstain": true,
772
+ "top_doc": "OFF-OFFICE-A",
773
+ "retrieval_score": 0.702454,
774
+ "top_posterior": 0.5,
775
+ "answer": "Professor Sen holds office hours on Tuesday at 3 PM. [OFF-OFFICE-A]",
776
+ "citations": [
777
+ "OFF-OFFICE-A"
778
+ ],
779
+ "correct": false,
780
+ "grounded": true
781
+ }
782
+ ]
783
+ }
784
+ }
evaluation/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # Benchmark package.
evaluation/framework_rag_benchmark.py ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from bayesian_langchain import BayesianIndex, BayesianRAG
8
+ from evaluation.rag_benchmark import CLEAN_CASES, CLEAN_DOCS, CONFLICT_CASES, CONFLICT_DOCS
9
+
10
+
11
+ def _case_ok(out: dict[str, Any], case: dict[str, Any]) -> bool:
12
+ if case["abstain"]:
13
+ return bool(out.get("abstained")) and not out.get("citations")
14
+ return (
15
+ not out.get("abstained")
16
+ and case["doc"] in out.get("citations", [])
17
+ and str(case["gold"]).lower() in str(out.get("answer", "")).lower()
18
+ )
19
+
20
+
21
+ def _run_suite(docs, cases) -> dict[str, Any]:
22
+ rag = BayesianRAG(BayesianIndex(docs), k=4)
23
+ rows = []
24
+ for case in cases:
25
+ out = rag.as_runnable().invoke(case["q"])
26
+ rows.append({
27
+ "id": case["id"],
28
+ "ok": _case_ok(out, case),
29
+ "answer": out["answer"],
30
+ "citations": out["citations"],
31
+ "abstained": out["abstained"],
32
+ "top_doc": out["hits"][0]["id"] if out["hits"] else None,
33
+ })
34
+ return {
35
+ "n_cases": len(rows),
36
+ "passed": sum(r["ok"] for r in rows),
37
+ "accuracy": sum(r["ok"] for r in rows) / len(rows),
38
+ "rows": rows,
39
+ }
40
+
41
+
42
+ def run_framework_benchmark(output_path: str | Path | None = None) -> dict[str, Any]:
43
+ result = {
44
+ "framework": "langchain-core",
45
+ "execution": "BayesianRetriever(BaseRetriever) -> BayesianRAG RunnableLambda -> grounded answer/abstention",
46
+ "clean": _run_suite(CLEAN_DOCS, CLEAN_CASES),
47
+ "conflict": _run_suite(CONFLICT_DOCS, CONFLICT_CASES),
48
+ }
49
+ if output_path is not None:
50
+ Path(output_path).write_text(json.dumps(result, indent=2), encoding="utf-8")
51
+ return result
52
+
53
+
54
+ if __name__ == "__main__":
55
+ print(json.dumps(run_framework_benchmark(), indent=2))
evaluation/rag_benchmark.py ADDED
@@ -0,0 +1,206 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from bayesian_langchain.core import (
8
+ BayesianIndex,
9
+ BayesianRetrievalConfig,
10
+ DocumentRecord,
11
+ PlainTfidfIndex,
12
+ ExtractiveGroundedGenerator,
13
+ )
14
+
15
+
16
+ def _md(kind: str) -> dict[str, Any]:
17
+ if kind == "official":
18
+ return {"reliability_alpha": 18, "reliability_beta": 2, "authority": 0.98, "freshness": 1.0, "tier": kind}
19
+ if kind == "stale":
20
+ return {"reliability_alpha": 6, "reliability_beta": 6, "authority": 0.45, "freshness": 0.18, "tier": kind}
21
+ if kind == "community":
22
+ return {"reliability_alpha": 7, "reliability_beta": 5, "authority": 0.55, "freshness": 0.65, "tier": kind}
23
+ if kind == "official_equal":
24
+ return {"reliability_alpha": 18, "reliability_beta": 2, "authority": 0.98, "freshness": 1.0, "tier": "official"}
25
+ return {"reliability_alpha": 1, "reliability_beta": 1, "authority": 0.5, "freshness": 0.5, "tier": kind}
26
+
27
+
28
+ CONFLICT_DOCS = [
29
+ DocumentRecord("LEG-HR-PORTAL", "The current HR portal for leave requests and payslips is WorkSphere.", "Current HR portal login guide", "legacy-cache", _md("stale")),
30
+ DocumentRecord("OFF-HR-PORTAL", "All employee self-service tasks, including leave requests and payslips, are now handled in PeopleHub.", "Employee self-service migration completed", "official-handbook", _md("official")),
31
+ DocumentRecord("COM-HR-PORTAL", "Some teams used WorkSphere during migration; ask HR if your bookmark no longer works.", "HR portal discussion", "community-wiki", _md("community")),
32
+ DocumentRecord("LEG-LEAVE", "Employees receive 15 paid leave days per year under the leave policy.", "Paid leave entitlement FAQ", "legacy-cache", _md("stale")),
33
+ DocumentRecord("OFF-LEAVE", "The 2026 employee benefits policy grants 18 paid leave days per calendar year.", "2026 employee benefits policy", "official-handbook", _md("official")),
34
+ DocumentRecord("COM-LEAVE", "Leave balances vary by category and should be checked in the HR system.", "Leave balance help", "community-wiki", _md("community")),
35
+ DocumentRecord("LEG-REMOTE", "The current remote work policy permits three remote days per week with manager approval.", "Current remote work days", "legacy-cache", _md("stale")),
36
+ DocumentRecord("OFF-REMOTE", "Under the active flexible-work policy, employees may work remotely up to two days per week with manager approval.", "Flexible-work policy revision", "official-handbook", _md("official")),
37
+ DocumentRecord("COM-REMOTE", "Remote work requires manager approval and may vary during special projects.", "Remote work tips", "community-wiki", _md("community")),
38
+ DocumentRecord("LEG-PAYSLIP", "Salary slips are published on the tenth working day of each month.", "Payslip publication date", "legacy-cache", _md("stale")),
39
+ DocumentRecord("OFF-PAYSLIP", "Payroll publishes salary slips by the fifth working day of each month.", "Payroll service standard", "official-handbook", _md("official")),
40
+ DocumentRecord("COM-PAYSLIP", "Payslip publication can be delayed around holidays.", "Payroll discussion", "community-wiki", _md("community")),
41
+ DocumentRecord("LEG-VPN", "The supported corporate VPN protocol is OpenVPN.", "Corporate VPN protocol", "legacy-cache", _md("stale")),
42
+ DocumentRecord("OFF-VPN", "The security baseline now requires WireGuard for the standard corporate VPN profile.", "Network security baseline", "official-it", _md("official")),
43
+ DocumentRecord("COM-VPN", "OpenVPN profiles may still exist on older laptops.", "VPN migration notes", "community-wiki", _md("community")),
44
+ DocumentRecord("LEG-BACKUP", "Production database backups are retained for 14 days.", "Database backup retention policy", "legacy-cache", _md("stale")),
45
+ DocumentRecord("OFF-BACKUP", "Production database backups have a retention period of 30 days.", "Disaster recovery control DR-7", "official-it", _md("official")),
46
+ DocumentRecord("COM-BACKUP", "Teams should not rely on local snapshots as their only backup.", "Backup practices", "community-wiki", _md("community")),
47
+ DocumentRecord("LEG-RATE", "The public API rate limit is 60 requests per minute per token.", "Public API rate limit", "legacy-cache", _md("stale")),
48
+ DocumentRecord("OFF-RATE", "The current gateway quota permits 120 requests per minute per token.", "API gateway operating limits", "official-it", _md("official")),
49
+ DocumentRecord("COM-RATE", "Burst traffic may still be throttled even below the nominal quota.", "API troubleshooting", "community-wiki", _md("community")),
50
+ DocumentRecord("LEG-REGION", "The primary deployment region is us-east-1.", "Primary cloud deployment region", "legacy-cache", _md("stale")),
51
+ DocumentRecord("OFF-REGION", "The production primary region is ap-south-1; disaster recovery remains in a separate region.", "Cloud production topology", "official-it", _md("official")),
52
+ DocumentRecord("COM-REGION", "Developers may use several regions for non-production experiments.", "Cloud developer notes", "community-wiki", _md("community")),
53
+ DocumentRecord("LEG-EXPENSE", "The meal expense cap is INR 3,000 per day on domestic travel.", "Domestic meal expense cap", "legacy-cache", _md("stale")),
54
+ DocumentRecord("OFF-EXPENSE", "For domestic business travel, reimbursable meals are capped at INR 4,500 per day.", "Travel and expense policy 2026", "official-finance", _md("official")),
55
+ DocumentRecord("COM-EXPENSE", "Itemized receipts are recommended for meal reimbursement.", "Expense claim tips", "community-wiki", _md("community")),
56
+ DocumentRecord("LEG-REIMB", "Employees must submit reimbursement claims within 30 days of the expense.", "Reimbursement submission deadline", "legacy-cache", _md("stale")),
57
+ DocumentRecord("OFF-REIMB", "Expense claims must be submitted within 15 days of the transaction date.", "Expense reimbursement standard", "official-finance", _md("official")),
58
+ DocumentRecord("COM-REIMB", "Finance may request additional receipts during reimbursement review.", "Reimbursement FAQ", "community-wiki", _md("community")),
59
+ DocumentRecord("LEG-TRAVEL", "All corporate travel should be booked through TravelPro.", "Corporate travel booking portal", "legacy-cache", _md("stale")),
60
+ DocumentRecord("OFF-TRAVEL", "Approved business travel must be booked through TripDesk.", "Business travel booking procedure", "official-finance", _md("official")),
61
+ DocumentRecord("COM-TRAVEL", "Direct airline booking can cause reimbursement delays.", "Travel booking discussion", "community-wiki", _md("community")),
62
+ DocumentRecord("LEG-PROC", "Purchases above INR 25,000 require procurement review.", "Procurement review threshold", "legacy-cache", _md("stale")),
63
+ DocumentRecord("OFF-PROC", "Procurement review is mandatory for purchases above INR 50,000.", "Procurement control PC-12", "official-finance", _md("official")),
64
+ DocumentRecord("COM-PROC", "Some categories require procurement review regardless of value.", "Procurement FAQ", "community-wiki", _md("community")),
65
+ DocumentRecord("OFF-LAB-A", "The graduate AI lab has moved to room B-204.", "AI lab room notice A", "official-campus", _md("official_equal")),
66
+ DocumentRecord("OFF-LAB-B", "The graduate AI lab has moved to room C-101.", "AI lab room notice B", "official-campus", _md("official_equal")),
67
+ DocumentRecord("OFF-OFFICE-A", "Professor Sen holds office hours on Tuesday at 3 PM.", "Office hours notice A", "official-campus", _md("official_equal")),
68
+ DocumentRecord("OFF-OFFICE-B", "Professor Sen holds office hours on Wednesday at 4 PM.", "Office hours notice B", "official-campus", _md("official_equal")),
69
+ ]
70
+
71
+ CONFLICT_CASES = [
72
+ {"id":"q1","q":"What is the current HR portal for leave requests and payslips?","gold":"PeopleHub","doc":"OFF-HR-PORTAL","abstain":False},
73
+ {"id":"q2","q":"How many paid leave days do employees get per year under the current policy?","gold":"18 paid leave days","doc":"OFF-LEAVE","abstain":False},
74
+ {"id":"q3","q":"How many remote work days per week are currently allowed with manager approval?","gold":"two days per week","doc":"OFF-REMOTE","abstain":False},
75
+ {"id":"q4","q":"By which working day are salary slips published each month?","gold":"fifth working day","doc":"OFF-PAYSLIP","abstain":False},
76
+ {"id":"q5","q":"Which VPN protocol is required by the current corporate security baseline?","gold":"WireGuard","doc":"OFF-VPN","abstain":False},
77
+ {"id":"q6","q":"What is the production database backup retention period?","gold":"30 days","doc":"OFF-BACKUP","abstain":False},
78
+ {"id":"q7","q":"What is the current public API rate limit per minute per token?","gold":"120 requests per minute","doc":"OFF-RATE","abstain":False},
79
+ {"id":"q8","q":"Which cloud region is primary for production?","gold":"ap-south-1","doc":"OFF-REGION","abstain":False},
80
+ {"id":"q9","q":"What is the domestic business travel meal expense cap per day?","gold":"INR 4,500 per day","doc":"OFF-EXPENSE","abstain":False},
81
+ {"id":"q10","q":"Within how many days must expense reimbursement claims be submitted?","gold":"15 days","doc":"OFF-REIMB","abstain":False},
82
+ {"id":"q11","q":"Which portal must be used to book approved business travel?","gold":"TripDesk","doc":"OFF-TRAVEL","abstain":False},
83
+ {"id":"q12","q":"Above what amount is procurement review mandatory?","gold":"INR 50,000","doc":"OFF-PROC","abstain":False},
84
+ {"id":"q13","q":"What is the company dental insurance annual limit?","gold":None,"doc":None,"abstain":True},
85
+ {"id":"q14","q":"What is the cafeteria breakfast price?","gold":None,"doc":None,"abstain":True},
86
+ {"id":"q15","q":"What is the emergency satellite phone number?","gold":None,"doc":None,"abstain":True},
87
+ {"id":"q16","q":"What is the annual gym membership reimbursement?","gold":None,"doc":None,"abstain":True},
88
+ {"id":"q17","q":"Which room is the graduate AI lab currently in?","gold":None,"doc":None,"abstain":True},
89
+ {"id":"q18","q":"When are Professor Sen's office hours?","gold":None,"doc":None,"abstain":True},
90
+ ]
91
+
92
+ CLEAN_DOCS = [
93
+ DocumentRecord("C1", "The observatory opens to visitors at 9 AM every Saturday.", "Observatory hours", "handbook", _md("official")),
94
+ DocumentRecord("C2", "The chemistry safety briefing is held in Hall 7.", "Chemistry briefing", "handbook", _md("official")),
95
+ DocumentRecord("C3", "Library books may be borrowed for 21 days.", "Library borrowing", "handbook", _md("official")),
96
+ DocumentRecord("C4", "The robotics club meets on Thursday evenings.", "Robotics club", "handbook", _md("official")),
97
+ DocumentRecord("C5", "Visitor parking is located beside Gate 3.", "Visitor parking", "handbook", _md("official")),
98
+ DocumentRecord("C6", "The campus shuttle begins service at 6:30 AM.", "Campus shuttle", "handbook", _md("official")),
99
+ DocumentRecord("C7", "The data science seminar carries two academic credits.", "Data science seminar", "handbook", _md("official")),
100
+ DocumentRecord("C8", "The emergency assembly point is the North Lawn.", "Emergency procedure", "handbook", _md("official")),
101
+ ]
102
+
103
+ CLEAN_CASES = [
104
+ {"id":"c1","q":"When does the observatory open on Saturday?","gold":"9 AM","doc":"C1","abstain":False},
105
+ {"id":"c2","q":"Where is the chemistry safety briefing held?","gold":"Hall 7","doc":"C2","abstain":False},
106
+ {"id":"c3","q":"How long may library books be borrowed?","gold":"21 days","doc":"C3","abstain":False},
107
+ {"id":"c4","q":"When does the robotics club meet?","gold":"Thursday evenings","doc":"C4","abstain":False},
108
+ {"id":"c5","q":"Where is visitor parking?","gold":"Gate 3","doc":"C5","abstain":False},
109
+ {"id":"c6","q":"What time does the campus shuttle begin service?","gold":"6:30 AM","doc":"C6","abstain":False},
110
+ {"id":"c7","q":"How many credits is the data science seminar worth?","gold":"two academic credits","doc":"C7","abstain":False},
111
+ {"id":"c8","q":"Where is the emergency assembly point?","gold":"North Lawn","doc":"C8","abstain":False},
112
+ ]
113
+
114
+
115
+ def _eval(name: str, docs: list[DocumentRecord], cases: list[dict[str, Any]], bayesian: bool) -> tuple[dict[str, Any], list[dict[str, Any]]]:
116
+ gen = ExtractiveGroundedGenerator()
117
+ if bayesian:
118
+ searcher = BayesianIndex(docs, BayesianRetrievalConfig(candidate_pool=10, likelihood_temperature=2.0))
119
+ else:
120
+ searcher = PlainTfidfIndex(docs)
121
+ rows: list[dict[str, Any]] = []
122
+ rr: list[float] = []
123
+ r1: list[float] = []
124
+ r3: list[float] = []
125
+ briers: list[float] = []
126
+ doc_map = {d.id: d for d in docs}
127
+ for c in cases:
128
+ hits = searcher.search(c["q"], k=4)
129
+ top = hits[0] if hits else None
130
+ if bayesian:
131
+ abstain = searcher.should_abstain(hits)
132
+ else:
133
+ abstain = (not hits) or top.retrieval_score < 0.06
134
+ if abstain:
135
+ answer, citations = gen.abstain_text, []
136
+ else:
137
+ answer, citations = gen.generate(c["q"], hits)
138
+ if c["abstain"]:
139
+ correct = answer == gen.abstain_text
140
+ else:
141
+ correct = str(c["gold"]).lower() in answer.lower()
142
+ ids = [h.document.id for h in hits]
143
+ rank = ids.index(c["doc"]) + 1 if c["doc"] in ids else 0
144
+ r1.append(1.0 if rank == 1 else 0.0)
145
+ r3.append(1.0 if 0 < rank <= 3 else 0.0)
146
+ rr.append(1.0 / rank if rank else 0.0)
147
+ p = top.posterior if top else 0.0
148
+ y = 1.0 if top and top.document.id == c["doc"] else 0.0
149
+ briers.append((p - y) ** 2)
150
+ grounded = True
151
+ if citations:
152
+ sentence = answer.rsplit(" [", 1)[0]
153
+ grounded = sentence in doc_map[citations[0]].text
154
+ rows.append({
155
+ "id": c["id"],
156
+ "query": c["q"],
157
+ "expected_abstain": c["abstain"],
158
+ "top_doc": None if top is None else top.document.id,
159
+ "retrieval_score": None if top is None else round(top.retrieval_score, 6),
160
+ "top_posterior": None if top is None else round(top.posterior, 6),
161
+ "answer": answer,
162
+ "citations": citations,
163
+ "correct": correct,
164
+ "grounded": grounded,
165
+ })
166
+ n_answerable = sum(not c["abstain"] for c in cases)
167
+ n_abstain = sum(c["abstain"] for c in cases)
168
+ summary = {
169
+ "system": name,
170
+ "n_cases": len(cases),
171
+ "answerable_cases": n_answerable,
172
+ "abstention_cases": n_abstain,
173
+ "recall_at_1": sum(r1) / len(r1) if r1 else None,
174
+ "recall_at_3": sum(r3) / len(r3) if r3 else None,
175
+ "mrr": sum(rr) / len(rr) if rr else None,
176
+ "end_to_end_accuracy": sum(r["correct"] for r in rows) / len(rows),
177
+ "grounding_accuracy": sum(r["grounded"] for r in rows) / len(rows),
178
+ "abstention_accuracy": (sum(r["correct"] for r, c in zip(rows, cases) if c["abstain"]) / n_abstain) if n_abstain else None,
179
+ "answer_accuracy_on_answerable": (sum(r["correct"] for r, c in zip(rows, cases) if not c["abstain"]) / n_answerable) if n_answerable else None,
180
+ "top_posterior_brier": sum(briers) / len(briers) if briers else None,
181
+ }
182
+ return summary, rows
183
+
184
+
185
+ def run_benchmark(output_path: str | Path | None = None) -> dict[str, Any]:
186
+ clean_b, clean_b_rows = _eval("bayesian", CLEAN_DOCS, CLEAN_CASES, True)
187
+ clean_t, clean_t_rows = _eval("plain_tfidf", CLEAN_DOCS, CLEAN_CASES, False)
188
+ conflict_b, conflict_b_rows = _eval("bayesian", CONFLICT_DOCS, CONFLICT_CASES, True)
189
+ conflict_t, conflict_t_rows = _eval("plain_tfidf", CONFLICT_DOCS, CONFLICT_CASES, False)
190
+ result = {
191
+ "benchmark": "controlled_rag_v2",
192
+ "methodology": (
193
+ "Two end-to-end suites. CLEAN checks ordinary retrieval+grounded answer synthesis. "
194
+ "CONFLICT adds stale authoritative conflicts, missing evidence, and unresolved equal-authority contradictions. "
195
+ "The generator is extractive and citation-grounded so the evaluation isolates retrieval and uncertainty behavior without an external LLM confound."
196
+ ),
197
+ "clean": {"bayesian": clean_b, "baseline": clean_t, "bayesian_cases": clean_b_rows, "baseline_cases": clean_t_rows},
198
+ "conflict": {"bayesian": conflict_b, "baseline": conflict_t, "bayesian_cases": conflict_b_rows, "baseline_cases": conflict_t_rows},
199
+ }
200
+ if output_path is not None:
201
+ Path(output_path).write_text(json.dumps(result, indent=2), encoding="utf-8")
202
+ return result
203
+
204
+
205
+ if __name__ == "__main__":
206
+ print(json.dumps(run_benchmark(), indent=2))
examples/quickstart.py ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ from bayesian_langchain import BayesianIndex, BayesianRAG, DocumentRecord
2
+
3
+ docs = [
4
+ DocumentRecord("official", "The current HR portal is PeopleHub.", "Current policy", "official", {"reliability_alpha":18,"reliability_beta":2,"authority":0.98,"freshness":1.0}),
5
+ DocumentRecord("legacy", "The HR portal is WorkSphere.", "Legacy guide", "cache", {"reliability_alpha":6,"reliability_beta":6,"authority":0.45,"freshness":0.18}),
6
+ ]
7
+ print(BayesianRAG(BayesianIndex(docs)).invoke("What is the current HR portal?"))
pyproject.toml ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "bayesian-langchain-rag"
7
+ version = "1.0.0"
8
+ description = "Generalized-Bayesian retrieval and uncertainty-aware RAG integrated with LangChain"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = {text = "MIT"}
12
+ authors = [{name = "Angshul Majumdar"}]
13
+ dependencies = [
14
+ "numpy>=1.24",
15
+ "scikit-learn>=1.3",
16
+ "pydantic>=2.7",
17
+ "langchain-core>=1.4.7,<2.0",
18
+ ]
19
+
20
+ [project.optional-dependencies]
21
+ dev = ["pytest>=8.0"]
22
+ full = ["langchain>=1.4,<2.0"]
23
+
24
+ [tool.setuptools]
25
+ package-dir = {"" = "src"}
26
+
27
+ [tool.setuptools.packages.find]
28
+ where = ["src"]
29
+
30
+ [tool.pytest.ini_options]
31
+ pythonpath = ["src"]
32
+ testpaths = ["tests"]
requirements.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ numpy>=1.24
2
+ scikit-learn>=1.3
3
+ pydantic>=2.7
4
+ langchain-core>=1.4.7,<2.0
src/bayesian_langchain/__init__.py ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from .core import (
2
+ BayesianIndex,
3
+ BayesianRetrievalConfig,
4
+ DocumentRecord,
5
+ RetrievalHit,
6
+ ExtractiveGroundedGenerator,
7
+ )
8
+
9
+ __all__ = [
10
+ "BayesianIndex",
11
+ "BayesianRetrievalConfig",
12
+ "DocumentRecord",
13
+ "RetrievalHit",
14
+ "ExtractiveGroundedGenerator",
15
+ ]
16
+
17
+ try:
18
+ from .retriever import BayesianRetriever
19
+ from .rag import BayesianRAG
20
+ __all__ += ["BayesianRetriever", "BayesianRAG"]
21
+ except ImportError:
22
+ # Core retrieval/evaluation remains usable before optional framework deps are installed.
23
+ pass
src/bayesian_langchain/core.py ADDED
@@ -0,0 +1,255 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any, Iterable, Mapping, Sequence
5
+ import math
6
+ import re
7
+
8
+ import numpy as np
9
+ from sklearn.feature_extraction.text import TfidfVectorizer, ENGLISH_STOP_WORDS
10
+
11
+
12
+ def _clip01(x: float, eps: float = 1e-6) -> float:
13
+ return min(max(float(x), eps), 1.0)
14
+
15
+
16
+ def _logsumexp(xs: Sequence[float]) -> float:
17
+ if not xs:
18
+ return float('-inf')
19
+ finite = [x for x in xs if math.isfinite(x)]
20
+ if not finite:
21
+ return float('-inf')
22
+ m = max(finite)
23
+ return m + math.log(sum(math.exp(x - m) for x in finite))
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class DocumentRecord:
28
+ id: str
29
+ text: str
30
+ title: str = ""
31
+ source: str = "unknown"
32
+ metadata: Mapping[str, Any] = field(default_factory=dict)
33
+
34
+ @property
35
+ def retrieval_text(self) -> str:
36
+ return (self.title + ". " + self.text).strip(". ")
37
+
38
+ def source_prior(self) -> float:
39
+ md = self.metadata
40
+ a = max(float(md.get("reliability_alpha", 1.0)), 1e-6)
41
+ b = max(float(md.get("reliability_beta", 1.0)), 1e-6)
42
+ reliability = a / (a + b)
43
+ authority = _clip01(md.get("authority", 0.5))
44
+ freshness = _clip01(md.get("freshness", 0.5))
45
+ return reliability * authority * freshness
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class RetrievalHit:
50
+ document: DocumentRecord
51
+ retrieval_score: float
52
+ log_prior: float
53
+ log_likelihood: float
54
+ log_posterior_unnormalized: float
55
+ posterior: float
56
+ query_coverage: float
57
+ rank: int
58
+
59
+ def as_dict(self) -> dict[str, Any]:
60
+ return {
61
+ "id": self.document.id,
62
+ "title": self.document.title,
63
+ "text": self.document.text,
64
+ "source": self.document.source,
65
+ "metadata": dict(self.document.metadata),
66
+ "retrieval_score": self.retrieval_score,
67
+ "log_prior": self.log_prior,
68
+ "log_likelihood": self.log_likelihood,
69
+ "posterior": self.posterior,
70
+ "query_coverage": self.query_coverage,
71
+ "rank": self.rank,
72
+ }
73
+
74
+
75
+ @dataclass(frozen=True)
76
+ class BayesianRetrievalConfig:
77
+ candidate_pool: int = 12
78
+ likelihood_temperature: float = 2.0
79
+ likelihood_floor: float = 0.02
80
+ reliability_weight: float = 1.0
81
+ authority_weight: float = 1.0
82
+ freshness_weight: float = 0.8
83
+ prior_floor: float = 1e-4
84
+ min_relevance: float = 0.06
85
+ min_top_posterior: float = 0.30
86
+ min_query_coverage: float = 0.30
87
+ min_dominance_ratio: float = 1.20
88
+
89
+
90
+ class BayesianIndex:
91
+ """Generalized-Bayesian reranker over TF-IDF retrieval candidates.
92
+
93
+ Candidate document hypotheses are scored by
94
+ p(d|q) ∝ p(d) exp(tau * sim(q,d)).
95
+ p(d) is induced by a Beta source-reliability mean plus authority/freshness
96
+ metadata. The exponential similarity term is a Gibbs/generalized-Bayes
97
+ likelihood, not claimed to be a literal language-model likelihood.
98
+ """
99
+
100
+ def __init__(self, documents: Sequence[DocumentRecord], config: BayesianRetrievalConfig | None = None):
101
+ if not documents:
102
+ raise ValueError("documents must be non-empty")
103
+ self.documents = list(documents)
104
+ self.config = config or BayesianRetrievalConfig()
105
+ self.vectorizer = TfidfVectorizer(
106
+ lowercase=True,
107
+ stop_words="english",
108
+ ngram_range=(1, 2),
109
+ sublinear_tf=True,
110
+ norm="l2",
111
+ )
112
+ self.matrix = self.vectorizer.fit_transform([d.retrieval_text for d in self.documents])
113
+
114
+ def _log_prior(self, d: DocumentRecord) -> float:
115
+ md = d.metadata
116
+ a = max(float(md.get("reliability_alpha", 1.0)), 1e-6)
117
+ b = max(float(md.get("reliability_beta", 1.0)), 1e-6)
118
+ rel = _clip01(a / (a + b))
119
+ auth = _clip01(md.get("authority", 0.5))
120
+ fresh = _clip01(md.get("freshness", 0.5))
121
+ cfg = self.config
122
+ prior = max(
123
+ cfg.prior_floor,
124
+ (rel ** cfg.reliability_weight)
125
+ * (auth ** cfg.authority_weight)
126
+ * (fresh ** cfg.freshness_weight),
127
+ )
128
+ return math.log(prior)
129
+
130
+ def lexical_scores(self, query: str) -> np.ndarray:
131
+ q = self.vectorizer.transform([str(query)])
132
+ # TF-IDF vectors are L2 normalized, so dot product is cosine similarity.
133
+ return (self.matrix @ q.T).toarray().ravel()
134
+
135
+ @staticmethod
136
+ def _query_coverage(query: str, text: str) -> float:
137
+ qterms = {t for t in re.findall(r"[A-Za-z0-9_-]+", query.lower()) if len(t) > 1 and t not in ENGLISH_STOP_WORDS}
138
+ if not qterms:
139
+ return 0.0
140
+ dterms = set(re.findall(r"[A-Za-z0-9_-]+", text.lower()))
141
+ return len(qterms & dterms) / len(qterms)
142
+
143
+ def search(self, query: str, k: int = 4) -> list[RetrievalHit]:
144
+ if k <= 0:
145
+ return []
146
+ scores = self.lexical_scores(query)
147
+ pool_n = min(max(k, self.config.candidate_pool), len(self.documents))
148
+ # Stable sort makes benchmark results reproducible under ties.
149
+ cand = np.argsort(-scores, kind="stable")[:pool_n]
150
+ logu: list[float] = []
151
+ rows: list[tuple[int, float, float, float]] = []
152
+ for i in cand:
153
+ d = self.documents[int(i)]
154
+ s = max(float(scores[int(i)]), 0.0)
155
+ lp = self._log_prior(d)
156
+ ll = self.config.likelihood_temperature * math.log(s + self.config.likelihood_floor)
157
+ u = lp + ll
158
+ rows.append((int(i), s, lp, ll))
159
+ logu.append(u)
160
+ lse = _logsumexp(logu)
161
+ probs = [math.exp(u - lse) if math.isfinite(lse) else 1.0 / len(logu) for u in logu]
162
+ ranked = sorted(
163
+ zip(rows, probs),
164
+ key=lambda rp: (rp[1], rp[0][1]),
165
+ reverse=True,
166
+ )
167
+ hits: list[RetrievalHit] = []
168
+ for rank, ((i, s, lp, ll), pr) in enumerate(ranked[:k], start=1):
169
+ hits.append(RetrievalHit(
170
+ document=self.documents[i],
171
+ retrieval_score=s,
172
+ log_prior=lp,
173
+ log_likelihood=ll,
174
+ log_posterior_unnormalized=lp + ll,
175
+ posterior=float(pr),
176
+ query_coverage=self._query_coverage(query, self.documents[i].retrieval_text),
177
+ rank=rank,
178
+ ))
179
+ return hits
180
+
181
+ def should_abstain(self, hits: Sequence[RetrievalHit]) -> bool:
182
+ if not hits:
183
+ return True
184
+ top = hits[0]
185
+ if (
186
+ top.retrieval_score < self.config.min_relevance
187
+ or top.posterior < self.config.min_top_posterior
188
+ or top.query_coverage < self.config.min_query_coverage
189
+ ):
190
+ return True
191
+ if len(hits) > 1:
192
+ second = hits[1]
193
+ dominance = top.posterior / max(second.posterior, 1e-12)
194
+ if dominance < self.config.min_dominance_ratio:
195
+ return True
196
+ return False
197
+
198
+
199
+ class ExtractiveGroundedGenerator:
200
+ """Dependency-free grounded answer synthesizer for tests and offline use.
201
+
202
+ It selects the query-relevant sentence from the highest-posterior evidence
203
+ and returns that evidence sentence verbatim with its document id. It never
204
+ invents content not present in the retrieved context.
205
+ """
206
+
207
+ abstain_text = "I do not know based on the provided documents."
208
+
209
+ @staticmethod
210
+ def _sentences(text: str) -> list[str]:
211
+ parts = re.split(r"(?<=[.!?])\s+|\n+", text.strip())
212
+ return [p.strip() for p in parts if p.strip()]
213
+
214
+ def generate(self, question: str, hits: Sequence[RetrievalHit]) -> tuple[str, list[str]]:
215
+ if not hits:
216
+ return self.abstain_text, []
217
+ # The posterior ranking has already selected the evidence hypothesis.
218
+ top = hits[0]
219
+ sents = self._sentences(top.document.text)
220
+ if not sents:
221
+ return self.abstain_text, []
222
+ if len(sents) == 1:
223
+ sentence = sents[0]
224
+ else:
225
+ v = TfidfVectorizer(lowercase=True, stop_words="english", ngram_range=(1, 2))
226
+ mat = v.fit_transform([question] + sents)
227
+ q = mat[0]
228
+ sims = (mat[1:] @ q.T).toarray().ravel()
229
+ sentence = sents[int(np.argmax(sims))]
230
+ return f"{sentence} [{top.document.id}]", [top.document.id]
231
+
232
+
233
+ class PlainTfidfIndex:
234
+ """Conventional lexical baseline used by the end-to-end RAG benchmark."""
235
+ def __init__(self, documents: Sequence[DocumentRecord]):
236
+ self.documents = list(documents)
237
+ self.vectorizer = TfidfVectorizer(lowercase=True, stop_words="english", ngram_range=(1, 2), sublinear_tf=True, norm="l2")
238
+ self.matrix = self.vectorizer.fit_transform([d.retrieval_text for d in self.documents])
239
+
240
+ @staticmethod
241
+ def _query_coverage(query: str, text: str) -> float:
242
+ qterms = {t for t in re.findall(r"[A-Za-z0-9_-]+", query.lower()) if len(t) > 1 and t not in ENGLISH_STOP_WORDS}
243
+ if not qterms:
244
+ return 0.0
245
+ dterms = set(re.findall(r"[A-Za-z0-9_-]+", text.lower()))
246
+ return len(qterms & dterms) / len(qterms)
247
+
248
+ def search(self, query: str, k: int = 4) -> list[RetrievalHit]:
249
+ q = self.vectorizer.transform([query])
250
+ scores = (self.matrix @ q.T).toarray().ravel()
251
+ order = np.argsort(-scores, kind="stable")[:k]
252
+ # Baseline assigns normalized positive retrieval mass only for reporting.
253
+ vals = [max(float(scores[int(i)]), 0.0) for i in order]
254
+ z = sum(vals) or 1.0
255
+ return [RetrievalHit(self.documents[int(i)], float(scores[int(i)]), 0.0, 0.0, 0.0, vals[j]/z, 0.0, j+1) for j, i in enumerate(order)]
src/bayesian_langchain/rag.py ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Sequence
4
+ from langchain_core.documents import Document
5
+ from langchain_core.runnables import RunnableLambda
6
+
7
+ from .core import BayesianIndex, DocumentRecord, ExtractiveGroundedGenerator, RetrievalHit
8
+ from .retriever import BayesianRetriever
9
+
10
+
11
+ class BayesianRAG:
12
+ """End-to-end LangChain RAG with explicit posterior uncertainty.
13
+
14
+ Retrieval is performed exactly once through ``BaseRetriever.invoke``. The
15
+ returned LangChain ``Document`` objects carry the posterior metadata and are
16
+ converted back to typed hits only for the uncertainty/grounding layer.
17
+ """
18
+
19
+ def __init__(self, index: BayesianIndex, generator: Any | None = None, k: int = 4):
20
+ self.index = index
21
+ self.retriever = BayesianRetriever(index=index, k=k)
22
+ self.generator = generator or ExtractiveGroundedGenerator()
23
+ self.k = k
24
+
25
+ @staticmethod
26
+ def _documents_to_hits(documents: Sequence[Document]) -> list[RetrievalHit]:
27
+ hits: list[RetrievalHit] = []
28
+ for fallback_rank, doc in enumerate(documents, start=1):
29
+ md = dict(doc.metadata)
30
+ rec = DocumentRecord(
31
+ id=str(md.pop("id", f"doc-{fallback_rank}")),
32
+ text=doc.page_content,
33
+ title=str(md.pop("title", "")),
34
+ source=str(md.pop("source", "unknown")),
35
+ metadata={
36
+ k: v
37
+ for k, v in md.items()
38
+ if k not in {
39
+ "retrieval_score", "log_prior", "log_likelihood",
40
+ "log_posterior_unnormalized", "posterior",
41
+ "query_coverage", "rank",
42
+ }
43
+ },
44
+ )
45
+ hits.append(RetrievalHit(
46
+ document=rec,
47
+ retrieval_score=float(doc.metadata.get("retrieval_score", 0.0)),
48
+ log_prior=float(doc.metadata.get("log_prior", 0.0)),
49
+ log_likelihood=float(doc.metadata.get("log_likelihood", 0.0)),
50
+ log_posterior_unnormalized=float(doc.metadata.get("log_posterior_unnormalized", 0.0)),
51
+ posterior=float(doc.metadata.get("posterior", 0.0)),
52
+ query_coverage=float(doc.metadata.get("query_coverage", 0.0)),
53
+ rank=int(doc.metadata.get("rank", fallback_rank)),
54
+ ))
55
+ return hits
56
+
57
+ def invoke(self, question: str) -> dict[str, Any]:
58
+ documents = self.retriever.invoke(question)
59
+ hits = self._documents_to_hits(documents)
60
+ if self.index.should_abstain(hits):
61
+ answer = ExtractiveGroundedGenerator.abstain_text
62
+ citations: list[str] = []
63
+ elif hasattr(self.generator, "generate"):
64
+ answer, citations = self.generator.generate(question, hits)
65
+ else:
66
+ context = "\n".join(f"[{h.document.id}] {h.document.text}" for h in hits)
67
+ prompt = (
68
+ "Answer only from the supplied context. Cite document ids in square brackets. "
69
+ "If unsupported, say you do not know.\n\n"
70
+ f"Context:\n{context}\n\nQuestion: {question}"
71
+ )
72
+ resp = self.generator.invoke(prompt)
73
+ answer = getattr(resp, "content", str(resp))
74
+ citations = [h.document.id for h in hits if f"[{h.document.id}]" in answer]
75
+ return {
76
+ "question": question,
77
+ "answer": answer,
78
+ "citations": citations,
79
+ "abstained": not citations,
80
+ "hits": [h.as_dict() for h in hits],
81
+ }
82
+
83
+ def as_runnable(self) -> RunnableLambda:
84
+ return RunnableLambda(self.invoke)
src/bayesian_langchain/retriever.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+ from pydantic import ConfigDict
5
+ from langchain_core.documents import Document
6
+ from langchain_core.retrievers import BaseRetriever
7
+
8
+ from .core import BayesianIndex, RetrievalHit
9
+
10
+
11
+ class BayesianRetriever(BaseRetriever):
12
+ """LangChain-native retriever backed by the generalized Bayesian index.
13
+
14
+ The LangChain contract returns ``Document`` objects. All posterior fields
15
+ required downstream are copied into document metadata so an end-to-end RAG
16
+ chain can use the *actual* retriever output without recomputing retrieval.
17
+ """
18
+
19
+ model_config = ConfigDict(arbitrary_types_allowed=True)
20
+ index: Any
21
+ k: int = 4
22
+
23
+ def search_hits(self, query: str) -> list[RetrievalHit]:
24
+ return self.index.search(query, k=self.k)
25
+
26
+ @staticmethod
27
+ def _hit_to_document(h: RetrievalHit) -> Document:
28
+ md = dict(h.document.metadata)
29
+ md.update({
30
+ "id": h.document.id,
31
+ "title": h.document.title,
32
+ "source": h.document.source,
33
+ "retrieval_score": h.retrieval_score,
34
+ "log_prior": h.log_prior,
35
+ "log_likelihood": h.log_likelihood,
36
+ "log_posterior_unnormalized": h.log_posterior_unnormalized,
37
+ "posterior": h.posterior,
38
+ "query_coverage": h.query_coverage,
39
+ "rank": h.rank,
40
+ })
41
+ return Document(page_content=h.document.text, metadata=md)
42
+
43
+ def _get_relevant_documents(self, query: str, *, run_manager: Any = None) -> list[Document]:
44
+ return [self._hit_to_document(h) for h in self.search_hits(query)]
src/bayesian_langchain/smc.py ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any, Callable, Generic, Sequence, TypeVar
5
+ import math
6
+ import random
7
+
8
+ S = TypeVar("S")
9
+ A = TypeVar("A")
10
+
11
+
12
+ def normalize_logweights(logw: Sequence[float]) -> list[float]:
13
+ if not logw:
14
+ return []
15
+ finite = [w for w in logw if math.isfinite(w)]
16
+ if not finite:
17
+ return [1.0 / len(logw)] * len(logw)
18
+ m = max(finite)
19
+ ws = [math.exp(w - m) if math.isfinite(w) else 0.0 for w in logw]
20
+ z = sum(ws)
21
+ if not math.isfinite(z) or z <= 0.0:
22
+ return [1.0 / len(logw)] * len(logw)
23
+ return [w / z for w in ws]
24
+
25
+
26
+ def effective_sample_size(probs: Sequence[float]) -> float:
27
+ s2 = sum(float(p) ** 2 for p in probs)
28
+ return 0.0 if s2 <= 0.0 else 1.0 / s2
29
+
30
+
31
+ def systematic_resample(probs: Sequence[float], rng: random.Random) -> list[int]:
32
+ n = len(probs)
33
+ if n == 0:
34
+ return []
35
+ cdf: list[float] = []
36
+ total = 0.0
37
+ for p in probs:
38
+ total += float(p)
39
+ cdf.append(total)
40
+ if total <= 0.0 or not math.isfinite(total):
41
+ return list(range(n))
42
+ cdf = [x / total for x in cdf]
43
+ cdf[-1] = 1.0
44
+ u0 = rng.random() / n
45
+ out: list[int] = []
46
+ j = 0
47
+ for i in range(n):
48
+ u = u0 + i / n
49
+ while j < n - 1 and u > cdf[j]:
50
+ j += 1
51
+ out.append(j)
52
+ return out
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class Proposal(Generic[A]):
57
+ action: A
58
+ probability: float
59
+
60
+
61
+ @dataclass
62
+ class Particle(Generic[S]):
63
+ state: S
64
+ logw: float = 0.0
65
+ history: list[Any] = field(default_factory=list)
66
+
67
+
68
+ @dataclass(frozen=True)
69
+ class SMCConfig:
70
+ n_particles: int = 32
71
+ n_steps: int = 3
72
+ ess_resample_fraction: float = 0.5
73
+ seed: int = 0
74
+
75
+
76
+ class ParticleFilter(Generic[S, A]):
77
+ """Importance-corrected SMC runtime.
78
+
79
+ `propose(state, t)` returns a discrete proposal distribution q(a|state).
80
+ `transition(state, action, t)` produces the next state.
81
+ `log_target_increment(prev, action, nxt, t)` supplies the incremental
82
+ log target density. The importance update is log w += log target - log q.
83
+ Resampling is never performed after the final weighting step, so the final
84
+ posterior weights are preserved.
85
+ """
86
+
87
+ def __init__(self, config: SMCConfig | None = None):
88
+ self.config = config or SMCConfig()
89
+
90
+ def run(
91
+ self,
92
+ initial_state: S,
93
+ propose: Callable[[S, int], Sequence[Proposal[A]]],
94
+ transition: Callable[[S, A, int], S],
95
+ log_target_increment: Callable[[S, A, S, int], float],
96
+ ) -> list[Particle[S]]:
97
+ cfg = self.config
98
+ rng = random.Random(cfg.seed)
99
+ particles = [Particle(state=initial_state) for _ in range(cfg.n_particles)]
100
+ for t in range(cfg.n_steps):
101
+ nxt: list[Particle[S]] = []
102
+ for p in particles:
103
+ proposals = [x for x in propose(p.state, t) if x.probability > 0.0]
104
+ if not proposals:
105
+ raise ValueError("proposal distribution is empty")
106
+ z = sum(x.probability for x in proposals)
107
+ probs = [x.probability / z for x in proposals]
108
+ r = rng.random()
109
+ acc = 0.0
110
+ j = len(proposals) - 1
111
+ for i, pr in enumerate(probs):
112
+ acc += pr
113
+ if r <= acc:
114
+ j = i
115
+ break
116
+ selected = proposals[j]
117
+ q = probs[j]
118
+ new_state = transition(p.state, selected.action, t)
119
+ inc = float(log_target_increment(p.state, selected.action, new_state, t))
120
+ nxt.append(Particle(new_state, p.logw + inc - math.log(q), p.history + [selected.action]))
121
+ particles = nxt
122
+ probs_now = normalize_logweights([p.logw for p in particles])
123
+ if t < cfg.n_steps - 1 and effective_sample_size(probs_now) < cfg.ess_resample_fraction * len(particles):
124
+ ids = systematic_resample(probs_now, rng)
125
+ particles = [Particle(particles[i].state, 0.0, list(particles[i].history)) for i in ids]
126
+ final_probs = normalize_logweights([p.logw for p in particles])
127
+ for p, pr in zip(particles, final_probs):
128
+ setattr(p, "posterior", pr)
129
+ return particles
tests/test_core.py ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import math
2
+ from bayesian_langchain.core import BayesianIndex, DocumentRecord
3
+ from bayesian_langchain.smc import normalize_logweights
4
+
5
+
6
+ def test_all_negative_infinity_weights_become_uniform():
7
+ p = normalize_logweights([float('-inf'), float('-inf'), float('-inf')])
8
+ assert p == [1/3, 1/3, 1/3]
9
+
10
+
11
+ def test_authoritative_current_document_beats_stale_high_overlap():
12
+ docs = [
13
+ DocumentRecord('stale','The current HR portal is WorkSphere.','Current HR portal','cache',{'reliability_alpha':6,'reliability_beta':6,'authority':0.45,'freshness':0.18}),
14
+ DocumentRecord('official','Leave requests and payslips are now handled in PeopleHub.','Migration complete','official',{'reliability_alpha':18,'reliability_beta':2,'authority':0.98,'freshness':1.0}),
15
+ ]
16
+ hits = BayesianIndex(docs).search('What is the current HR portal for leave requests and payslips?', k=2)
17
+ assert hits[0].document.id == 'official'
tests/test_framework_rag_e2e.py ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ from evaluation.framework_rag_benchmark import run_framework_benchmark
2
+
3
+
4
+ def test_real_langchain_framework_rag_suite():
5
+ result = run_framework_benchmark()
6
+ assert result["clean"]["passed"] == result["clean"]["n_cases"] == 8
7
+ assert result["conflict"]["passed"] == result["conflict"]["n_cases"] == 18
tests/test_langchain_integration.py ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from langchain_core.runnables import RunnableLambda
2
+
3
+ from bayesian_langchain import BayesianIndex, BayesianRAG, BayesianRetriever, DocumentRecord
4
+
5
+
6
+ def _docs():
7
+ return [
8
+ DocumentRecord(
9
+ 'd1',
10
+ 'The access code is ORBIT-7.',
11
+ 'Access',
12
+ 'official',
13
+ {'reliability_alpha':10,'reliability_beta':1,'authority':0.99,'freshness':1.0},
14
+ )
15
+ ]
16
+
17
+
18
+ def test_real_langchain_retriever_and_runnable():
19
+ idx = BayesianIndex(_docs())
20
+ retriever = BayesianRetriever(index=idx, k=1)
21
+ out = retriever.invoke('What is the access code?')
22
+ assert out[0].metadata['id'] == 'd1'
23
+ assert out[0].metadata['posterior'] > 0.0
24
+ rag = BayesianRAG(idx)
25
+ ans = rag.as_runnable().invoke('What is the access code?')
26
+ assert 'ORBIT-7' in ans['answer']
27
+ assert ans['citations'] == ['d1']
28
+
29
+
30
+ def test_langchain_model_runnable_generation_branch():
31
+ idx = BayesianIndex(_docs())
32
+ generator = RunnableLambda(lambda prompt: 'The access code is ORBIT-7. [d1]')
33
+ rag = BayesianRAG(idx, generator=generator, k=1)
34
+ out = rag.as_runnable().invoke('What is the access code?')
35
+ assert out['answer'] == 'The access code is ORBIT-7. [d1]'
36
+ assert out['citations'] == ['d1']
37
+ assert not out['abstained']
tests/test_rag_e2e.py ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ from evaluation.rag_benchmark import run_benchmark
2
+
3
+
4
+ def test_end_to_end_rag_benchmark():
5
+ x = run_benchmark()
6
+ assert x['clean']['bayesian']['end_to_end_accuracy'] == 1.0
7
+ assert x['conflict']['bayesian']['recall_at_1'] == 1.0
8
+ assert x['conflict']['bayesian']['abstention_accuracy'] == 1.0
9
+ assert x['conflict']['bayesian']['grounding_accuracy'] == 1.0
tests/test_smc_math.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import math
2
+
3
+
4
+ def _imports():
5
+ try:
6
+ from bayesian_langchain.smc import ParticleFilter, Proposal, SMCConfig
7
+ except ImportError:
8
+ from bayesian_langgraph.smc import ParticleFilter, Proposal, SMCConfig
9
+ return ParticleFilter, Proposal, SMCConfig
10
+
11
+
12
+ def test_final_step_preserves_nonuniform_posterior_weights():
13
+ ParticleFilter, Proposal, SMCConfig = _imports()
14
+ pf = ParticleFilter(SMCConfig(n_particles=64, n_steps=1, ess_resample_fraction=0.99, seed=7))
15
+
16
+ def propose(state, t):
17
+ return [Proposal("good", 0.5), Proposal("bad", 0.5)]
18
+
19
+ def transition(state, action, t):
20
+ return action
21
+
22
+ def log_target(prev, action, nxt, t):
23
+ return 0.0 if action == "good" else math.log(1e-5)
24
+
25
+ particles = pf.run(None, propose, transition, log_target)
26
+ probs = [p.posterior for p in particles]
27
+ assert abs(sum(probs) - 1.0) < 1e-12
28
+ # If the old final-resampling bug returned, these would all be 1/N.
29
+ assert max(probs) - min(probs) > 1e-3
30
+
31
+
32
+ def test_importance_weight_contains_inverse_proposal_probability():
33
+ ParticleFilter, Proposal, SMCConfig = _imports()
34
+ pf = ParticleFilter(SMCConfig(n_particles=256, n_steps=1, seed=11))
35
+
36
+ def propose(state, t):
37
+ return [Proposal("a", 0.9), Proposal("b", 0.1)]
38
+
39
+ def transition(state, action, t):
40
+ return action
41
+
42
+ # Equal target mass for both actions. Importance correction should cancel
43
+ # the 9:1 proposal imbalance in the weighted posterior, up to MC error.
44
+ def log_target(prev, action, nxt, t):
45
+ return math.log(0.5)
46
+
47
+ particles = pf.run(None, propose, transition, log_target)
48
+ mass = {"a": 0.0, "b": 0.0}
49
+ for p in particles:
50
+ mass[p.state] += p.posterior
51
+ assert abs(mass["a"] - 0.5) < 0.15
52
+ assert abs(mass["b"] - 0.5) < 0.15