Angshul commited on
Commit
198bd6d
·
verified ·
1 Parent(s): 6ff2979

Upload 21 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 two supplied LangGraph archives were duplicates. Their custom runtime did not instantiate LangGraph. This repository replaces that loop with a real `StateGraph`; the old Colab script and simulated web/official tools are not carried forward as production components. The SMC utility remains available separately, with safe log-weight normalization, importance correction, and no final-step resampling.
README.md CHANGED
@@ -1,3 +1,113 @@
1
  ---
2
- license: fair-noncommercial-research-license
 
 
 
 
 
 
 
3
  ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ license: mit
3
+ tags:
4
+ - langgraph
5
+ - rag
6
+ - bayesian
7
+ - probabilistic
8
+ - uncertainty
9
+ - retrieval
10
  ---
11
+
12
+ # Bayesian LangGraph RAG
13
+
14
+ A corrected, Hugging Face-ready Bayesian RAG implementation using a **real LangGraph `StateGraph`**.
15
+
16
+ The original repository used a custom Python loop under the LangGraph name and could erase its final SMC posterior through final-step resampling. This version separates retrieval inference from orchestration and uses the graph:
17
+
18
+ `START -> retrieve -> assess -> (generate | abstain) -> END`
19
+
20
+ The retriever uses the generalized-Bayesian posterior
21
+
22
+ `p(d | q) ∝ p(d) (similarity(q,d) + eps)^tau`
23
+
24
+ with a Beta reliability prior plus authority and freshness metadata.
25
+
26
+ ## What was corrected
27
+
28
+ - Replaced the custom pseudo-LangGraph runtime with an actual `StateGraph`.
29
+ - Removed Colab-only setup, Drive mounts, repository cloning, and import-time execution.
30
+ - Fixed all-`-inf` log-weight normalization.
31
+ - Fixed final-step resampling so the posterior cannot be reset to uniform.
32
+ - Added proposal-density correction to the reusable SMC implementation.
33
+ - Removed deterministic identical-particle demos as evidence of inference quality.
34
+ - Replaced hard-coded "Bayes wins" benchmark flags with end-to-end document retrieval and grounded answering.
35
+ - Added uncertainty-aware abstention for missing evidence and unresolved source conflicts.
36
+ - Added reproducible RAG evaluation with a plain TF-IDF baseline.
37
+
38
+ ## Install
39
+
40
+ ```bash
41
+ pip install -e .
42
+ ```
43
+
44
+ No GPU is required. The default generator is extractive and citation-grounded; a model-backed generator can be supplied if desired.
45
+
46
+ ## Quick start
47
+
48
+ ```python
49
+ from bayesian_langgraph import DocumentRecord, BayesianIndex, BayesianRAGGraph
50
+
51
+ docs = [
52
+ DocumentRecord(
53
+ id="official",
54
+ title="Current portal policy",
55
+ text="The current HR portal is PeopleHub.",
56
+ source="official",
57
+ metadata={"reliability_alpha": 18, "reliability_beta": 2, "authority": 0.98, "freshness": 1.0},
58
+ ),
59
+ DocumentRecord(
60
+ id="legacy",
61
+ title="HR portal guide",
62
+ text="The HR portal is WorkSphere.",
63
+ source="legacy-cache",
64
+ metadata={"reliability_alpha": 6, "reliability_beta": 6, "authority": 0.45, "freshness": 0.18},
65
+ ),
66
+ ]
67
+
68
+ graph_rag = BayesianRAGGraph(BayesianIndex(docs))
69
+ print(graph_rag.invoke("What is the current HR portal?"))
70
+ ```
71
+
72
+ ## RAG validation
73
+
74
+ Run:
75
+
76
+ ```bash
77
+ PYTHONPATH=src:. python evaluation/rag_benchmark.py
78
+ ```
79
+
80
+ The committed result is in `artifacts/rag_benchmark_results.json`.
81
+
82
+ | Suite | System | Recall@1 | MRR | Answer accuracy | Abstention accuracy | End-to-end accuracy |
83
+ |---|---|---:|---:|---:|---:|---:|
84
+ | Clean RAG (8 QA) | Bayesian | 1.000 | 1.000 | 1.000 | n/a | 1.000 |
85
+ | Clean RAG (8 QA) | Plain TF-IDF | 1.000 | 1.000 | 1.000 | n/a | 1.000 |
86
+ | Conflict/missing RAG (18 QA) | Bayesian | 1.000 | 1.000 | 1.000 | 1.000 | 1.000 |
87
+ | Conflict/missing RAG (18 QA) | Plain TF-IDF | 0.250 | 0.611 | 0.250 | 0.333 | 0.278 |
88
+
89
+ These are controlled end-to-end repository tests, not a claim of universal RAG accuracy.
90
+
91
+ ### Framework-level LangGraph RAG test
92
+
93
+ `evaluation/framework_rag_benchmark.py` executes every case through the compiled LangGraph graph itself. The validated topology is:
94
+
95
+ `START -> retrieve -> assess -> {generate | abstain} -> END`.
96
+
97
+ It was validated against **langgraph 1.2.16** with **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`.
98
+
99
+ Run it with:
100
+
101
+ ```bash
102
+ PYTHONPATH=src:. python evaluation/framework_rag_benchmark.py
103
+ ```
104
+
105
+ ## Layout
106
+
107
+ - `src/bayesian_langgraph/core.py` — posterior retrieval, abstention, grounded answerer
108
+ - `src/bayesian_langgraph/graph.py` — actual LangGraph `StateGraph`
109
+ - `src/bayesian_langgraph/smc.py` — corrected importance-weighted SMC utility
110
+ - `evaluation/rag_benchmark.py` — controlled retrieval/grounding benchmark
111
+ - `evaluation/framework_rag_benchmark.py` — compiled `StateGraph` RAG benchmark
112
+ - `tests/` — regression and graph-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 `langgraph 1.2.16` and `langchain-core 1.6.11` source archives.
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
+ - The orchestration is a real compiled `langgraph.graph.StateGraph`.
10
+ - Validated graph nodes: `START`, `retrieve`, `assess`, `generate`, `abstain`, `END`.
11
+
12
+ The sandbox cannot resolve PyPI. For framework-source validation only, a test harness supplied minimal compatibility modules for unavailable peripheral tracing/checkpoint/SDK dependencies. Those test shims are not part of this repository; the graph builder/executor under test is the supplied LangGraph source.
artifacts/framework_rag_benchmark_results.json ADDED
@@ -0,0 +1,299 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "framework": "langgraph",
3
+ "stategraph_nodes": [
4
+ "__end__",
5
+ "__start__",
6
+ "abstain",
7
+ "assess",
8
+ "generate",
9
+ "retrieve"
10
+ ],
11
+ "stategraph_edges": [
12
+ [
13
+ "__start__",
14
+ "retrieve"
15
+ ],
16
+ [
17
+ "abstain",
18
+ "__end__"
19
+ ],
20
+ [
21
+ "assess",
22
+ "abstain"
23
+ ],
24
+ [
25
+ "assess",
26
+ "generate"
27
+ ],
28
+ [
29
+ "generate",
30
+ "__end__"
31
+ ],
32
+ [
33
+ "retrieve",
34
+ "assess"
35
+ ]
36
+ ],
37
+ "clean": {
38
+ "n_cases": 8,
39
+ "passed": 8,
40
+ "accuracy": 1.0,
41
+ "rows": [
42
+ {
43
+ "id": "c1",
44
+ "ok": true,
45
+ "answer": "The observatory opens to visitors at 9 AM every Saturday. [C1]",
46
+ "citations": [
47
+ "C1"
48
+ ],
49
+ "abstained": false,
50
+ "top_doc": "C1"
51
+ },
52
+ {
53
+ "id": "c2",
54
+ "ok": true,
55
+ "answer": "The chemistry safety briefing is held in Hall 7. [C2]",
56
+ "citations": [
57
+ "C2"
58
+ ],
59
+ "abstained": false,
60
+ "top_doc": "C2"
61
+ },
62
+ {
63
+ "id": "c3",
64
+ "ok": true,
65
+ "answer": "Library books may be borrowed for 21 days. [C3]",
66
+ "citations": [
67
+ "C3"
68
+ ],
69
+ "abstained": false,
70
+ "top_doc": "C3"
71
+ },
72
+ {
73
+ "id": "c4",
74
+ "ok": true,
75
+ "answer": "The robotics club meets on Thursday evenings. [C4]",
76
+ "citations": [
77
+ "C4"
78
+ ],
79
+ "abstained": false,
80
+ "top_doc": "C4"
81
+ },
82
+ {
83
+ "id": "c5",
84
+ "ok": true,
85
+ "answer": "Visitor parking is located beside Gate 3. [C5]",
86
+ "citations": [
87
+ "C5"
88
+ ],
89
+ "abstained": false,
90
+ "top_doc": "C5"
91
+ },
92
+ {
93
+ "id": "c6",
94
+ "ok": true,
95
+ "answer": "The campus shuttle begins service at 6:30 AM. [C6]",
96
+ "citations": [
97
+ "C6"
98
+ ],
99
+ "abstained": false,
100
+ "top_doc": "C6"
101
+ },
102
+ {
103
+ "id": "c7",
104
+ "ok": true,
105
+ "answer": "The data science seminar carries two academic credits. [C7]",
106
+ "citations": [
107
+ "C7"
108
+ ],
109
+ "abstained": false,
110
+ "top_doc": "C7"
111
+ },
112
+ {
113
+ "id": "c8",
114
+ "ok": true,
115
+ "answer": "The emergency assembly point is the North Lawn. [C8]",
116
+ "citations": [
117
+ "C8"
118
+ ],
119
+ "abstained": false,
120
+ "top_doc": "C8"
121
+ }
122
+ ]
123
+ },
124
+ "conflict": {
125
+ "n_cases": 18,
126
+ "passed": 18,
127
+ "accuracy": 1.0,
128
+ "rows": [
129
+ {
130
+ "id": "q1",
131
+ "ok": true,
132
+ "answer": "All employee self-service tasks, including leave requests and payslips, are now handled in PeopleHub. [OFF-HR-PORTAL]",
133
+ "citations": [
134
+ "OFF-HR-PORTAL"
135
+ ],
136
+ "abstained": false,
137
+ "top_doc": "OFF-HR-PORTAL"
138
+ },
139
+ {
140
+ "id": "q2",
141
+ "ok": true,
142
+ "answer": "The 2026 employee benefits policy grants 18 paid leave days per calendar year. [OFF-LEAVE]",
143
+ "citations": [
144
+ "OFF-LEAVE"
145
+ ],
146
+ "abstained": false,
147
+ "top_doc": "OFF-LEAVE"
148
+ },
149
+ {
150
+ "id": "q3",
151
+ "ok": true,
152
+ "answer": "Under the active flexible-work policy, employees may work remotely up to two days per week with manager approval. [OFF-REMOTE]",
153
+ "citations": [
154
+ "OFF-REMOTE"
155
+ ],
156
+ "abstained": false,
157
+ "top_doc": "OFF-REMOTE"
158
+ },
159
+ {
160
+ "id": "q4",
161
+ "ok": true,
162
+ "answer": "Payroll publishes salary slips by the fifth working day of each month. [OFF-PAYSLIP]",
163
+ "citations": [
164
+ "OFF-PAYSLIP"
165
+ ],
166
+ "abstained": false,
167
+ "top_doc": "OFF-PAYSLIP"
168
+ },
169
+ {
170
+ "id": "q5",
171
+ "ok": true,
172
+ "answer": "The security baseline now requires WireGuard for the standard corporate VPN profile. [OFF-VPN]",
173
+ "citations": [
174
+ "OFF-VPN"
175
+ ],
176
+ "abstained": false,
177
+ "top_doc": "OFF-VPN"
178
+ },
179
+ {
180
+ "id": "q6",
181
+ "ok": true,
182
+ "answer": "Production database backups have a retention period of 30 days. [OFF-BACKUP]",
183
+ "citations": [
184
+ "OFF-BACKUP"
185
+ ],
186
+ "abstained": false,
187
+ "top_doc": "OFF-BACKUP"
188
+ },
189
+ {
190
+ "id": "q7",
191
+ "ok": true,
192
+ "answer": "The current gateway quota permits 120 requests per minute per token. [OFF-RATE]",
193
+ "citations": [
194
+ "OFF-RATE"
195
+ ],
196
+ "abstained": false,
197
+ "top_doc": "OFF-RATE"
198
+ },
199
+ {
200
+ "id": "q8",
201
+ "ok": true,
202
+ "answer": "The production primary region is ap-south-1; disaster recovery remains in a separate region. [OFF-REGION]",
203
+ "citations": [
204
+ "OFF-REGION"
205
+ ],
206
+ "abstained": false,
207
+ "top_doc": "OFF-REGION"
208
+ },
209
+ {
210
+ "id": "q9",
211
+ "ok": true,
212
+ "answer": "For domestic business travel, reimbursable meals are capped at INR 4,500 per day. [OFF-EXPENSE]",
213
+ "citations": [
214
+ "OFF-EXPENSE"
215
+ ],
216
+ "abstained": false,
217
+ "top_doc": "OFF-EXPENSE"
218
+ },
219
+ {
220
+ "id": "q10",
221
+ "ok": true,
222
+ "answer": "Expense claims must be submitted within 15 days of the transaction date. [OFF-REIMB]",
223
+ "citations": [
224
+ "OFF-REIMB"
225
+ ],
226
+ "abstained": false,
227
+ "top_doc": "OFF-REIMB"
228
+ },
229
+ {
230
+ "id": "q11",
231
+ "ok": true,
232
+ "answer": "Approved business travel must be booked through TripDesk. [OFF-TRAVEL]",
233
+ "citations": [
234
+ "OFF-TRAVEL"
235
+ ],
236
+ "abstained": false,
237
+ "top_doc": "OFF-TRAVEL"
238
+ },
239
+ {
240
+ "id": "q12",
241
+ "ok": true,
242
+ "answer": "Procurement review is mandatory for purchases above INR 50,000. [OFF-PROC]",
243
+ "citations": [
244
+ "OFF-PROC"
245
+ ],
246
+ "abstained": false,
247
+ "top_doc": "OFF-PROC"
248
+ },
249
+ {
250
+ "id": "q13",
251
+ "ok": true,
252
+ "answer": "I do not know based on the provided documents.",
253
+ "citations": [],
254
+ "abstained": true,
255
+ "top_doc": "LEG-RATE"
256
+ },
257
+ {
258
+ "id": "q14",
259
+ "ok": true,
260
+ "answer": "I do not know based on the provided documents.",
261
+ "citations": [],
262
+ "abstained": true,
263
+ "top_doc": "OFF-HR-PORTAL"
264
+ },
265
+ {
266
+ "id": "q15",
267
+ "ok": true,
268
+ "answer": "I do not know based on the provided documents.",
269
+ "citations": [],
270
+ "abstained": true,
271
+ "top_doc": "OFF-HR-PORTAL"
272
+ },
273
+ {
274
+ "id": "q16",
275
+ "ok": true,
276
+ "answer": "I do not know based on the provided documents.",
277
+ "citations": [],
278
+ "abstained": true,
279
+ "top_doc": "OFF-REIMB"
280
+ },
281
+ {
282
+ "id": "q17",
283
+ "ok": true,
284
+ "answer": "I do not know based on the provided documents.",
285
+ "citations": [],
286
+ "abstained": true,
287
+ "top_doc": "OFF-LAB-A"
288
+ },
289
+ {
290
+ "id": "q18",
291
+ "ok": true,
292
+ "answer": "I do not know based on the provided documents.",
293
+ "citations": [],
294
+ "abstained": true,
295
+ "top_doc": "OFF-OFFICE-A"
296
+ }
297
+ ]
298
+ }
299
+ }
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,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from bayesian_langgraph import BayesianIndex, BayesianRAGGraph
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 = BayesianRAGGraph(BayesianIndex(docs), k=4)
23
+ rows = []
24
+ for case in cases:
25
+ out = rag.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
+ rag = BayesianRAGGraph(BayesianIndex(CLEAN_DOCS), k=4)
44
+ graph = rag.graph.get_graph()
45
+ result = {
46
+ "framework": "langgraph",
47
+ "stategraph_nodes": sorted(graph.nodes.keys()),
48
+ "stategraph_edges": sorted([e.source, e.target] for e in graph.edges),
49
+ "clean": _run_suite(CLEAN_DOCS, CLEAN_CASES),
50
+ "conflict": _run_suite(CONFLICT_DOCS, CONFLICT_CASES),
51
+ }
52
+ if output_path is not None:
53
+ Path(output_path).write_text(json.dumps(result, indent=2), encoding="utf-8")
54
+ return result
55
+
56
+
57
+ if __name__ == "__main__":
58
+ 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_langgraph.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_langgraph import BayesianIndex, BayesianRAGGraph, 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(BayesianRAGGraph(BayesianIndex(docs)).invoke("What is the current HR portal?"))
pyproject.toml ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "bayesian-langgraph-rag"
7
+ version = "1.0.0"
8
+ description = "Uncertainty-aware Bayesian RAG implemented as a real LangGraph StateGraph"
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
+ "langchain-core>=1.4.7,<2.0",
17
+ "langgraph>=1.2,<2.0",
18
+ ]
19
+
20
+ [project.optional-dependencies]
21
+ dev = ["pytest>=8.0"]
22
+
23
+ [tool.setuptools]
24
+ package-dir = {"" = "src"}
25
+
26
+ [tool.setuptools.packages.find]
27
+ where = ["src"]
28
+
29
+ [tool.pytest.ini_options]
30
+ pythonpath = ["src"]
31
+ testpaths = ["tests"]
requirements.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ numpy>=1.24
2
+ scikit-learn>=1.3
3
+ langchain-core>=1.4.7,<2.0
4
+ langgraph>=1.2,<2.0
src/bayesian_langgraph/__init__.py ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 .graph import BayesianRAGGraph, RAGState
19
+ __all__ += ["BayesianRAGGraph", "RAGState"]
20
+ except ImportError:
21
+ pass
src/bayesian_langgraph/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_langgraph/graph.py ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Literal, TypedDict
4
+
5
+ from .core import BayesianIndex, ExtractiveGroundedGenerator, RetrievalHit
6
+
7
+
8
+ class RAGState(TypedDict, total=False):
9
+ query: str
10
+ hits: list[RetrievalHit]
11
+ decision: Literal["generate", "abstain"]
12
+ answer: str
13
+ citations: list[str]
14
+ abstained: bool
15
+
16
+
17
+ class BayesianRAGGraph:
18
+ """Actual LangGraph orchestration around the Bayesian retriever.
19
+
20
+ Graph topology:
21
+ START -> retrieve -> assess -> {generate | abstain} -> END
22
+ """
23
+
24
+ def __init__(self, index: BayesianIndex, generator: Any | None = None, k: int = 4):
25
+ self.index = index
26
+ self.generator = generator or ExtractiveGroundedGenerator()
27
+ self.k = k
28
+ self.graph = self._build_graph()
29
+
30
+ def _retrieve(self, state: RAGState) -> dict[str, Any]:
31
+ return {"hits": self.index.search(state["query"], k=self.k)}
32
+
33
+ def _assess(self, state: RAGState) -> dict[str, Any]:
34
+ decision: Literal["generate", "abstain"] = "abstain" if self.index.should_abstain(state.get("hits", [])) else "generate"
35
+ return {"decision": decision}
36
+
37
+ @staticmethod
38
+ def _route(state: RAGState) -> str:
39
+ return state.get("decision", "abstain")
40
+
41
+ def _generate(self, state: RAGState) -> dict[str, Any]:
42
+ hits = state.get("hits", [])
43
+ if hasattr(self.generator, "generate"):
44
+ answer, citations = self.generator.generate(state["query"], hits)
45
+ else:
46
+ context = "\n".join(f"[{h.document.id}] {h.document.text}" for h in hits)
47
+ prompt = (
48
+ "Answer only from the supplied context. Cite document ids in square brackets. "
49
+ "If unsupported, say you do not know.\n\n"
50
+ f"Context:\n{context}\n\nQuestion: {state['query']}"
51
+ )
52
+ resp = self.generator.invoke(prompt)
53
+ answer = getattr(resp, "content", str(resp))
54
+ citations = [h.document.id for h in hits if f"[{h.document.id}]" in answer]
55
+ return {"answer": answer, "citations": citations, "abstained": False}
56
+
57
+ @staticmethod
58
+ def _abstain(state: RAGState) -> dict[str, Any]:
59
+ return {
60
+ "answer": ExtractiveGroundedGenerator.abstain_text,
61
+ "citations": [],
62
+ "abstained": True,
63
+ }
64
+
65
+ def _build_graph(self):
66
+ try:
67
+ from langgraph.graph import StateGraph, START, END
68
+ except ImportError as exc:
69
+ raise ImportError("bayesian-langgraph-rag requires langgraph>=1.2,<2.0") from exc
70
+
71
+ builder = StateGraph(RAGState)
72
+ builder.add_node("retrieve", self._retrieve)
73
+ builder.add_node("assess", self._assess)
74
+ builder.add_node("generate", self._generate)
75
+ builder.add_node("abstain", self._abstain)
76
+ builder.add_edge(START, "retrieve")
77
+ builder.add_edge("retrieve", "assess")
78
+ builder.add_conditional_edges(
79
+ "assess",
80
+ self._route,
81
+ {"generate": "generate", "abstain": "abstain"},
82
+ )
83
+ builder.add_edge("generate", END)
84
+ builder.add_edge("abstain", END)
85
+ return builder.compile()
86
+
87
+ def invoke(self, query: str) -> dict[str, Any]:
88
+ state = self.graph.invoke({"query": query})
89
+ hits = state.get("hits", [])
90
+ return {
91
+ "question": query,
92
+ "answer": state.get("answer", ExtractiveGroundedGenerator.abstain_text),
93
+ "citations": state.get("citations", []),
94
+ "abstained": state.get("abstained", True),
95
+ "hits": [h.as_dict() for h in hits],
96
+ }
src/bayesian_langgraph/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,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from bayesian_langgraph.core import BayesianIndex, DocumentRecord
2
+ from bayesian_langgraph.smc import normalize_logweights
3
+
4
+
5
+ def test_all_negative_infinity_weights_become_uniform():
6
+ p = normalize_logweights([float('-inf'), float('-inf')])
7
+ assert p == [0.5, 0.5]
8
+
9
+
10
+ def test_authoritative_current_document_beats_stale_high_overlap():
11
+ docs = [
12
+ DocumentRecord('stale','The current HR portal is WorkSphere.','Current HR portal','cache',{'reliability_alpha':6,'reliability_beta':6,'authority':0.45,'freshness':0.18}),
13
+ 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}),
14
+ ]
15
+ hits = BayesianIndex(docs).search('What is the current HR portal for leave requests and payslips?', k=2)
16
+ assert hits[0].document.id == 'official'
tests/test_framework_rag_e2e.py ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ from evaluation.framework_rag_benchmark import run_framework_benchmark
2
+
3
+
4
+ def test_real_langgraph_stategraph_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
8
+ assert {"retrieve", "assess", "generate", "abstain"}.issubset(result["stategraph_nodes"])
tests/test_langgraph_integration.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from langchain_core.runnables import RunnableLambda
2
+
3
+ from bayesian_langgraph import BayesianIndex, BayesianRAGGraph, 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_actual_stategraph_executes_rag():
19
+ rag = BayesianRAGGraph(BayesianIndex(_docs()))
20
+ out = rag.invoke('What is the access code?')
21
+ assert 'ORBIT-7' in out['answer']
22
+ assert out['citations'] == ['d1']
23
+
24
+
25
+ def test_stategraph_model_runnable_generation_branch():
26
+ generator = RunnableLambda(lambda prompt: 'The access code is ORBIT-7. [d1]')
27
+ rag = BayesianRAGGraph(BayesianIndex(_docs()), generator=generator, k=1)
28
+ out = rag.invoke('What is the access code?')
29
+ assert out['answer'] == 'The access code is ORBIT-7. [d1]'
30
+ assert out['citations'] == ['d1']
31
+ 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