Exporting Runs
context-grid is not a RAG framework and does not serve production traffic. What it produces is a decision, and a decision is only worth making if it can leave the tool. The winning configuration comes out as YAML you can commit, as runnable Python, and as a one-page report you can paste into a team decision doc — because nobody adopts a tool, they adopt an argument that came out of one.
write_bundle: everything, in one call
write_bundle writes the report, the raw results, the winning config and the manifest into a
directory. Give it a real Results from a Lab run or Runner. Everything on this page runs
against the same tiny two-file corpus — create it first:
from pathlib import Path
Path("my-docs").mkdir(exist_ok=True)Path("my-docs/shipping.md").write_text( "Express shipping costs $15 and arrives within two business days of order " "placement. Standard shipping is free and takes 5 to 7 business days.\n")Path("my-docs/refunds.md").write_text( "You have 30 days from the delivery date to return an item for a full " "refund. Items must be unused and in original packaging.\n")import contextgrid as cg
CORPUS_DIR = "./my-docs"corpus = cg.Corpus.from_dir(CORPUS_DIR)
evalset = cg.EvalSet( id="support-docs", version=1, items=[ cg.EvalItem( id="q1", question="How much does express shipping cost?", gold=[cg.GoldSpan(span=cg.Span(doc_id="shipping.md", start=0, end=75), grade=1)], ), cg.EvalItem( id="q2", question="How long do I have to return an item?", gold=[cg.GoldSpan(span=cg.Span(doc_id="refunds.md", start=0, end=78), grade=1)], ), ],)
lab = cg.Lab(corpus)lab.grid(chunker=["recursive:128", "structural:128"], embedder="tfidf", index="dense")results = lab.run(evalset)
manifest = cg.build_manifest(results.best("recall@5").config, corpus, evalset)written = cg.write_bundle( results, "./bundle", manifest=manifest, name="support-docs-sweep", corpus=CORPUS_DIR, evalset=None,)for path in written: print(path)bundle/report.mdbundle/results.jsonbundle/winning-config.yamlbundle/use_winning_config.pybundle/manifest.jsonresultsResultsdirectorystr | Pathmetricstrdefault recall@5manifestManifest | Nonedefault Nonemanifest.json when given.namestr | Nonedefault Noneresults.meta["name"].corpusstr | Path | Nonedefault NoneThe documents the sweep ran over. Without it, winning-config.yaml falls back to a flat
listing of pipeline fields — a record, not a config you can hand back to the tool. See the
note on winning_config_to_yaml below.
evalsetstr | Path | Nonedefault NonePass this too and the re-run can be scored, not just executed.
Exactly these five files are written, and only these:
| File | Written by | Always written? |
|---|---|---|
report.md | results_to_markdown | Yes |
results.json | results_to_json | Yes |
winning-config.yaml | config_to_yaml or winning_config_to_yaml | Only if results.best(metric) is not None |
use_winning_config.py | config_to_python | Only alongside winning-config.yaml |
manifest.json | manifest.save | If manifest= was given, or built from corpus= plus evalset= |
results_to_json
Every configuration and every number, for offline analysis — including the per-question scores, so a sceptic can re-run the statistics rather than take the summary on trust:
import json
print(list(json.loads(cg.results_to_json(results, manifest=manifest)).keys()))['mode', 'cache', 'warnings', 'runs', 'manifest']The top-level shape is {"mode", "cache", "warnings", "runs", "manifest"} (manifest only
present when you pass one). Each entry in runs carries config, label, metrics,
timings, cost, chunks, index_bytes, scored_queries, unresolved_gold,
confidence_interval, by_type, failures, no_ground_truth, per_query, answers,
retrieval and warnings — everything write_bundle shows in report.md, plus the raw
per-question numbers the report only summarises.
results_to_markdown
The one-page report. Ordered the way somebody reads it, not the way it was computed: the conclusion first, then the evidence, then the caveats — a report that opens with a methodology section does not get read.
print(cg.results_to_markdown(results, manifest=manifest, name="support-docs-sweep"))# support-docs-sweep — retrieval configuration comparison
## What to use
markdown · recursive:128 · tfidf · dense scored best on recall@5 at 1.000, across 2 configurations, scored on 2 questions. markdown · recursive:128 · tfidf · dense and markdown · structural:128 · tfidf · dense are not distinguishable on this eval set (n=2). The gap of +0.000 on recall@5 sits inside the confidence interval +0.000 to +0.000, so it is consistent with no difference at all. They scored identically on every single question, so this is not a close call between two different configurations -- they are behaving the same way. It runs locally at no cost per query, answering at under 1 ms p95.
> **The top two are not statistically distinguishable.** Either is a defensible choice on this evidence; pick on cost or latency instead.
## Score
**100/100 over 2 dimension(s): chunk, retrieval (not measured: embed, generation, parse)**
| Dimension | Score ||---|---:|| `chunk` | 1.000 || `retrieval` | 1.000 |
Comparable only against another score computed over the same dimensions.
## Leaderboard
| Configuration | recall@5 | p95 ms | $/1k queries | Chunks ||---|---:|---:|---:|---:|| `markdown · recursive:128 · tfidf · dense` | 1.000 | 0.0 | 0.0000 | 2 || `markdown · structural:128 · tfidf · dense` | 1.000 | 0.0 | 0.0000 | 2 |
## Which decision mattered
- **Chunker**: no measurable difference between the values tried.
> **These are averages over runs, not controlled comparisons.** In `ofat` mode each value appears in a different number of configurations, so a value that happens to sit in the baseline arm is averaged over different company than one that does not. Treat this as a pointer to what to sweep properly in `factorial` mode, not as a measured effect.
## Warnings
- **one_chunk_per_document**: recursive:128 produced 2 chunk(s) from 2 document(s), so each document is a single chunk and these scores rank documents rather than passages. The chunker axis cannot change a number it never touched -- sweep smaller sizes, or measure on longer documents- **one_chunk_per_document**: structural:128 produced 2 chunk(s) from 2 document(s), so each document is a single chunk and these scores rank documents rather than passages. The chunker axis cannot change a number it never touched -- sweep smaller sizes, or measure on longer documents- **evalset_at_ceiling**: every one of the 2 configurations scored recall@5 = 1.000. Nothing here can be ranked: the eval set is answered perfectly by all of them, so the sweep measured no difference rather than finding none. Ask harder questions, or compare at a smaller cut-off (recall@1) where there is room to separate them
## Reproducing this
- Manifest: `5abb81d925f2`- Corpus: `a973d96ba2ec` (2 files)- Eval set: `support-docs` v1 (`9bd2c880051b`)- Resolution: coverage at 0.5- context-grid 0.9.5 on Python 3.13.5
Two runs with the same manifest hash must produce identical numbers.resultsResultsmetricstrdefault recall@5manifestManifest | Nonedefault NoneAdds a “Reproducing this” section at the bottom, naming the manifest hash, corpus hash, eval set id and version, and the resolution policy used.
limitintdefault 15namestr | Nonedefault NoneThe sweep’s title. A caller with no name of its own should pass nothing rather than invent
one — the bare word "experiment" is treated as no name at all, since that is what
ExperimentConfig fills in when a config was never read from a file.
Sections appear only when they have something to say: generation columns (faithfulness,
answer_relevancy, groundedness, citation_accuracy) show up only if a run actually
generated answers, “Why the rest failed” only when the winner had failures, and “Warnings”
only when a warning survived the run.
build_manifest and Manifest
“Reproducible” is a claim until something makes it checkable. Manifest is a hash-pinned
record of everything that could change a number — the corpus contents, the eval set and its
version, every parameter, the resolution policy, and library versions.
winner_config = results.best("recall@5").configmanifest = cg.build_manifest(winner_config, corpus, evalset)print(manifest.short_hash)print(manifest.to_dict())5abb81d925f2{'manifest_hash': '5abb81d925f227a314dcadf5fafea3347f173e2eb8a632478ba2f9a50352de41', 'config': {...}, 'corpus_hash': 'a973d96ba2ec299caee982fe89d454d7ee0706ddfe887e0db595e1c3fc1ba9a6', 'corpus_files': 2, 'evalset_id': 'support-docs', 'evalset_version': 1, 'evalset_hash': '...', 'resolution': {'policy': 'coverage', 'threshold': 0.5}, 'versions': {'contextgrid': '0.9.5', 'python': '3.13.x', 'platform': 'darwin'}, 'seeds': {}, 'created_at': '2026-...', 'notes': ''}configConfigcorpusCorpusevalsetEvalSetresolverSpanResolver | Nonedefault NoneSpanResolver().seedsdict[str, int] | Nonedefault Nonenotesstrdefault ""Every Manifest field:
configdict[str, Any]Config, as config.as_dict().corpus_hashstrcorpus.content_hash() — changes if any source file’s content changes.corpus_filesintlen(corpus).evalset_idstrevalset.id.evalset_versionintevalset.version.evalset_hashstrresolutiondict[str, Any]{"policy": ..., "threshold": ...}.versionsdict[str, str]contextgrid, python, platform, plus numpy, pymupdf, pdfplumber, openai, anthropic when installed and imported.seedsdict[str, int]created_atstrnotesstrOther Manifest methods: .hash() (the full sha256), .short_hash (first 12 characters),
.save(path) / Manifest.load(path) to round-trip through JSON, and .matches(other) to
compare two manifests’ hashes directly.
config_to_yaml
The pipeline’s fields as flat YAML — hand-written rather than via a YAML library, since the core installs with numpy and nothing else:
print(cg.config_to_yaml(winner_config, manifest=manifest))# context-grid configuration## manifest: 5abb81d925f2# corpus: a973d96ba2ec (2 files)# evalset: support-docs v1#
ingestion: nullparser: markdownchunker: "recursive:128"embedder: tfidfindex: densetransform: nullretrieval: nullreranker: nullk: 10candidates: 50generator: nullThis is a record of the winning pipeline’s fields, not something contextgrid run can
read back — there is no corpus: and no grid: wrapper. For a file you can actually re-run,
use winning_config_to_yaml.
winning_config_to_yaml
The real thing: an experiment file contextgrid run accepts directly.
from pathlib import Pathfrom contextgrid.config.schema import ExperimentConfig, RunConfig
experiment = ExperimentConfig( corpus=Path(CORPUS_DIR).resolve(), run=RunConfig(headline="recall@5", k=winner_config.k), name="winning-config",)print(cg.winning_config_to_yaml(winner_config, experiment, manifest=manifest))# context-grid configuration## manifest: 5abb81d925f2# corpus: a973d96ba2ec (2 files)# evalset: support-docs v1## Re-run this file directly: contextgrid run winning-config.yaml#
name: "winning-config"
corpus: "/Users/you/my-docs"
# One value per axis: this file names a single configuration, not a sweep.grid: ingestion: null parser: markdown chunker: "recursive:128" embedder: tfidf index: dense transform: null retrieval: null reranker: null candidates: 50 generator: null
run: mode: ofat k: 10 headline: "recall@5" seed: 0 resolution_policy: coverage resolution_threshold: 0.5 machine_usd_per_hour: 0.0 cache: memory model: nullcorpus: and evalset: are written as whatever path experiment.corpus holds — str(experiment.corpus),
verbatim. The example resolves it to an absolute path (/Users/you/my-docs above is this
machine’s actual absolute path, shortened for the page) before building ExperimentConfig,
and on purpose: this file usually lands in a report.out/ directory that sits below
wherever the original config lived, and paths in a config resolve relative to the config
file’s own location — a copied relative path would silently point somewhere else.
write_bundle does this resolving for you automatically; calling winning_config_to_yaml
directly, as above, makes it your job. k comes off the winning Config, not off
experiment.run, since it is the config that actually won that this file describes.
budget_seconds and budget_usd are deliberately left out: they exist to stop a sweep that
is taking too long or costing too much, and carrying them onto a single re-run could only cut
that one run short.
config_to_python
Runnable Python that rebuilds one configuration — not a template with holes, this is code that actually runs:
print(cg.config_to_python(winner_config, corpus=CORPUS_DIR))"""The winning configuration, as context-grid found it."""
import contextgrid as cg
# markdown · recursive:128 · tfidf · dense# Any field not named below is at its default; `cg.Config()` puts it back.config = cg.Config( chunker="recursive:128",)
corpus = cg.Corpus.from_dir("/Users/you/my-docs")pipeline = cg.build(config, corpus)
for chunk_id in pipeline.search("your question here"): print(chunk_id)The corpus = line above is str(Path(CORPUS_DIR).expanduser().resolve()) — whatever you
passed as corpus= to config_to_python, made absolute the same way. /Users/you/my-docs is
this run’s actual path with the machine-specific prefix trimmed for the page; yours will show
wherever your own CORPUS_DIR resolves to.
Field lines are read off Config at export time rather than hand-listed, so this cannot fall
behind the dataclass. Only fields that differ from cg.Config()’s defaults are printed — the
comment above config = cg.Config(...) says so explicitly. Call it without corpus= and the
snippet is honest about that too:
print(cg.config_to_python(winner_config))# Placeholder: this export was not told where the documents are.corpus = cg.Corpus.from_dir("./documents")explain_diff: the regression-triage tool
When a metric drops, diff the manifest against the last passing run — the changed line is the
suspect. explain_diff writes that comparison in plain English:
manifest_a = cg.build_manifest(cg.Config(chunker="recursive:128"), corpus, evalset)manifest_b = cg.build_manifest(cg.Config(chunker="recursive:256"), corpus, evalset)print(cg.explain_diff(manifest_a, manifest_b))1 thing(s) changed between these runs: config.chunker: 'recursive:128' -> 'recursive:256'When nothing at all changed:
Nothing in these two manifests is different: same winning configuration, corpus, eval set, resolution policy, versions and seeds. On that evidence the two runs should have produced identical numbers for this one configuration.
That is not the same as the two runs being identical. A manifest records the configuration that won, not the sweep that found it, so two runs over different grids -- or over the same grid in different modes -- can both end up here.
If this configuration did score differently in the two runs, something outside the manifest is affecting results and that is worth finding.diff(before, after) (also exported from contextgrid.report) returns the same information
as a plain dict[str, tuple[Any, Any]], keyed like "config.chunker" or "corpus_hash",
if you want to act on it programmatically instead of reading English.
Every file this page describes traces back to a real run through
Lab and the grid; see Cost for where the $/1k queries
column in report.md comes from, and Caching for how a warm cache
changes the compute_seconds a manifest’s cost figures were built from.