sift-engine 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. evals/__init__.py +13 -0
  2. evals/agent_loop/__init__.py +23 -0
  3. evals/agent_loop/agent.py +314 -0
  4. evals/agent_loop/judge.py +228 -0
  5. evals/agent_loop/questions.py +396 -0
  6. evals/agent_loop/report.py +197 -0
  7. evals/agent_loop/runner.py +234 -0
  8. evals/agent_loop/tools.py +384 -0
  9. evals/bench/__init__.py +0 -0
  10. evals/bench/aggregate_report.py +113 -0
  11. evals/bench/drift.py +301 -0
  12. evals/bench/fixtures/__init__.py +0 -0
  13. evals/bench/fixtures/sites.py +440 -0
  14. evals/bench/full_suite.py +380 -0
  15. evals/bench/per_stage/__init__.py +0 -0
  16. evals/bench/per_stage/commit.py +88 -0
  17. evals/bench/per_stage/extract.py +267 -0
  18. evals/bench/per_stage/fetch.py +190 -0
  19. evals/bench/per_stage/mcp.py +83 -0
  20. evals/bench/per_stage/plan.py +186 -0
  21. evals/bench/per_stage/publish.py +61 -0
  22. evals/bench/per_stage/seed.py +189 -0
  23. evals/bench/report.py +167 -0
  24. evals/bench/runner.py +103 -0
  25. evals/bench/scoring/__init__.py +0 -0
  26. evals/bench/scoring/fidelity.py +118 -0
  27. evals/bench/scoring/structural.py +121 -0
  28. evals/bench/scoring/use_case.py +60 -0
  29. evals/cli.py +620 -0
  30. evals/determinism.py +135 -0
  31. evals/efficiency.py +162 -0
  32. evals/facts_coverage.py +198 -0
  33. evals/facts_validation.py +150 -0
  34. evals/llm_judge.py +331 -0
  35. evals/performance.py +217 -0
  36. evals/sampler.py +111 -0
  37. evals/structural.py +237 -0
  38. sift/__init__.py +40 -0
  39. sift/_io.py +82 -0
  40. sift/agent_surface.py +267 -0
  41. sift/browser.py +420 -0
  42. sift/classify.py +164 -0
  43. sift/cli.py +1388 -0
  44. sift/commit.py +165 -0
  45. sift/config.py +302 -0
  46. sift/decide.py +173 -0
  47. sift/extract.py +669 -0
  48. sift/extract_code.py +190 -0
  49. sift/extract_next_state.py +305 -0
  50. sift/extract_strategy.py +143 -0
  51. sift/facts.py +334 -0
  52. sift/fetch.py +459 -0
  53. sift/index_profile.py +50 -0
  54. sift/integrity.py +155 -0
  55. sift/manifest.py +441 -0
  56. sift/mcp_server.py +1889 -0
  57. sift/normalize.py +62 -0
  58. sift/paths.py +136 -0
  59. sift/plan.py +149 -0
  60. sift/publish.py +642 -0
  61. sift/purge.py +58 -0
  62. sift/registry.py +495 -0
  63. sift/sites/__init__.py +232 -0
  64. sift/sites/ato.py +331 -0
  65. sift/sites/augov.py +186 -0
  66. sift/sites/generic.py +14 -0
  67. sift/sites/generic_browser.py +50 -0
  68. sift/sites/mdn.py +128 -0
  69. sift/sites/python_docs.py +134 -0
  70. sift/sites/stripe.py +138 -0
  71. sift/sources/__init__.py +86 -0
  72. sift/sources/firecrawl.py +488 -0
  73. sift/sources/sitemap.py +345 -0
  74. sift/status.py +141 -0
  75. sift_engine-0.1.0.dist-info/METADATA +342 -0
  76. sift_engine-0.1.0.dist-info/RECORD +80 -0
  77. sift_engine-0.1.0.dist-info/WHEEL +5 -0
  78. sift_engine-0.1.0.dist-info/entry_points.txt +4 -0
  79. sift_engine-0.1.0.dist-info/licenses/LICENSE +202 -0
  80. sift_engine-0.1.0.dist-info/top_level.txt +2 -0
@@ -0,0 +1,396 @@
1
+ """Hand-curated questions for the agent-loop eval.
2
+
3
+ Each question pins:
4
+ * ``qid`` — short stable id used in result joins
5
+ * ``use_case`` — one of the 6 bench use cases (so the report can split lift by domain)
6
+ * ``text`` — what the agent is asked
7
+ * ``gold_urls`` — pages that contain the answer; the judge uses these as
8
+ the citation-faithfulness ground truth + the harness
9
+ asserts the URLs exist in the sift index before running
10
+ * ``gold_answer`` — a 1-3 sentence reference; the judge compares the agent's
11
+ answer against this, not against a free-form rubric, so
12
+ grading stays low-variance run to run
13
+ * ``fresh_sensitive`` — True when the right answer changes year-over-year
14
+ (tax rates, model releases). These are the questions
15
+ where sift's freshness story should dominate parametric
16
+ knowledge most cleanly.
17
+
18
+ Curation strategy: questions tie to v1.0-baseline published fixtures
19
+ (see ``evals/bench/results/v1.0-baseline-2026-05-31.json``); ~20 total,
20
+ spread across the 6 use cases. Mix factual lookups, multi-page synthesis,
21
+ and freshness-sensitive items so the per-question report is interpretable.
22
+
23
+ Avoided on purpose:
24
+ * questions with answers Claude very likely memorized verbatim (would mask
25
+ the retrieval signal — e.g. "what is HTTP 200?")
26
+ * questions about pages we know are not in the corpus (would unfairly
27
+ penalize sift-grep)
28
+ * ambiguous, opinion-based, or list-style questions where the judge has
29
+ nothing crisp to grade against
30
+ """
31
+ from __future__ import annotations
32
+
33
+ from dataclasses import dataclass
34
+ from typing import Optional
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class Question:
39
+ qid: str
40
+ use_case: str
41
+ text: str
42
+ gold_urls: tuple[str, ...]
43
+ gold_answer: str
44
+ fresh_sensitive: bool = False
45
+ notes: str = ""
46
+
47
+ @property
48
+ def gold_hosts(self) -> set[str]:
49
+ out: set[str] = set()
50
+ for u in self.gold_urls:
51
+ out.add(u.split("//", 1)[-1].split("/", 1)[0].lower())
52
+ return out
53
+
54
+
55
+ # ---- The 20-question set --------------------------------------------------
56
+
57
+ QUESTIONS: tuple[Question, ...] = (
58
+ # ============================================================
59
+ # Coding agents (4) — docs.python.org, developer.mozilla.org, docs.stripe.com
60
+ # ============================================================
61
+ Question(
62
+ qid="py-pathlib-suffix",
63
+ use_case="coding-agents",
64
+ text="In Python's pathlib, what attribute returns the file extension "
65
+ "of a Path (including the dot), and what does it return for a "
66
+ "path with no extension?",
67
+ gold_urls=(
68
+ "https://docs.python.org/3/library/pathlib.html",
69
+ ),
70
+ gold_answer=("PurePath.suffix returns the final component's extension "
71
+ "including the leading dot, e.g. '.txt'. Returns an empty "
72
+ "string '' for paths with no extension."),
73
+ fresh_sensitive=False,
74
+ ),
75
+ Question(
76
+ qid="py-asyncio-gather-vs-taskgroup",
77
+ use_case="coding-agents",
78
+ text="In modern Python (3.11+), what is the recommended replacement "
79
+ "for asyncio.gather() when you need structured concurrency with "
80
+ "exception handling, and why is it preferred?",
81
+ gold_urls=(
82
+ "https://docs.python.org/3/library/asyncio-task.html",
83
+ ),
84
+ gold_answer=("asyncio.TaskGroup (Python 3.11+) is preferred because "
85
+ "it provides structured concurrency: if any task fails, "
86
+ "all other tasks in the group are cancelled, and "
87
+ "exceptions are collected into an ExceptionGroup rather "
88
+ "than silently lost."),
89
+ fresh_sensitive=True,
90
+ notes="3.11+ feature; older Claude training data may default to gather().",
91
+ ),
92
+ Question(
93
+ qid="mdn-css-cascade-layers",
94
+ use_case="coding-agents",
95
+ text="What CSS at-rule was added to control the cascade order of "
96
+ "rule groups independently of specificity, and how do later "
97
+ "layer declarations interact with earlier ones?",
98
+ gold_urls=(
99
+ "https://developer.mozilla.org/en-US/docs/Web/CSS/@layer",
100
+ ),
101
+ gold_answer=("@layer creates explicit cascade layers. Rules in later-"
102
+ "declared layers override rules in earlier ones "
103
+ "regardless of specificity. Un-layered styles take "
104
+ "precedence over all layered styles."),
105
+ fresh_sensitive=False,
106
+ ),
107
+ Question(
108
+ qid="stripe-idempotency-key-header",
109
+ use_case="coding-agents",
110
+ text="What HTTP header does Stripe's API use for idempotent requests, "
111
+ "and what's the recommended way to generate the value?",
112
+ gold_urls=(
113
+ "https://docs.stripe.com/api/idempotent_requests",
114
+ ),
115
+ gold_answer=("The Idempotency-Key request header. Stripe recommends "
116
+ "using a fresh UUID (v4) per logical operation; the same "
117
+ "key replayed within 24 hours returns the original "
118
+ "result without re-executing the request."),
119
+ fresh_sensitive=False,
120
+ ),
121
+
122
+ # ============================================================
123
+ # Tax & compliance (4) — ato.gov.au, irs.gov
124
+ # ============================================================
125
+ Question(
126
+ qid="ato-gst-rate",
127
+ use_case="tax-compliance",
128
+ text="What is the current GST rate in Australia, and which broad "
129
+ "categories of supplies are GST-free?",
130
+ gold_urls=(
131
+ "https://www.ato.gov.au/businesses-and-organisations/gst-excise-and-indirect-taxes/gst",
132
+ ),
133
+ gold_answer=("GST in Australia is 10%. Major GST-free categories "
134
+ "include most basic food, certain medical and health "
135
+ "services, education courses, childcare, and exports."),
136
+ fresh_sensitive=False,
137
+ ),
138
+ Question(
139
+ qid="ato-individual-return-due-date",
140
+ use_case="tax-compliance",
141
+ text="When is the due date for an Australian individual to lodge "
142
+ "their own tax return (without a registered tax agent), and "
143
+ "what financial year does that cover?",
144
+ gold_urls=(
145
+ "https://www.ato.gov.au/individuals-and-families/your-tax-return/how-to-lodge-your-tax-return",
146
+ ),
147
+ gold_answer=("Individuals lodging their own return must lodge by "
148
+ "31 October for the previous Australian financial year "
149
+ "(1 July – 30 June). Lodging through a registered tax "
150
+ "agent typically has a later due date."),
151
+ fresh_sensitive=False,
152
+ ),
153
+ Question(
154
+ qid="irs-standard-mileage-2025",
155
+ use_case="tax-compliance",
156
+ text="What is the IRS standard mileage rate for business use of a "
157
+ "personal vehicle in 2025?",
158
+ gold_urls=(
159
+ "https://www.irs.gov/tax-professionals/standard-mileage-rates",
160
+ ),
161
+ gold_answer=("70 cents per mile for business use in 2025."),
162
+ fresh_sensitive=True,
163
+ notes="Rate changes annually; tests freshness vs parametric memory.",
164
+ ),
165
+ Question(
166
+ qid="irs-401k-contribution-limit",
167
+ use_case="tax-compliance",
168
+ text="What is the employee elective deferral contribution limit for "
169
+ "a 401(k) plan in 2025, and what's the additional catch-up "
170
+ "amount for people aged 50 and over?",
171
+ gold_urls=(
172
+ "https://www.irs.gov/retirement-plans/plan-participant-employee/retirement-topics-401k-and-profit-sharing-plan-contribution-limits",
173
+ ),
174
+ gold_answer=("The 2025 employee elective deferral limit is $23,500. "
175
+ "The standard catch-up for employees aged 50+ is an "
176
+ "additional $7,500."),
177
+ fresh_sensitive=True,
178
+ ),
179
+
180
+ # ============================================================
181
+ # Legal & standards (3) — rfc-editor.org, eur-lex.europa.eu
182
+ # ============================================================
183
+ Question(
184
+ qid="rfc-9110-retry-after",
185
+ use_case="legal-standards",
186
+ text="According to RFC 9110, what two value formats are valid for "
187
+ "the Retry-After HTTP response header, and which status codes "
188
+ "is it typically used with?",
189
+ gold_urls=(
190
+ "https://www.rfc-editor.org/rfc/rfc9110.html",
191
+ ),
192
+ gold_answer=("Retry-After accepts either an HTTP-date or a non-"
193
+ "negative integer number of seconds. It is typically "
194
+ "sent with 503 (Service Unavailable), 429 (Too Many "
195
+ "Requests), or any 3xx redirect."),
196
+ fresh_sensitive=False,
197
+ ),
198
+ Question(
199
+ qid="rfc-8259-json-numbers",
200
+ use_case="legal-standards",
201
+ text="What does RFC 8259 say about the interoperable range and "
202
+ "precision of JSON numbers, and what is the recommended "
203
+ "interoperable range?",
204
+ gold_urls=(
205
+ "https://www.rfc-editor.org/rfc/rfc8259.html",
206
+ ),
207
+ gold_answer=("RFC 8259 notes that JSON does not set limits on number "
208
+ "magnitude or precision, but recommends an interoperable "
209
+ "range matching IEEE 754 double precision (roughly "
210
+ "-(2^53)+1 to (2^53)-1 for integers) since many parsers "
211
+ "use that representation."),
212
+ fresh_sensitive=False,
213
+ ),
214
+ Question(
215
+ qid="eur-lex-gdpr-erasure-when",
216
+ use_case="legal-standards",
217
+ text="Under GDPR Article 17 (right to erasure), name two specific "
218
+ "grounds on which a data subject can require the controller to "
219
+ "erase their personal data without undue delay.",
220
+ gold_urls=(
221
+ "https://eur-lex.europa.eu/legal-content/EN/TXT/?uri=CELEX:32016R0679",
222
+ ),
223
+ gold_answer=("Examples include: the data is no longer necessary for "
224
+ "the purposes it was collected; the data subject "
225
+ "withdraws consent and there is no other legal ground; "
226
+ "the data subject objects to processing and there is no "
227
+ "overriding legitimate ground; the data has been "
228
+ "unlawfully processed."),
229
+ fresh_sensitive=False,
230
+ notes="eur-lex only had 8 pages in v1.0 — may not be reachable in sift-grep.",
231
+ ),
232
+
233
+ # ============================================================
234
+ # Support & policy (3) — help.shopify.com, support.atlassian.com, www.notion.com
235
+ # ============================================================
236
+ Question(
237
+ qid="shopify-checkout-discount-percentage",
238
+ use_case="support-policy",
239
+ text="In Shopify, where in the admin do you create a percentage-"
240
+ "based discount code, and what are the main minimum-requirement "
241
+ "options you can attach?",
242
+ gold_urls=(
243
+ "https://help.shopify.com/en/manual/discounts/discount-types",
244
+ ),
245
+ gold_answer=("Discount codes are created under Discounts in the "
246
+ "Shopify admin. For a percentage discount, the main "
247
+ "minimum requirements are a minimum purchase amount or "
248
+ "a minimum quantity of items."),
249
+ fresh_sensitive=False,
250
+ ),
251
+ Question(
252
+ qid="notion-share-database-publicly",
253
+ use_case="support-policy",
254
+ text="In Notion, how do you make a database accessible to anyone on "
255
+ "the web, and what permission level should you set for them?",
256
+ gold_urls=(
257
+ "https://www.notion.com/help/sharing-and-permissions",
258
+ ),
259
+ gold_answer=("Open the share menu and toggle 'Share to web' (or "
260
+ "'Publish'). The default web access is read-only / "
261
+ "'Can view'; editor or commenter access requires the "
262
+ "viewer to have a Notion account with explicit "
263
+ "workspace permissions."),
264
+ fresh_sensitive=False,
265
+ ),
266
+ Question(
267
+ qid="atlassian-jira-migrate-server-to-cloud",
268
+ use_case="support-policy",
269
+ text="When migrating from Jira Server (or Data Center) to Jira Cloud, "
270
+ "what is the official Atlassian-recommended tool, and at a high "
271
+ "level what does the migration plan include?",
272
+ gold_urls=(
273
+ "https://support.atlassian.com/migration/docs/migrate-from-jira-server-to-cloud",
274
+ ),
275
+ gold_answer=("Atlassian recommends the Jira Cloud Migration "
276
+ "Assistant, installed as an app on the Server/Data "
277
+ "Center instance. The migration plan covers selecting "
278
+ "projects, users, and customisations, running an "
279
+ "assessment, and choosing whether to merge into an "
280
+ "existing cloud site or migrate fresh."),
281
+ fresh_sensitive=False,
282
+ ),
283
+
284
+ # ============================================================
285
+ # Change monitoring (3) — developers.openai.com, vercel.com, docs.github.com
286
+ # ============================================================
287
+ Question(
288
+ qid="openai-recent-model",
289
+ use_case="change-monitoring",
290
+ text="What is the most recently released OpenAI API model according "
291
+ "to the official OpenAI changelog, and what model family does "
292
+ "it belong to?",
293
+ gold_urls=(
294
+ "https://developers.openai.com/changelog",
295
+ ),
296
+ gold_answer=("Refer to the most recent dated entry in the OpenAI "
297
+ "developer changelog; the answer should name the "
298
+ "specific model and identify its family (e.g. GPT, o-"
299
+ "series, GPT-image)."),
300
+ fresh_sensitive=True,
301
+ notes="Pure freshness question — the answer is whatever the changelog "
302
+ "lists, and changes month to month.",
303
+ ),
304
+ Question(
305
+ qid="vercel-custom-domains-pricing",
306
+ use_case="change-monitoring",
307
+ text="On which Vercel pricing plans are custom domains supported, "
308
+ "and is there a limit on the number per project?",
309
+ gold_urls=(
310
+ "https://vercel.com/docs/limits",
311
+ "https://vercel.com/docs/pricing",
312
+ ),
313
+ gold_answer=("Custom domains are available on all Vercel plans, "
314
+ "including Hobby. The current per-project limit "
315
+ "differs by plan: Hobby is capped (e.g. 50), with "
316
+ "higher limits on Pro and Enterprise."),
317
+ fresh_sensitive=True,
318
+ ),
319
+ Question(
320
+ qid="github-actions-concurrency-group",
321
+ use_case="change-monitoring",
322
+ text="In GitHub Actions, what does the workflow-level `concurrency` "
323
+ "key do, and what option causes an in-progress run to be "
324
+ "cancelled when a new run starts?",
325
+ gold_urls=(
326
+ "https://docs.github.com/en/actions/using-jobs/using-concurrency",
327
+ ),
328
+ gold_answer=("`concurrency` groups runs so only one runs at a time "
329
+ "within the group. Setting `cancel-in-progress: true` "
330
+ "cancels any in-progress run in the group when a new "
331
+ "run starts."),
332
+ fresh_sensitive=False,
333
+ ),
334
+
335
+ # ============================================================
336
+ # Internal knowledge (3) — handbook.gitlab.com, posthog.com (handbook), about.gitlab.com
337
+ # ============================================================
338
+ Question(
339
+ qid="gitlab-handbook-vacation",
340
+ use_case="internal-knowledge",
341
+ text="What is GitLab's official policy on paid time off / vacation "
342
+ "for team members, as described in the GitLab handbook?",
343
+ gold_urls=(
344
+ "https://handbook.gitlab.com/handbook/people-group/paid-time-off/",
345
+ ),
346
+ gold_answer=("GitLab offers an uncapped 'flexible' paid time-off "
347
+ "policy: team members take time off as needed, with no "
348
+ "official upper limit, and managers encourage a minimum "
349
+ "amount of leave each year."),
350
+ fresh_sensitive=False,
351
+ ),
352
+ Question(
353
+ qid="posthog-handbook-interview-process",
354
+ use_case="internal-knowledge",
355
+ text="According to the PostHog handbook, what is the typical "
356
+ "engineering interview process, and roughly how many stages "
357
+ "does it include?",
358
+ gold_urls=(
359
+ "https://posthog.com/handbook/people/hiring-process",
360
+ ),
361
+ gold_answer=("PostHog's engineering interview process typically "
362
+ "includes an application review, a recruiter / hiring-"
363
+ "manager call, a technical interview, a small paid "
364
+ "project or 'small project interview', and a culture / "
365
+ "team-fit interview — roughly four to five stages."),
366
+ fresh_sensitive=False,
367
+ ),
368
+ Question(
369
+ qid="gitlab-about-values",
370
+ use_case="internal-knowledge",
371
+ text="What are GitLab's core company values (the CREDIT or similar "
372
+ "acronym), and what does each letter stand for?",
373
+ gold_urls=(
374
+ "https://about.gitlab.com/handbook/values/",
375
+ ),
376
+ gold_answer=("GitLab's values are CREDIT: Collaboration, Results, "
377
+ "Efficiency, Diversity / Inclusion / Belonging, "
378
+ "Iteration, Transparency."),
379
+ fresh_sensitive=False,
380
+ ),
381
+ )
382
+
383
+
384
+ def by_qid(qid: str) -> Optional[Question]:
385
+ for q in QUESTIONS:
386
+ if q.qid == qid:
387
+ return q
388
+ return None
389
+
390
+
391
+ def by_use_case(uc: str) -> tuple[Question, ...]:
392
+ return tuple(q for q in QUESTIONS if q.use_case == uc)
393
+
394
+
395
+ def fresh_only() -> tuple[Question, ...]:
396
+ return tuple(q for q in QUESTIONS if q.fresh_sensitive)
@@ -0,0 +1,197 @@
1
+ """Render the agent-loop bench result as a human-readable markdown report.
2
+
3
+ The report's job is to answer the headline question — *does sift help the
4
+ agent get more answers right?* — in numbers a non-engineer can read. Four
5
+ sections, in priority order:
6
+
7
+ 1. **Headline lift**: mean correctness per condition + sift's lift over
8
+ closed-book / over web-fetch. This IS the result.
9
+ 2. **Per-use-case breakdown**: where sift wins biggest. Tax + change-
10
+ monitoring should dominate (freshness); coding-agents may not move
11
+ much (parametric knowledge is strong).
12
+ 3. **Per-question table**: every cell, sortable in a markdown viewer.
13
+ For drill-down when a number looks surprising.
14
+ 4. **Cost + tool-use**: tokens spent per condition + how many tools the
15
+ agent reached for under sift-grep / web-fetch.
16
+
17
+ Nothing here calls an LLM — input is a SuiteResult dict, output is a string.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ from collections import defaultdict
22
+ from statistics import mean
23
+ from typing import Iterable
24
+
25
+
26
+ def _round(x: float, n: int = 2) -> float:
27
+ return round(float(x), n)
28
+
29
+
30
+ def _by_condition(cells: list[dict]) -> dict[str, list[dict]]:
31
+ out: dict[str, list[dict]] = defaultdict(list)
32
+ for c in cells:
33
+ out[c["condition"]].append(c)
34
+ return out
35
+
36
+
37
+ def _mean_correctness(cells: Iterable[dict]) -> float:
38
+ scores = [int((c.get("judge") or {}).get("correctness") or 0)
39
+ for c in cells]
40
+ scores = [s for s in scores if s > 0]
41
+ if not scores:
42
+ return 0.0
43
+ return _round(mean(scores))
44
+
45
+
46
+ def _pct(cells: Iterable[dict], predicate) -> float:
47
+ cells = list(cells)
48
+ if not cells:
49
+ return 0.0
50
+ n = sum(1 for c in cells if predicate(c))
51
+ return _round(n / len(cells), 3)
52
+
53
+
54
+ def _refused(c: dict) -> bool:
55
+ return bool((c.get("agent") or {}).get("refused"))
56
+
57
+
58
+ def _passing(c: dict, threshold: int = 4) -> bool:
59
+ return int((c.get("judge") or {}).get("correctness") or 0) >= threshold
60
+
61
+
62
+ def _has_citation(c: dict) -> bool:
63
+ return bool((c.get("judge") or {}).get("citation_present"))
64
+
65
+
66
+ def _faithful_citation(c: dict) -> bool:
67
+ return bool((c.get("judge") or {}).get("citation_faithful"))
68
+
69
+
70
+ def _qid_to_question(suite: dict) -> dict[str, dict]:
71
+ return {q["qid"]: q for q in (suite.get("questions") or [])}
72
+
73
+
74
+ def write_report(suite: dict) -> str:
75
+ """Render ``suite`` (the return of ``SuiteResult.to_dict()``) as
76
+ markdown."""
77
+ results = suite.get("results") or []
78
+ by_cond = _by_condition(results)
79
+ conditions = suite.get("config", {}).get("conditions") or sorted(by_cond)
80
+ qmap = _qid_to_question(suite)
81
+
82
+ lines: list[str] = ["# Agent-loop bench", ""]
83
+ lines.append(f"- **Agent model**: `{suite['config'].get('agent_model')}`")
84
+ lines.append(f"- **Judge model**: `{suite['config'].get('judge_model')}`")
85
+ lines.append(f"- **Sift index**: `{suite['config'].get('sift_root')}`"
86
+ f" (run `{suite['config'].get('sift_run_id') or 'current'}`)")
87
+ lines.append(f"- **Conditions**: {', '.join(conditions)}")
88
+ lines.append(f"- **Questions**: {suite['config'].get('n_questions')}")
89
+ lines.append(f"- **Wall time**: {suite['config'].get('total_wall_seconds', '?')}s")
90
+ lines.append("")
91
+
92
+ # 1. Headline lift
93
+ lines.append("## 1. Headline correctness")
94
+ lines.append("")
95
+ lines.append("| condition | mean correctness (1-5) | pass-rate (≥4) | refusal rate | n |")
96
+ lines.append("|---|---:|---:|---:|---:|")
97
+ for cond in conditions:
98
+ bucket = by_cond.get(cond) or []
99
+ lines.append(
100
+ f"| `{cond}` | {_mean_correctness(bucket)} "
101
+ f"| {_pct(bucket, _passing)} "
102
+ f"| {_pct(bucket, _refused)} "
103
+ f"| {len(bucket)} |"
104
+ )
105
+ # Sift-grep lift (most likely the headline number)
106
+ sg = by_cond.get("sift-grep") or []
107
+ cb = by_cond.get("closed-book") or []
108
+ wf = by_cond.get("web-fetch") or []
109
+ if sg and cb:
110
+ lift = _round(_mean_correctness(sg) - _mean_correctness(cb))
111
+ lines.append("")
112
+ lines.append(f"**sift-grep lift over closed-book**: {lift:+}/5 "
113
+ f"({_mean_correctness(sg)} − {_mean_correctness(cb)})")
114
+ if sg and wf:
115
+ lift = _round(_mean_correctness(sg) - _mean_correctness(wf))
116
+ lines.append(f"**sift-grep lift over web-fetch**: {lift:+}/5 "
117
+ f"({_mean_correctness(sg)} − {_mean_correctness(wf)})")
118
+ lines.append("")
119
+
120
+ # 2. Per-use-case
121
+ lines.append("## 2. Per-use-case correctness")
122
+ lines.append("")
123
+ use_cases = sorted({q["use_case"] for q in qmap.values()})
124
+ header = "| use case | " + " | ".join(f"`{c}`" for c in conditions) + " |"
125
+ sep = "|---|" + "|".join("---:" for _ in conditions) + "|"
126
+ lines.append(header)
127
+ lines.append(sep)
128
+ for uc in use_cases:
129
+ qids_in_uc = {q["qid"] for q in qmap.values() if q["use_case"] == uc}
130
+ row = [uc]
131
+ for cond in conditions:
132
+ cells = [c for c in (by_cond.get(cond) or [])
133
+ if c["qid"] in qids_in_uc]
134
+ row.append(str(_mean_correctness(cells)))
135
+ lines.append("| " + " | ".join(row) + " |")
136
+ lines.append("")
137
+
138
+ # 3. Per-question table
139
+ lines.append("## 3. Per-question results")
140
+ lines.append("")
141
+ header = "| qid | use case | " + " | ".join(f"`{c}`" for c in conditions) + " | fresh? |"
142
+ sep = "|---|---|" + "|".join("---:" for _ in conditions) + "|---|"
143
+ lines.append(header)
144
+ lines.append(sep)
145
+ for qid, q in sorted(qmap.items()):
146
+ row = [qid, q["use_case"]]
147
+ for cond in conditions:
148
+ cell = next((c for c in (by_cond.get(cond) or [])
149
+ if c["qid"] == qid), None)
150
+ score = int(((cell or {}).get("judge") or {}).get("correctness") or 0)
151
+ row.append(str(score) if score else "—")
152
+ row.append("yes" if q.get("fresh_sensitive") else "")
153
+ lines.append("| " + " | ".join(row) + " |")
154
+ lines.append("")
155
+
156
+ # 4. Citations + tool-use
157
+ lines.append("## 4. Citation behavior")
158
+ lines.append("")
159
+ lines.append("| condition | cites any URL | cites a gold-host URL |")
160
+ lines.append("|---|---:|---:|")
161
+ for cond in conditions:
162
+ bucket = by_cond.get(cond) or []
163
+ lines.append(
164
+ f"| `{cond}` | {_pct(bucket, _has_citation)} "
165
+ f"| {_pct(bucket, _faithful_citation)} |"
166
+ )
167
+ lines.append("")
168
+
169
+ # 5. Cost
170
+ lines.append("## 5. Cost (total tokens)")
171
+ lines.append("")
172
+ totals = suite.get("totals") or {}
173
+ a = totals.get("agent_tokens") or {}
174
+ j = totals.get("judge_tokens") or {}
175
+ lines.append(f"- **Agent tokens**: in={a.get('input', 0):,} "
176
+ f"out={a.get('output', 0):,} "
177
+ f"cache_read={a.get('cache_read', 0):,} "
178
+ f"cache_write={a.get('cache_write', 0):,}")
179
+ lines.append(f"- **Judge tokens**: in={j.get('input', 0):,} "
180
+ f"out={j.get('output', 0):,} "
181
+ f"cache_read={j.get('cache_read', 0):,} "
182
+ f"cache_write={j.get('cache_write', 0):,}")
183
+ lines.append("")
184
+
185
+ # 6. Notable failures (judge reasons for cells scored ≤ 2)
186
+ failures = [c for c in results
187
+ if int((c.get("judge") or {}).get("correctness") or 0) in (1, 2)
188
+ and (c.get("judge") or {}).get("brief_reason")]
189
+ if failures:
190
+ lines.append("## 6. Notable failures (correctness ≤ 2)")
191
+ lines.append("")
192
+ for c in failures:
193
+ reason = ((c.get("judge") or {}).get("brief_reason") or "").strip()
194
+ lines.append(f"- `{c['qid']}` / `{c['condition']}` — {reason}")
195
+ lines.append("")
196
+
197
+ return "\n".join(lines)