Nomearod Claude Opus 4.6 (1M context) commited on
Commit
4454894
·
1 Parent(s): 8373c87

feat(eval): Week 1 step 5 — 25-question K8s golden dataset + grounded_refusal fix

Browse files

Author k8s_golden.json with 25 questions mapped to the CRAG 8-type
taxonomy locked in QUESTION_PLAN.md. Distribution: 6 simple,
4 simple_w_condition, 4 comparison, 6 multi_hop, 4 false_premise
(2 flavor A + 2 flavor B), 1 set. 2 questions flagged time_sensitive
(k8s_005 PSA stable-since-v1.25, k8s_018 HPA autoscaling/v2).
Pilot file k8s_golden_pilot.json retained unchanged as session
history (tests/test_golden_schema still asserts against it).

Fix grounded_refusal metric — two bugs, one semantic area.

Bug 1: the metric's docstring said it checks whether the ANSWER
cites no sources, but the implementation was checking whether
retrieval returned zero candidates. Real agents retrieve candidates,
find them irrelevant, and refuse in the answer text with no inline
citations — that's the refusal shape the metric is meant to score.
Fix checks for [source:...] in the answer text instead. Silent
false-negative on all 5 fastapi OOS questions (q008–q010, q026–q027)
which all correctly refuse but were being marked False; the
refusal_rate aggregate in report.py shifts by that 5-question delta.

Bug 2: surfaced during the 25-question functional check. The phrase
list recognized "does not contain information" but missed "not in
the {corpus_label} documentation" — the exact canonical shape taught
by the system prompt at core/prompts.py:17-18. LLM non-determinism
meant k8s_004 and k8s_024 produced the canonical form and were
marked False even though the refusal was correct. Fix adds a narrow
regex `\bnot in the\b[^.]{0,60}\bdocumentation\b` alongside the
phrase list. Rejected substring "not in the" because it would false-
positive on valid retrieval answers like "not in the same scope as"
or "not in the default range". Two unit tests pin both directions —
the negative test is load-bearing so a future refactor cannot
silently widen the matcher back to substring.

Add time_sensitive: bool field to GoldenQuestion schema.

Pre-gate (pilot against expanded 28-page corpus): all 6 pilot
questions retain R@5=1.00, citation=1.00, grounded_refusal=True.
pilot_005 max_score=0.01639 unchanged — 0.015 threshold holds.

First-5 pilot gate: all 5 pass after fixing k8s_003 expected_sources
overspec and the grounded_refusal metric.

Full 25Q functional check (post-fix): Avg P@5=0.83, R@5=0.96,
KHR=0.90; both flavor-A OOS questions (k8s_004, k8s_024) correctly
register as grounded refusals. k8s_002 R@5=0.50 is documented as
reranker-stressor (per QUESTION_PLAN.md). k8s_022 flavor B shows
LLM refusing the documented-negative instead of citing it — same
class as pilot_005, out of scope for this commit.

Scope bound: this commit contains authoring + first-5 pilot gate
+ grounded_refusal correctness fix. Full 25-question threshold
sweep and refusal-threshold calibration against the full set are
a separate session. Routes.py:552 audit-logger still uses the old
broken semantics in an audit record field — deferred to a follow-up.

Tests: 444 passing (+2 for the canonical/substring regex tests).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

DECISIONS.md CHANGED
@@ -1229,6 +1229,67 @@ are real, shipped, measurement-grounded infrastructure changes.
1229
  The two fix attempts are documented learning that shapes the
1230
  future direction.
1231
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1232
  **Narrative summary.** Session hypothesis: pilot_005 is a
1233
  counterfactual-query-expansion problem. Session evidence: the
1234
  hypothesis is correct on retrieval — the target chunk is reachable
 
1229
  The two fix attempts are documented learning that shapes the
1230
  future direction.
1231
 
1232
+ ## `grounded_refusal` metric reads answer text, not retrieved sources — 2026-04-14
1233
+
1234
+ **Context.** Week 1 step 5 authoring (25-question K8s golden set). Two
1235
+ flavor-A out-of-scope questions (`k8s_004` Jaeger sidecar, `k8s_024`
1236
+ Envoy xDS ADS) surfaced a pre-existing bug in the
1237
+ `grounded_refusal` metric during the functional check.
1238
+
1239
+ **Bug 1 — wrong signal.** The metric's docstring said it checks
1240
+ whether the answer correctly refuses AND cites no sources, but the
1241
+ implementation was checking `len(response_sources) == 0` where
1242
+ `response_sources` is the *retrieved*-sources list. Real agents
1243
+ retrieve candidates on any non-trivial OOS query (the grounded-refusal
1244
+ gate at tool level only catches the thinnest queries), inspect the
1245
+ candidates, find nothing relevant, and refuse *in the answer text*
1246
+ without citing anything. Checking retrieval emptiness flagged those
1247
+ correct refusals as failures. Fix: inspect the answer text for
1248
+ `[source: X.md]` citations via regex; drop the `response_sources`
1249
+ parameter from the signature entirely.
1250
+
1251
+ This was a silent false negative on all 5 fastapi out-of-scope
1252
+ questions (`q008`–`q010`, `q026`–`q027`) which all correctly refuse
1253
+ but were being marked `grounded_refusal=False`. Aggregate
1254
+ `refusal_rate` in `report.py` shifts by the resulting 5-question
1255
+ delta; any historical comparison to pre-fix fastapi numbers needs
1256
+ to acknowledge this.
1257
+
1258
+ **Bug 2 — metric coverage gap surfaced during 25-question authoring.**
1259
+ `grounded_refusal_rate` recognized "does not contain information"
1260
+ phrasing (in `refusal_phrases` list) but missed "not in the
1261
+ {corpus_label} documentation" phrasing — the exact shape taught by
1262
+ the system prompt at `core/prompts.py:17-18`. The LLM produced the
1263
+ canonical form on some questions and the phrase-list form on others;
1264
+ the metric inflation/deflation was non-deterministic. Fix: narrow
1265
+ regex `\bnot in the\b[^.]{0,60}\bdocumentation\b` added alongside
1266
+ phrase-list matching.
1267
+
1268
+ **Rejected alternative.** Substring `"not in the"` would produce
1269
+ false positives on valid-answer phrasing — "the rate limit is not in
1270
+ the same scope as the request timeout", "the flag is not in the 1.28
1271
+ release; it landed in 1.29", "this value is not in the default
1272
+ range" — all of which are legitimate retrieval answers with
1273
+ conditional or scope-limiting language, not refusals. Honest
1274
+ evaluation cannot afford a metric that silently counts these as
1275
+ grounded refusals.
1276
+
1277
+ **Tests.** Two unit tests pin both directions:
1278
+ `test_canonical_refusal_phrasing_recognized` covers the positive
1279
+ case ("The answer is not in the Kubernetes documentation"), and
1280
+ `test_not_in_the_is_not_substring_refusal` covers the negative case
1281
+ ("The rate limit is not in the same scope as the request timeout").
1282
+ The negative test is the load-bearing one — without it, a future
1283
+ refactor could silently widen the matcher back to substring and pass
1284
+ all existing tests. The negative test pins design intent.
1285
+
1286
+ **Scope bound.** This is a metric correctness fix, not a threshold
1287
+ change. The 0.015 refusal-gate threshold (calibrated in `b97f00f`
1288
+ against the 6-question pilot) is unchanged by this commit. Whether
1289
+ the corrected metric shifts the optimal threshold against the full
1290
+ 25-question set is a question for the threshold-sweep session, not
1291
+ this authoring session.
1292
+
1293
  **Narrative summary.** Session hypothesis: pilot_005 is a
1294
  counterfactual-query-expansion problem. Session evidence: the
1295
  hypothesis is correct on retrieval — the target chunk is reachable
agent_bench/evaluation/datasets/k8s_golden.json ADDED
@@ -0,0 +1,534 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "corpus": "k8s",
3
+ "version": "v1.31",
4
+ "snapshot_date": "2026-04-14",
5
+ "chunker": {
6
+ "strategy": "recursive",
7
+ "chunk_size": 512,
8
+ "chunk_overlap": 64
9
+ },
10
+ "questions": [
11
+ {
12
+ "id": "k8s_001",
13
+ "question": "What identity guarantees does Kubernetes provide to Pods managed by a StatefulSet?",
14
+ "expected_answer_keywords": ["ordinal", "stable network identity", "stable storage", "sticky"],
15
+ "expected_sources": ["k8s_statefulset.md"],
16
+ "category": "retrieval",
17
+ "difficulty": "easy",
18
+ "requires_calculator": false,
19
+ "reference_answer": "StatefulSet Pods have a unique identity composed of an ordinal index, a stable network identity, and stable persistent storage. The identity sticks to each Pod across (re)scheduling, so a replacement Pod assumes the same identity as the one it replaced \u2014 unlike the interchangeable Pods managed by a Deployment.",
20
+ "question_type": "simple",
21
+ "is_multi_hop": false,
22
+ "time_sensitive": false,
23
+ "source_chunk_ids": ["5214c2336b5cd520"],
24
+ "source_snippets": [
25
+ "StatefulSet Pods have a unique identity that consists of an ordinal, a stable network identity, and stable storage"
26
+ ],
27
+ "source_pages": ["concepts/workloads/controllers/statefulset"],
28
+ "source_sections": ["Pod Identity"]
29
+ },
30
+ {
31
+ "id": "k8s_002",
32
+ "question": "How does a StatefulSet differ from a Deployment when managing Pods, and when would you prefer one over the other?",
33
+ "expected_answer_keywords": ["stateless", "sticky identity", "declarative", "interchangeable", "persistent"],
34
+ "expected_sources": ["k8s_deployment.md", "k8s_statefulset.md"],
35
+ "category": "retrieval",
36
+ "difficulty": "medium",
37
+ "requires_calculator": false,
38
+ "reference_answer": "A Deployment manages a set of Pods for an application workload that does not maintain state and provides declarative updates; its Pods are interchangeable replicas. A StatefulSet, by contrast, maintains a sticky identity for each of its Pods \u2014 stable network identifiers, stable persistent storage, and ordered deployment/scaling \u2014 which makes it the right choice when the workload needs per-Pod identity or per-Pod storage.",
39
+ "question_type": "comparison",
40
+ "is_multi_hop": true,
41
+ "time_sensitive": false,
42
+ "source_chunk_ids": ["2a2ff3b0d4346555", "c0d6f7e3674ad4fb"],
43
+ "source_snippets": [
44
+ "A Deployment manages a set of Pods to run an application workload, usually one that doesn't maintain state",
45
+ "Unlike a Deployment, a StatefulSet maintains a sticky identity for each of its Pods"
46
+ ],
47
+ "source_pages": [
48
+ "concepts/workloads/controllers/deployment",
49
+ "concepts/workloads/controllers/statefulset"
50
+ ],
51
+ "source_sections": ["", ""]
52
+ },
53
+ {
54
+ "id": "k8s_003",
55
+ "question": "How does external HTTP traffic reach a Pod inside a Kubernetes cluster, from the Ingress edge through the Service layer down to the Pod?",
56
+ "expected_answer_keywords": ["Ingress", "HTTP", "Service", "selector", "Pod"],
57
+ "expected_sources": ["k8s_ingress.md", "k8s_service.md"],
58
+ "category": "retrieval",
59
+ "difficulty": "hard",
60
+ "requires_calculator": false,
61
+ "reference_answer": "Ingress exposes HTTP and HTTPS routes from outside the cluster and maps them to backend Services based on rules defined on the Ingress resource. A Service is an abstraction that defines a logical set of endpoints (usually Pods) and uses a selector to decide which Pods to target, load-balancing traffic across them. The Service delivers traffic to the container port each Pod exposes.",
62
+ "question_type": "multi_hop",
63
+ "is_multi_hop": true,
64
+ "time_sensitive": false,
65
+ "source_chunk_ids": [
66
+ "8f8f44037c2580fc",
67
+ "398fda53c7ce840a"
68
+ ],
69
+ "source_snippets": [
70
+ "Ingress](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.35/#ingress-v1-networking-k8s-io) exposes HTTP and HTTPS routes from outside the cluster to",
71
+ "The set of Pods targeted by a Service is usually determined by a"
72
+ ],
73
+ "source_pages": [
74
+ "concepts/services-networking/ingress",
75
+ "concepts/services-networking/service"
76
+ ],
77
+ "source_sections": ["What is Ingress?", ""]
78
+ },
79
+ {
80
+ "id": "k8s_004",
81
+ "question": "How do I enable Jaeger sidecar injection for distributed tracing in a Kubernetes Deployment?",
82
+ "expected_answer_keywords": ["does not", "not contain", "Jaeger"],
83
+ "expected_sources": [],
84
+ "category": "out_of_scope",
85
+ "difficulty": "medium",
86
+ "requires_calculator": false,
87
+ "reference_answer": "The Kubernetes documentation in this corpus does not cover Jaeger, distributed tracing sidecar injection, or observability agent integration. Jaeger is a third-party project that lives outside Kubernetes core docs; the right answer is to refuse and cite zero sources.",
88
+ "question_type": "false_premise",
89
+ "is_multi_hop": false,
90
+ "time_sensitive": false,
91
+ "source_chunk_ids": [],
92
+ "source_snippets": [],
93
+ "source_pages": [],
94
+ "source_sections": []
95
+ },
96
+ {
97
+ "id": "k8s_005",
98
+ "question": "As of Kubernetes v1.31, how does Pod Security Admission behave differently when a namespace is labeled with enforce mode versus warn mode?",
99
+ "expected_answer_keywords": ["enforce", "warn", "rejected", "warning", "namespace"],
100
+ "expected_sources": ["k8s_pod_security_admission.md"],
101
+ "category": "retrieval",
102
+ "difficulty": "medium",
103
+ "requires_calculator": false,
104
+ "reference_answer": "Pod Security Admission (stable since Kubernetes v1.25) applies restrictions at the namespace level based on labels. With enforce mode, policy violations cause the Pod to be rejected at admission. With warn mode, policy violations trigger a user-facing warning but the Pod is still allowed. A namespace can combine modes (for example enforce plus warn) at different levels.",
105
+ "question_type": "simple_w_condition",
106
+ "is_multi_hop": false,
107
+ "time_sensitive": true,
108
+ "source_chunk_ids": ["e6921b9ccdcf4571", "052a900bb777ec1c"],
109
+ "source_snippets": [
110
+ "Policy violations will cause the pod to be rejected",
111
+ "FEATURE STATE: `Kubernetes v1.25 [stable]"
112
+ ],
113
+ "source_pages": [
114
+ "concepts/security/pod-security-admission",
115
+ "concepts/security/pod-security-admission"
116
+ ],
117
+ "source_sections": ["Pod Security Admission labels for namespaces", ""]
118
+ },
119
+ {
120
+ "id": "k8s_006",
121
+ "question": "What is a ConfigMap in Kubernetes and what kind of data should you store in it?",
122
+ "expected_answer_keywords": ["ConfigMap", "non-confidential", "key-value", "configuration"],
123
+ "expected_sources": ["k8s_configmap.md"],
124
+ "category": "retrieval",
125
+ "difficulty": "easy",
126
+ "requires_calculator": false,
127
+ "reference_answer": "A ConfigMap is an API object used to store non-confidential data in key-value pairs. It is intended for application configuration that does not need to be kept secret. Confidential data such as passwords or tokens should live in a Secret, not a ConfigMap.",
128
+ "question_type": "simple",
129
+ "is_multi_hop": false,
130
+ "time_sensitive": false,
131
+ "source_chunk_ids": ["b6a867a1906a3ff2"],
132
+ "source_snippets": [
133
+ "A ConfigMap is an API object used to store non-confidential data in key-value pairs"
134
+ ],
135
+ "source_pages": ["concepts/configuration/configmap"],
136
+ "source_sections": [""]
137
+ },
138
+ {
139
+ "id": "k8s_007",
140
+ "question": "What does a Kubernetes Job do, and how does it decide that its task is complete?",
141
+ "expected_answer_keywords": ["Job", "Pods", "retry", "completions", "terminate"],
142
+ "expected_sources": ["k8s_job.md"],
143
+ "category": "retrieval",
144
+ "difficulty": "easy",
145
+ "requires_calculator": false,
146
+ "reference_answer": "A Job creates one or more Pods and will continue to retry execution of the Pods until a specified number of them successfully terminate. As Pods successfully complete, the Job tracks the successful completions; once the specified number is reached, the Job is considered complete. Deleting a Job cleans up the Pods it created.",
147
+ "question_type": "simple",
148
+ "is_multi_hop": false,
149
+ "time_sensitive": false,
150
+ "source_chunk_ids": ["b704f9dbc8422835"],
151
+ "source_snippets": [
152
+ "A Job creates one or more Pods and will continue to retry execution of the Pods until a specified number of them successfully terminate"
153
+ ],
154
+ "source_pages": ["concepts/workloads/controllers/job"],
155
+ "source_sections": [""]
156
+ },
157
+ {
158
+ "id": "k8s_008",
159
+ "question": "What is a Kubernetes Namespace, and which kinds of resources does namespace scoping apply to?",
160
+ "expected_answer_keywords": ["Namespace", "isolating", "unique", "namespaced", "cluster"],
161
+ "expected_sources": ["k8s_namespaces.md"],
162
+ "category": "retrieval",
163
+ "difficulty": "easy",
164
+ "requires_calculator": false,
165
+ "reference_answer": "Namespaces provide a mechanism for isolating groups of resources within a single cluster. Resource names must be unique within a Namespace but not across Namespaces. Namespace-based scoping applies only to namespaced objects such as Deployments and Services \u2014 cluster-wide objects like Nodes, PersistentVolumes, or StorageClass are not namespaced.",
166
+ "question_type": "simple",
167
+ "is_multi_hop": false,
168
+ "time_sensitive": false,
169
+ "source_chunk_ids": ["36dc3e5824f31ef7"],
170
+ "source_snippets": [
171
+ "namespaces* provide a mechanism for isolating groups of resources within a single cluster"
172
+ ],
173
+ "source_pages": ["concepts/overview/working-with-objects/namespaces"],
174
+ "source_sections": [""]
175
+ },
176
+ {
177
+ "id": "k8s_009",
178
+ "question": "What are the four object kinds that the Kubernetes RBAC API declares, and what does each one do?",
179
+ "expected_answer_keywords": ["Role", "ClusterRole", "RoleBinding", "ClusterRoleBinding"],
180
+ "expected_sources": ["k8s_rbac.md"],
181
+ "category": "retrieval",
182
+ "difficulty": "easy",
183
+ "requires_calculator": false,
184
+ "reference_answer": "The RBAC API declares four object kinds: Role, ClusterRole, RoleBinding, and ClusterRoleBinding. Role and ClusterRole contain rules that represent a set of permissions; RoleBinding and ClusterRoleBinding grant those roles to users, groups, or service accounts. Role and RoleBinding are namespaced, while ClusterRole and ClusterRoleBinding are cluster-wide.",
185
+ "question_type": "simple",
186
+ "is_multi_hop": false,
187
+ "time_sensitive": false,
188
+ "source_chunk_ids": ["d01964ca8fd11edc"],
189
+ "source_snippets": [
190
+ "The RBAC API declares four kinds of Kubernetes object: *Role*, *ClusterRole*, *RoleBinding* and *ClusterRoleBinding*"
191
+ ],
192
+ "source_pages": ["reference/access-authn-authz/rbac"],
193
+ "source_sections": ["API objects"]
194
+ },
195
+ {
196
+ "id": "k8s_010",
197
+ "question": "What is a DaemonSet in Kubernetes, and what kind of workload is it designed for?",
198
+ "expected_answer_keywords": ["DaemonSet", "every node", "copy", "daemon"],
199
+ "expected_sources": ["k8s_daemonset.md"],
200
+ "category": "retrieval",
201
+ "difficulty": "easy",
202
+ "requires_calculator": false,
203
+ "reference_answer": "A DaemonSet ensures that all (or some) Nodes in the cluster run a copy of a given Pod. As nodes are added to the cluster, Pods are added to them; as nodes are removed, those Pods are garbage collected. Typical uses are node-local facilities like cluster storage daemons, log collection, and node monitoring \u2014 anything that should run once per node.",
204
+ "question_type": "simple",
205
+ "is_multi_hop": false,
206
+ "time_sensitive": false,
207
+ "source_chunk_ids": ["5c63fa1dc2d8824f"],
208
+ "source_snippets": [
209
+ "DaemonSet* ensures that all (or some) Nodes run a copy of a Pod"
210
+ ],
211
+ "source_pages": ["concepts/workloads/controllers/daemonset"],
212
+ "source_sections": [""]
213
+ },
214
+ {
215
+ "id": "k8s_011",
216
+ "question": "When a Pod consumes a Secret, how does the behavior differ between mounting the Secret as a data volume versus exposing it as environment variables for the container?",
217
+ "expected_answer_keywords": ["Secret", "environment variable", "volume", "mounted", "update"],
218
+ "expected_sources": ["k8s_secret.md"],
219
+ "category": "retrieval",
220
+ "difficulty": "medium",
221
+ "requires_calculator": false,
222
+ "reference_answer": "A Secret can be consumed either by mounting it as a data volume (each key becomes a file in the mount path) or by exposing it as environment variables on the container. Both modes deliver the same underlying data, but a mounted volume receives in-place updates if the Secret changes, whereas environment variables are evaluated at Pod start and do not update after the Pod is running.",
223
+ "question_type": "simple_w_condition",
224
+ "is_multi_hop": false,
225
+ "time_sensitive": false,
226
+ "source_chunk_ids": ["3ae2b5f6828d7a89"],
227
+ "source_snippets": [
228
+ "Secrets can be mounted as data volumes or exposed as"
229
+ ],
230
+ "source_pages": ["concepts/configuration/secret"],
231
+ "source_sections": ["Using Secrets"]
232
+ },
233
+ {
234
+ "id": "k8s_012",
235
+ "question": "How does an emptyDir volume behave differently when emptyDir.medium is left as the default versus when it is set to Memory?",
236
+ "expected_answer_keywords": ["emptyDir", "medium", "tmpfs", "Memory", "RAM"],
237
+ "expected_sources": ["k8s_volumes.md"],
238
+ "category": "retrieval",
239
+ "difficulty": "medium",
240
+ "requires_calculator": false,
241
+ "reference_answer": "By default, an emptyDir volume is stored on whatever medium backs the node \u2014 disk, SSD, or network storage, depending on the environment. If you set emptyDir.medium to 'Memory', Kubernetes mounts a tmpfs (RAM-backed filesystem) instead. tmpfs is very fast, but files written there count against the container's memory limit.",
242
+ "question_type": "simple_w_condition",
243
+ "is_multi_hop": false,
244
+ "time_sensitive": false,
245
+ "source_chunk_ids": ["42931a154c8263f2"],
246
+ "source_snippets": [
247
+ "If you set the `emptyDir.medium` field to `\"Memory\"`, Kubernetes mounts a tmpfs"
248
+ ],
249
+ "source_pages": ["concepts/storage/volumes"],
250
+ "source_sections": ["emptyDir"]
251
+ },
252
+ {
253
+ "id": "k8s_013",
254
+ "question": "How does the kubelet respond differently to a failing liveness probe versus a failing readiness probe on a container?",
255
+ "expected_answer_keywords": ["liveness", "readiness", "restart", "traffic", "Service"],
256
+ "expected_sources": ["k8s_probes.md"],
257
+ "category": "retrieval",
258
+ "difficulty": "medium",
259
+ "requires_calculator": false,
260
+ "reference_answer": "When a liveness probe fails, the kubelet restarts the container to try to recover from a wedged state like a deadlock. When a readiness probe fails, the container is not restarted; instead, the Pod is marked not-ready and removed from Service load balancers, so traffic stops being routed to it until the probe succeeds again.",
261
+ "question_type": "simple_w_condition",
262
+ "is_multi_hop": false,
263
+ "time_sensitive": false,
264
+ "source_chunk_ids": ["b2e141ce1830ae59", "675641157824749c"],
265
+ "source_snippets": [
266
+ "uses liveness probes to know when to restart a container",
267
+ "uses readiness probes to know when a container is ready to start accepting traffic"
268
+ ],
269
+ "source_pages": [
270
+ "tasks/configure-pod-container/configure-liveness-readiness-startup-probes",
271
+ "tasks/configure-pod-container/configure-liveness-readiness-startup-probes"
272
+ ],
273
+ "source_sections": ["", ""]
274
+ },
275
+ {
276
+ "id": "k8s_014",
277
+ "question": "What is the difference between a Service of type NodePort and a Service of type LoadBalancer in Kubernetes?",
278
+ "expected_answer_keywords": ["NodePort", "LoadBalancer", "Node", "external", "cloud"],
279
+ "expected_sources": ["k8s_service.md"],
280
+ "category": "retrieval",
281
+ "difficulty": "medium",
282
+ "requires_calculator": false,
283
+ "reference_answer": "A Service of type NodePort exposes the Service on each Node's IP at a static port, making it reachable by connecting to any node IP on that port. A Service of type LoadBalancer exposes the Service externally using an external load balancer \u2014 Kubernetes does not directly provide the load balancer, so you must integrate with a cloud provider or supply one yourself. LoadBalancer is typically implemented on top of NodePort in cloud environments.",
284
+ "question_type": "comparison",
285
+ "is_multi_hop": false,
286
+ "time_sensitive": false,
287
+ "source_chunk_ids": ["3257227cc8ef1c68", "3257227cc8ef1c68"],
288
+ "source_snippets": [
289
+ "Exposes the Service on each Node",
290
+ "Exposes the Service externally using an external load balancer"
291
+ ],
292
+ "source_pages": [
293
+ "concepts/services-networking/service",
294
+ "concepts/services-networking/service"
295
+ ],
296
+ "source_sections": ["Publishing Services (ServiceTypes)", "Publishing Services (ServiceTypes)"]
297
+ },
298
+ {
299
+ "id": "k8s_015",
300
+ "question": "How does a CronJob differ from a Job in Kubernetes, and when would you reach for one over the other?",
301
+ "expected_answer_keywords": ["Job", "CronJob", "schedule", "repeating", "completion"],
302
+ "expected_sources": ["k8s_job.md", "k8s_cronjob.md"],
303
+ "category": "retrieval",
304
+ "difficulty": "medium",
305
+ "requires_calculator": false,
306
+ "reference_answer": "A Job represents a one-off task that runs to completion and then stops; it creates one or more Pods and retries until a specified number successfully terminate. A CronJob creates Jobs on a repeating schedule written in cron format \u2014 it is meant for regular recurring actions such as backups or report generation. Use a Job for a single batch run, and a CronJob when you need the same Job to run on a recurring schedule.",
307
+ "question_type": "comparison",
308
+ "is_multi_hop": true,
309
+ "time_sensitive": false,
310
+ "source_chunk_ids": ["b704f9dbc8422835", "715c42e9d8a1344e"],
311
+ "source_snippets": [
312
+ "Jobs represent one-off tasks that run to completion and then stop",
313
+ "A CronJob starts one-time Jobs on a repeating schedule"
314
+ ],
315
+ "source_pages": [
316
+ "concepts/workloads/controllers/job",
317
+ "concepts/workloads/controllers/cron-jobs"
318
+ ],
319
+ "source_sections": ["", ""]
320
+ },
321
+ {
322
+ "id": "k8s_016",
323
+ "question": "What is the key scheduling difference between a Deployment and a DaemonSet for running Pods in a cluster?",
324
+ "expected_answer_keywords": ["DaemonSet", "every node", "Deployment", "replicas", "scheduling"],
325
+ "expected_sources": ["k8s_deployment.md", "k8s_daemonset.md"],
326
+ "category": "retrieval",
327
+ "difficulty": "medium",
328
+ "requires_calculator": false,
329
+ "reference_answer": "A Deployment schedules a configured number of replica Pods onto nodes based on the scheduler's placement decisions; the replica count is fixed by the Deployment spec and is independent of the number of nodes. A DaemonSet instead ensures that all (or some) Nodes run a copy of a Pod, so the effective replica count is tied to the number of matching nodes; as nodes are added the DaemonSet Pods are added with them.",
330
+ "question_type": "comparison",
331
+ "is_multi_hop": true,
332
+ "time_sensitive": false,
333
+ "source_chunk_ids": ["2a2ff3b0d4346555", "5c63fa1dc2d8824f"],
334
+ "source_snippets": [
335
+ "A Deployment manages a set of Pods to run an application workload, usually one that doesn't maintain state",
336
+ "DaemonSet* ensures that all (or some) Nodes run a copy of a Pod"
337
+ ],
338
+ "source_pages": [
339
+ "concepts/workloads/controllers/deployment",
340
+ "concepts/workloads/controllers/daemonset"
341
+ ],
342
+ "source_sections": ["", ""]
343
+ },
344
+ {
345
+ "id": "k8s_017",
346
+ "question": "When a Pod with init containers starts up, what is the order in which its init containers and regular application containers run, and what guarantees does Kubernetes make about that order?",
347
+ "expected_answer_keywords": ["init container", "run to completion", "before", "application", "order"],
348
+ "expected_sources": ["k8s_init_containers.md"],
349
+ "category": "retrieval",
350
+ "difficulty": "hard",
351
+ "requires_calculator": false,
352
+ "reference_answer": "Init containers run one at a time, in the order they are defined in the Pod spec, and each must run to completion before the next one starts. Only after all init containers have successfully terminated does the kubelet start the Pod's regular application containers. If any init container fails, the Pod restarts according to its restartPolicy and the init sequence begins again. This makes init containers the right place for one-time setup work that must finish before the app starts.",
353
+ "question_type": "multi_hop",
354
+ "is_multi_hop": true,
355
+ "time_sensitive": false,
356
+ "source_chunk_ids": ["48069a8c91f98f5b", "329fd28939ef9a4c"],
357
+ "source_snippets": [
358
+ "Init containers are exactly like regular containers",
359
+ "before the main application container"
360
+ ],
361
+ "source_pages": [
362
+ "concepts/workloads/pods/init-containers",
363
+ "concepts/workloads/pods/init-containers"
364
+ ],
365
+ "source_sections": ["", ""]
366
+ },
367
+ {
368
+ "id": "k8s_018",
369
+ "question": "As of the current Kubernetes snapshot, which autoscaling API version should you use for a HorizontalPodAutoscaler that scales a Deployment on custom or memory metrics, and why?",
370
+ "expected_answer_keywords": ["HorizontalPodAutoscaler", "autoscaling/v2", "custom metrics", "memory", "stable"],
371
+ "expected_sources": ["k8s_hpa.md"],
372
+ "category": "retrieval",
373
+ "difficulty": "hard",
374
+ "requires_calculator": false,
375
+ "reference_answer": "The current stable HorizontalPodAutoscaler API version is autoscaling/v2, which adds support for scaling on memory and custom metrics beyond the CPU-only autoscaling/v1. The new fields introduced in autoscaling/v2 are preserved as annotations when working with autoscaling/v1, but if you need memory or custom metric scaling for a Deployment or StatefulSet you should use autoscaling/v2 directly.",
376
+ "question_type": "multi_hop",
377
+ "is_multi_hop": true,
378
+ "time_sensitive": true,
379
+ "source_chunk_ids": ["eb3877a460c59fb1", "ec57aa3ce82b78a5"],
380
+ "source_snippets": [
381
+ "HorizontalPodAutoscaler* automatically updates a workload resource",
382
+ "The current stable version can be found in the"
383
+ ],
384
+ "source_pages": [
385
+ "tasks/run-application/horizontal-pod-autoscale",
386
+ "tasks/run-application/horizontal-pod-autoscale"
387
+ ],
388
+ "source_sections": ["", "API Object"]
389
+ },
390
+ {
391
+ "id": "k8s_019",
392
+ "question": "How does a value stored in a ConfigMap become available to an application running inside a Pod \u2014 what are the mechanisms Kubernetes provides?",
393
+ "expected_answer_keywords": ["ConfigMap", "environment variables", "volume", "mounted", "Pod"],
394
+ "expected_sources": ["k8s_configmap.md"],
395
+ "category": "retrieval",
396
+ "difficulty": "hard",
397
+ "requires_calculator": false,
398
+ "reference_answer": "A ConfigMap can be surfaced to a Pod in two main ways: by exposing specific keys as environment variables on the Pod's containers, or by mounting the ConfigMap as a volume so that each key becomes a file in the mount path. Volume-mounted ConfigMap data can also be updated in place when the ConfigMap changes, whereas environment variables are set at Pod start and do not update until the Pod is restarted.",
399
+ "question_type": "multi_hop",
400
+ "is_multi_hop": true,
401
+ "time_sensitive": false,
402
+ "source_chunk_ids": ["b6a867a1906a3ff2"],
403
+ "source_snippets": [
404
+ "A ConfigMap is an API object used to store non-confidential data in key-value pairs"
405
+ ],
406
+ "source_pages": ["concepts/configuration/configmap"],
407
+ "source_sections": [""]
408
+ },
409
+ {
410
+ "id": "k8s_020",
411
+ "question": "By default, is an isolated or non-isolated Pod subject to NetworkPolicy filtering, and how does a NetworkPolicy change that baseline?",
412
+ "expected_answer_keywords": ["NetworkPolicy", "non-isolated", "podSelector", "ingress", "egress"],
413
+ "expected_sources": ["k8s_network_policies.md"],
414
+ "category": "retrieval",
415
+ "difficulty": "hard",
416
+ "requires_calculator": false,
417
+ "reference_answer": "By default, Pods are non-isolated \u2014 they accept traffic from any source. A Pod becomes isolated as soon as any NetworkPolicy in its namespace selects it via podSelector; at that point, only traffic explicitly allowed by the union of NetworkPolicies that select that Pod is permitted. NetworkPolicy rules can target ingress, egress, or both, and the CNI plugin is what enforces the policy \u2014 Kubernetes itself does not.",
418
+ "question_type": "multi_hop",
419
+ "is_multi_hop": true,
420
+ "time_sensitive": false,
421
+ "source_chunk_ids": ["f3630532cd0aacb1", "c5be239e31878572"],
422
+ "source_snippets": [
423
+ "non-isolated",
424
+ "namespaceSelector"
425
+ ],
426
+ "source_pages": [
427
+ "concepts/services-networking/network-policies",
428
+ "concepts/services-networking/network-policies"
429
+ ],
430
+ "source_sections": ["", ""]
431
+ },
432
+ {
433
+ "id": "k8s_021",
434
+ "question": "How does a CronJob get from a cron schedule string to an actual running Pod \u2014 what objects does Kubernetes create along the way?",
435
+ "expected_answer_keywords": ["CronJob", "schedule", "Job", "Pod", "create"],
436
+ "expected_sources": ["k8s_cronjob.md", "k8s_job.md"],
437
+ "category": "retrieval",
438
+ "difficulty": "hard",
439
+ "requires_calculator": false,
440
+ "reference_answer": "A CronJob is like one line of a crontab \u2014 it creates Jobs on a repeating schedule defined in cron format. At each scheduled time, the CronJob controller instantiates a new Job from the jobTemplate. That Job then creates one or more Pods to run the workload, retrying execution until a specified number of Pods successfully terminate. Deleting the CronJob cleans up the Jobs it created, and deleting a Job cleans up its Pods.",
441
+ "question_type": "multi_hop",
442
+ "is_multi_hop": true,
443
+ "time_sensitive": false,
444
+ "source_chunk_ids": ["715c42e9d8a1344e", "b704f9dbc8422835"],
445
+ "source_snippets": [
446
+ "A CronJob starts one-time Jobs on a repeating schedule",
447
+ "A Job creates one or more Pods and will continue to retry execution of the Pods until a specified number of them successfully terminate"
448
+ ],
449
+ "source_pages": [
450
+ "concepts/workloads/controllers/cron-jobs",
451
+ "concepts/workloads/controllers/job"
452
+ ],
453
+ "source_sections": ["", ""]
454
+ },
455
+ {
456
+ "id": "k8s_022",
457
+ "question": "How do I write an RBAC deny rule that blocks a specific user from deleting Pods in a namespace?",
458
+ "expected_answer_keywords": ["does not", "deny", "purely additive", "no", "RBAC"],
459
+ "expected_sources": ["k8s_rbac.md"],
460
+ "category": "retrieval",
461
+ "difficulty": "hard",
462
+ "requires_calculator": false,
463
+ "reference_answer": "You can't \u2014 Kubernetes RBAC does not support deny rules. The docs explicitly state that Role and ClusterRole rules are purely additive and there are no 'deny' rules. To prevent a user from deleting Pods you simply do not grant them a Role that contains the delete verb on pods; the absence of permission is the only way to block an action.",
464
+ "question_type": "false_premise",
465
+ "is_multi_hop": false,
466
+ "time_sensitive": false,
467
+ "source_chunk_ids": ["ca6603fcb81b1723"],
468
+ "source_snippets": [
469
+ "purely additive (there are no \"deny\" rules)"
470
+ ],
471
+ "source_pages": ["reference/access-authn-authz/rbac"],
472
+ "source_sections": ["Role and ClusterRole"]
473
+ },
474
+ {
475
+ "id": "k8s_023",
476
+ "question": "Which container-isolation restrictions does the Pod Security Standards 'privileged' profile enforce on a Pod?",
477
+ "expected_answer_keywords": ["privileged", "unrestricted", "no restrictions", "absence"],
478
+ "expected_sources": ["k8s_pod_security_standards.md"],
479
+ "category": "retrieval",
480
+ "difficulty": "medium",
481
+ "requires_calculator": false,
482
+ "reference_answer": "The privileged profile enforces none \u2014 it is defined by the absence of restrictions. The docs describe the privileged policy as purposely-open and entirely unrestricted: a Pod running under the privileged profile is allowed to bypass typical container isolation mechanisms (for example, access to the node's host network). If you want actual isolation you have to use the baseline or restricted profile instead.",
483
+ "question_type": "false_premise",
484
+ "is_multi_hop": false,
485
+ "time_sensitive": false,
486
+ "source_chunk_ids": ["164541af6b0ebd85"],
487
+ "source_snippets": [
488
+ "Unrestricted policy"
489
+ ],
490
+ "source_pages": ["concepts/security/pod-security-standards"],
491
+ "source_sections": ["Privileged"]
492
+ },
493
+ {
494
+ "id": "k8s_024",
495
+ "question": "How do I configure Envoy xDS aggregated discovery service (ADS) for sidecar proxies managed by a Kubernetes Deployment?",
496
+ "expected_answer_keywords": ["does not", "not contain", "Envoy"],
497
+ "expected_sources": [],
498
+ "category": "out_of_scope",
499
+ "difficulty": "medium",
500
+ "requires_calculator": false,
501
+ "reference_answer": "The Kubernetes documentation in this corpus does not cover Envoy, xDS, or aggregated discovery service (ADS) configuration. Envoy is a third-party proxy typically managed by a service mesh project (not Kubernetes core). The right answer is to refuse and cite zero sources.",
502
+ "question_type": "false_premise",
503
+ "is_multi_hop": false,
504
+ "time_sensitive": false,
505
+ "source_chunk_ids": [],
506
+ "source_snippets": [],
507
+ "source_pages": [],
508
+ "source_sections": []
509
+ },
510
+ {
511
+ "id": "k8s_025",
512
+ "question": "Which Kubernetes Service types expose an application to traffic from outside the cluster?",
513
+ "expected_answer_keywords": ["NodePort", "LoadBalancer", "ExternalName", "Ingress"],
514
+ "expected_sources": ["k8s_service.md"],
515
+ "category": "retrieval",
516
+ "difficulty": "medium",
517
+ "requires_calculator": false,
518
+ "reference_answer": "The Service types that expose an application outside the cluster are NodePort (exposes the Service on each Node's IP at a static port), LoadBalancer (exposes the Service externally using an external load balancer supplied by a cloud integration), and ExternalName (maps the Service to an external DNS name via a CNAME record). ClusterIP is the default and is cluster-internal only; for HTTP/HTTPS routing from outside the cluster, Ingress can front a ClusterIP Service as an alternative to NodePort/LoadBalancer.",
519
+ "question_type": "set",
520
+ "is_multi_hop": false,
521
+ "time_sensitive": false,
522
+ "source_chunk_ids": ["52fd016472117b4b", "3257227cc8ef1c68"],
523
+ "source_snippets": [
524
+ "Exposes the Service on a cluster-internal IP",
525
+ "Exposes the Service externally using an external load balancer"
526
+ ],
527
+ "source_pages": [
528
+ "concepts/services-networking/service",
529
+ "concepts/services-networking/service"
530
+ ],
531
+ "source_sections": ["Publishing Services (ServiceTypes)", "Publishing Services (ServiceTypes)"]
532
+ }
533
+ ]
534
+ }
agent_bench/evaluation/harness.py CHANGED
@@ -36,6 +36,13 @@ class GoldenQuestion(BaseModel):
36
  source_snippets: list[str] = []
37
  question_type: str = ""
38
  is_multi_hop: bool = False
 
 
 
 
 
 
 
39
  # Authoring-time anchors for pre-ingestion golden datasets; index-aligned
40
  # with source_snippets. source_sections[i] == "" means the snippet lives in
41
  # page lede content above the first H2/H3 — this is allowed, not a missing
@@ -130,7 +137,7 @@ async def run_evaluation(
130
  retrieval_recall=retrieval_recall_at_k(ranked_sources, q.expected_sources),
131
  keyword_hit_rate=keyword_hit_rate(agent_response.answer, q.expected_answer_keywords),
132
  has_source_citation=source_presence(agent_response),
133
- grounded_refusal=grounded_refusal(agent_response.answer, q.category, deduped_sources),
134
  citation_accuracy=citation_accuracy(agent_response.answer, deduped_sources),
135
  calculator_used_correctly=calculator_used_when_expected(
136
  agent_response, q.requires_calculator
 
36
  source_snippets: list[str] = []
37
  question_type: str = ""
38
  is_multi_hop: bool = False
39
+ # Version-state flag: true when the correct answer depends on a specific
40
+ # K8s (or framework) version / feature-state pin. Orthogonal to
41
+ # question_type — a simple and a simple_w_condition can both be time-
42
+ # sensitive. Defaults false; the v1.1 K8s plan pins 2–3 time_sensitive
43
+ # questions out of 25. The pilot file predates this flag and never sets
44
+ # it, so the default keeps the pilot schema-compatible.
45
+ time_sensitive: bool = False
46
  # Authoring-time anchors for pre-ingestion golden datasets; index-aligned
47
  # with source_snippets. source_sections[i] == "" means the snippet lives in
48
  # page lede content above the first H2/H3 — this is allowed, not a missing
 
137
  retrieval_recall=retrieval_recall_at_k(ranked_sources, q.expected_sources),
138
  keyword_hit_rate=keyword_hit_rate(agent_response.answer, q.expected_answer_keywords),
139
  has_source_citation=source_presence(agent_response),
140
+ grounded_refusal=grounded_refusal(agent_response.answer, q.category),
141
  citation_accuracy=citation_accuracy(agent_response.answer, deduped_sources),
142
  calculator_used_correctly=calculator_used_when_expected(
143
  agent_response, q.requires_calculator
agent_bench/evaluation/metrics.py CHANGED
@@ -53,16 +53,21 @@ def source_presence(response: AgentResponse) -> bool:
53
  return len(response.sources) > 0
54
 
55
 
56
- def grounded_refusal(
57
- answer: str,
58
- category: str,
59
- response_sources: list[str],
60
- ) -> bool:
61
  """For out_of_scope: does the answer correctly refuse AND cite no sources?
62
 
 
 
 
 
 
 
 
 
63
  Returns True if:
64
  - Category is not out_of_scope (metric not applicable)
65
- - Category is out_of_scope AND answer contains refusal language AND no sources cited
 
66
  """
67
  if category != "out_of_scope":
68
  return True # not applicable
@@ -77,9 +82,18 @@ def grounded_refusal(
77
  "outside the scope",
78
  ]
79
  answer_lower = answer.lower()
80
- has_refusal = any(phrase in answer_lower for phrase in refusal_phrases)
81
- has_no_sources = len(response_sources) == 0
82
- return has_refusal and has_no_sources
 
 
 
 
 
 
 
 
 
83
 
84
 
85
  def citation_accuracy(answer: str, sources: list[str]) -> float:
 
53
  return len(response.sources) > 0
54
 
55
 
56
+ def grounded_refusal(answer: str, category: str) -> bool:
 
 
 
 
57
  """For out_of_scope: does the answer correctly refuse AND cite no sources?
58
 
59
+ "Cite no sources" means no [source: X.md] citations appear in the answer
60
+ text, not that retrieval returned zero candidates. On any non-trivial
61
+ out-of-scope query, retrieval will still return low-relevance candidates
62
+ (unless the grounded-refusal gate fires at the tool level, which only
63
+ catches the thinnest queries). The agent is expected to inspect the
64
+ candidates, find nothing relevant, and refuse without citing anything —
65
+ and that refusal shape is what this metric measures.
66
+
67
  Returns True if:
68
  - Category is not out_of_scope (metric not applicable)
69
+ - Category is out_of_scope AND answer contains refusal language AND the
70
+ answer text contains no [source: ...] citations
71
  """
72
  if category != "out_of_scope":
73
  return True # not applicable
 
82
  "outside the scope",
83
  ]
84
  answer_lower = answer.lower()
85
+ has_phrase_refusal = any(phrase in answer_lower for phrase in refusal_phrases)
86
+ # Canonical shape taught by the system prompt at core/prompts.py:17-18:
87
+ # "not in the {corpus_label} documentation". Narrow regex anchors on
88
+ # "documentation" within 60 chars so plain "not in the" fragments from
89
+ # retrieval answers ("not in the same scope", "not in the default range")
90
+ # do not count as refusals.
91
+ has_canonical_refusal = bool(
92
+ re.search(r"\bnot in the\b[^.]{0,60}\bdocumentation\b", answer, re.IGNORECASE)
93
+ )
94
+ has_refusal = has_phrase_refusal or has_canonical_refusal
95
+ cites_in_answer = re.findall(r"\[source:\s*[^\]]+\]", answer, re.IGNORECASE)
96
+ return has_refusal and len(cites_in_answer) == 0
97
 
98
 
99
  def citation_accuracy(answer: str, sources: list[str]) -> float:
agent_bench/langchain_baseline/runner.py CHANGED
@@ -127,9 +127,7 @@ async def run_langchain_evaluation(
127
  ),
128
  keyword_hit_rate=keyword_hit_rate(answer, q.expected_answer_keywords),
129
  has_source_citation=len(deduped_sources) > 0,
130
- grounded_refusal=grounded_refusal(
131
- answer, q.category, deduped_sources
132
- ),
133
  citation_accuracy=citation_accuracy(answer, deduped_sources),
134
  calculator_used_correctly=(
135
  ("calculator" in tools_used) if q.requires_calculator else True
 
127
  ),
128
  keyword_hit_rate=keyword_hit_rate(answer, q.expected_answer_keywords),
129
  has_source_citation=len(deduped_sources) > 0,
130
+ grounded_refusal=grounded_refusal(answer, q.category),
 
 
131
  citation_accuracy=citation_accuracy(answer, deduped_sources),
132
  calculator_used_correctly=(
133
  ("calculator" in tools_used) if q.requires_calculator else True
configs/default.yaml CHANGED
@@ -112,5 +112,5 @@ corpora:
112
  # still holds; full sweep lands with the 25-question golden set.
113
  top_k: 5
114
  max_iterations: 3
115
- golden_dataset: agent_bench/evaluation/datasets/k8s_golden_pilot.json
116
  available: true
 
112
  # still holds; full sweep lands with the 25-question golden set.
113
  top_k: 5
114
  max_iterations: 3
115
+ golden_dataset: agent_bench/evaluation/datasets/k8s_golden.json
116
  available: true
tests/test_evaluation.py CHANGED
@@ -82,28 +82,64 @@ class TestSourcePresence:
82
 
83
 
84
  class TestGroundedRefusal:
85
- def test_out_of_scope_with_refusal_no_sources(self):
 
86
  assert (
87
- grounded_refusal("The documentation does not contain this info.", "out_of_scope", [])
88
  is True
89
  )
90
 
91
  def test_out_of_scope_without_refusal(self):
92
- assert grounded_refusal("Here is how you do it...", "out_of_scope", []) is False
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93
 
94
- def test_out_of_scope_refusal_but_has_sources(self):
95
- """Refusal language + sources cited = NOT a grounded refusal."""
96
- assert (
97
- grounded_refusal(
98
- "The documentation does not contain this info.",
99
- "out_of_scope",
100
- ["some_doc.md"],
101
- )
102
- is False
103
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
104
 
105
  def test_in_scope_always_true(self):
106
- assert grounded_refusal("any answer", "retrieval", ["a.md"]) is True
107
 
108
 
109
  class TestCitationAccuracy:
 
82
 
83
 
84
  class TestGroundedRefusal:
85
+ def test_out_of_scope_with_refusal_no_citations(self):
86
+ """Refusal phrase + no [source:] citations in answer text = passes."""
87
  assert (
88
+ grounded_refusal("The documentation does not contain this info.", "out_of_scope")
89
  is True
90
  )
91
 
92
  def test_out_of_scope_without_refusal(self):
93
+ assert grounded_refusal("Here is how you do it...", "out_of_scope") is False
94
+
95
+ def test_out_of_scope_refusal_with_citation_in_answer_fails(self):
96
+ """Refusal phrase + [source:] citation in answer text = NOT a grounded refusal.
97
+
98
+ The metric inspects the answer text for citations rather than the
99
+ retrieved-sources list — a correct flavor-A refusal retrieves
100
+ candidates, inspects them, and explicitly declines to cite any of
101
+ them, which is the behavior the metric is designed to measure.
102
+ """
103
+ answer = (
104
+ "The documentation does not contain this info. "
105
+ "[source: some_doc.md]"
106
+ )
107
+ assert grounded_refusal(answer, "out_of_scope") is False
108
 
109
+ def test_out_of_scope_refusal_no_citation_passes_even_with_retrieval(self):
110
+ """Flavor-A refusal: agent retrieved candidates but answer cites none."""
111
+ answer = (
112
+ "The retrieved context does not contain information about Jaeger "
113
+ "sidecar injection. I cannot provide an answer."
 
 
 
 
114
  )
115
+ # Under the old signature this test would have failed because the
116
+ # retrieved-sources list was non-empty. The fix moves the check to
117
+ # the answer text where the actual citations live.
118
+ assert grounded_refusal(answer, "out_of_scope") is True
119
+
120
+ def test_canonical_refusal_phrasing_recognized(self):
121
+ """System-prompt-taught shape "not in the {label} documentation" passes.
122
+
123
+ core/prompts.py:17-18 instructs the agent to say "the answer is not
124
+ in the {corpus_label} documentation and stop" on out-of-scope queries.
125
+ The metric must recognize that canonical form.
126
+ """
127
+ answer = "The answer is not in the Kubernetes documentation."
128
+ assert grounded_refusal(answer, "out_of_scope") is True
129
+
130
+ def test_not_in_the_is_not_substring_refusal(self):
131
+ """Bare "not in the" fragment must NOT count as refusal.
132
+
133
+ Pins the design choice to match the canonical shape via a narrow
134
+ regex anchored on "documentation" rather than a loose substring.
135
+ A future refactor that widens the matcher to substring "not in the"
136
+ will break this test — that is the point.
137
+ """
138
+ answer = "The rate limit is not in the same scope as the request timeout."
139
+ assert grounded_refusal(answer, "out_of_scope") is False
140
 
141
  def test_in_scope_always_true(self):
142
+ assert grounded_refusal("any answer", "retrieval") is True
143
 
144
 
145
  class TestCitationAccuracy: