Skip to content

Performance workbook ​

How duckdb-code behaves as a codebase grows, where it breaks, and the decisions that keep it fast. The numbers here come from indexing the author's ~/Public tree (≈8,200 indexable files across a dozen repositories, ignored directories pruned) — a deliberately large, real-world target used to find the limits.

How to optimise performance? ​

Three optimizations carry almost all of the speed duckdb-code has on a large tree, in priority order. The first is the one you tune; the other two are built in. Every figure below is measured on the ~/Public tree (≈12 repositories); the sections after this one show the full before/after and the reasoning.

1. Avoid indexing what you don't need — tune .gitignore ​

The cheapest work is the work never done, and the single biggest lever is what you exclude. duckdb-code skips, by default, the .git directory, the usual dependency/build directories (node_modules, .venv, vendor, dist, build, __pycache__, the *_cache dirs), files over 1 MB, and anything matched by a .gitignore or .duckdbgraphignore — honored hierarchically, so every directory's ignore file applies to it and its descendants (git semantics), not only one at the indexed root.

Honoring nested ignore files alone, on ~/Public, removed 52% of files and 86% of edges, cutting index time from 524 s to 104 s and the database from 251 MB to 72 MB (see measured scale). That is a larger win than any engine change here — generated code, build output, vendored trees, and caches are pure noise in a code graph, and keeping them out makes every later query faster and the impact/explain signal cleaner.

How to tune it. If a report shows a surprising file count, or impact surfaces vendored or generated symbols:

  • Make sure each repository under the indexed path has its own .gitignore (they are now honored); the tool respects them automatically.
  • For paths you want excluded from the graph but NOT from git, add a .duckdbgraphignore (gitignore syntax) in the relevant directory — e.g. ignore a large generated-fixtures tree, sample data, or a docs build output that git happens to track.
  • Verify with duckdb-code report: the excluded files fold into the skipped count, and the node/edge totals should drop to the real source.
  • Only reach for --no-default-ignores when you deliberately want a vendored tree in the graph; .gitignore/.duckdbgraphignore are still honored even then.

2. Index quickly — batched SQL, not per-row work ​

Indexing resolves cross-file edges and writes the graph. Both must scale with nodes + edges, never with their product, and must persist in bulk. Two fixes took a 2,768-file repository from "did not finish in 5+ minutes" to ~65 s (see graph indexing): an indexed last-segment lookup replaced a per-edge full-table scan, and an edge-fan-out cap replaced a 166k→598k best-effort edge explosion written one row at a time (~236 s of inserts alone). Writing then still bound one row at a time through executemany, three quarters of a full ~/Public build; loading each table as numpy columns in one statement took that build from 243 s to 31 s (see bulk loading). You do not configure any of this — it is why a real codebase indexes in seconds rather than hanging — but the rule generalizes to any DuckDB-backed tool: resolve set-based, cap noisy fan-out, write columns, not rows.

3. Query with graph techniques in the engine ​

impact and explain traverse the graph with a single recursive SQL closure (WITH RECURSIVE), not a per-hop round-trip loop. On the full ~/Public graph a deep reverse-dependency query (28,674 affected nodes) runs in 93 ms in SQL vs 531 ms for the former Python breadth-first search — ~5.7× faster, identical results (see graph traversal). Pushing the multi-hop walk into the engine as one query is what keeps change-impact analysis responsive at six-figure graph sizes. (DuckDB's DuckPGQ graph extension was evaluated and ruled out — its parser does not activate on the pinned DuckDB; core WITH RECURSIVE wins on both speed and zero added dependency.)

In short: exclude aggressively (your .gitignore is the main dial), re-index after edits (it is incremental and cheap), and trust that traversal is already engine-native. The payoff compounds: on ~/Public the combined effect is an 86% smaller graph built 80% faster, then queried ~5.7× faster still.

Graph indexing: resolve edges in SQL, not in a Python loop ​

Indexing is two phases: extraction (tree-sitter parses each file into nodes and unresolved edges that carry a raw target string) and resolution (the cross-file pass that binds every unresolved edge to a real node by building a symbol table and matching targets against it). Extraction is linear in the number of files and stays cheap. Resolution is where scale is won or lost.

A full index of a 2,768-file repository (io2e-hermione-edge) originally did not finish in over five minutes. Bisecting the pipeline by phase found that neither the walk nor the parse was the problem — and the cause was not one thing but three, each masked by the next:

text
phase        measured on the full 2,768-file repo
-----        -------------------------------------
walk+extract 13 s           (linear, fine)
resolve      1.9 s          BUT exploded 166k edges -> 598k
write        ~236 s (hang)  600k edges inserted one row at a time

Cause 1 — a per-edge full-table scan in the scoped suffix fallback. The main resolution loop was already dict-indexed, but the fallback that matches a bare name to any symbol with the same final dotted segment scanned the entire symbol table once per edge — O(scoped_edges × distinct_qualified_names). Fix: precompute an index keyed by last dotted segment, turning the scan into an O(1) lookup. Resolution dropped to ~2 s.

Cause 2 — a fan-out explosion of best-effort edges. Resolution then finished fast but emitted 598k edges from 166k inputs — 92% of them best_effort. A common bare name (run, handler, __init__) shares its last segment with symbols in dozens or hundreds of unrelated modules, and binding to all of them fabricated a near-complete bipartite blow-up that is noise, not a dependency signal. The measured fan-out distribution: 2,292 edges each matched >100 symbols, accounting for most of the blow-up. Fix: cap the suffix fan-out — when a bare-name match binds to more than a threshold of distinct symbols, treat it as unresolvable and drop the edge. The cap must clear legitimate moderate-fan-in symbols (a class imported by a handful of modules sits around 6); a threshold of 20 collapses the pathological tail while preserving those. Edges fell from 598k to ~98k.

Cause 3 — row-by-row inserts. Even the legitimate edge set was written with one execute per row — one round-trip to DuckDB per edge. At 600k edges this alone extrapolated to ~236 s. Fix (first): insert nodes and edges with executemany, which removed the per-call overhead but, as measured later, still binds every row one by one: 74 s for 234,000 edge rows. Fix (since #42): load each table as numpy columns scanned by one INSERT … SELECT, 0.23 s for the same rows (see bulk loading).

With all three fixes the same repository indexes in ~65 s (13 s extract, fast resolve, bulk write of ~98k edges) instead of never finishing.

Rules of thumb. (1) Resolution cost must scale with nodes + edges, never nodes × edges — index the fallback, do not scan it. (2) A best-effort match that fans out to many candidates is noise; cap it rather than fabricate a dense sub-graph. (3) Persist as columns (numpy arrays scanned by INSERT … SELECT), never row by row, and executemany counts as row by row. A single slow phase can hide behind another, so bisect by phase before optimizing.

Bulk loading: columns, not rows ​

executemany reads like a bulk insert, but DuckDB's Python client binds and inserts each row of it in turn. Measured on 234,000 rows shaped like the edges table (six columns, empty table, same rows checked after each path):

PathTimeExtra dependency
executemany74.41 snone
multi-row VALUES, 1,000 rows per statement15.24 snone
CSV file + COPY0.74 snone
dict of numpy arrays, one INSERT … SELECT0.23 snumpy

Decision (shipped, #42). Every bulk write in the indexer goes through one helper that builds a numpy column per field and inserts the registered relation in one statement, and the per-row UPDATEs of centrality and churn became one UPDATE … FROM. Typed arrays carry floats and integers; strings, NULLs, and timezone-aware timestamps travel as object arrays and land in the same types as before, with no cast. The embedding lists are placed one by one into a 1-D object array, because np.array would turn equal-length lists into a 2-D array that DuckDB does not scan as one column; 71,000 vectors of 256 floats load in 0.91 s instead of 20.9 s. A test builds the fixture index both ways and checks the stored rows are identical.

A full index of ~/Public, wall-clock per phase, before and after:

Phaseexecutemanynumpy columns
extract (tree-sitter, Markdown)20.0 s17.3 s
resolve + test edges1.8 s1.5 s
write files, nodes, edges123.3 s0.8 s
write docstrings3.7 s0.0 s
centrality29.7 s0.9 s
communities25.6 s0.5 s
embeddings (embed + insert)34.3 s7.6 s
git co-change3.6 s1.9 s
total242.9 s31.2 s
throughput16 files/s, 1,857 rows/s128 files/s, 14,476 rows/s
peak resident memory1,876 MB1,714 MB

The build is now dominated by extraction and the embedding model, both linear in the codebase, and peak memory went down rather than up, so one statement per table needs no chunking. At 128 files per second, a tree ten times the size of ~/Public indexes in about five minutes; parallel extraction is the next lever.

Parallel extraction and a large codebase ​

Extraction parses every file with tree-sitter or markdown-it. Markdown is 82% of it on ~/Public (12.0 s for 2,617 files, a 4.0 ms median, against 1.3 ms for a Python file), and markdown-it is pure Python, so threads would stay serialized behind the GIL. Decision (shipped, #43). From 500 files on, the indexer extracts across a pool of spawned processes, one per core up to eight (DUCKDB_CODE_WORKERS overrides, 1 forces the sequential path), in chunks of 64 files. Each worker returns plain tuples, which pickle in 0.12 s for all of ~/Public against 0.90 s as models, and the parent merges the chunks in walk order, so the nodes, edges, and their order are identical to a sequential run; a test builds the fixture index both ways and compares the stored rows. A pool takes about 0.7 s to start, which is why trees under 500 files stay sequential.

The large codebase is a workspace of three public repositories cloned at pinned commits, each with its last 500 commits: home-assistant/core (81e94416815c), microsoft/vscode (aee59f2f5c11), and apache/airflow (51140809d2a7). That is 46,222 indexed files, 11.6 times ~/Public, mostly Python and TypeScript. Wall-clock per phase, on an 8-core Apple M1 Pro:

Phase~/Public sequential~/Public parallelLarge sequentialLarge parallel
extract17.3 s5.7 s116.7 s47.7 s
resolve edges1.0 s1.0 s66.8 s36.9 s
derive test edges0.5 s0.5 s38.0 s40.0 s
centrality0.9 s0.9 s35.5 s38.3 s
embeddings7.6 s6.3 s25.1 s26.5 s
write and the rest3.9 s3.8 s51.3 s51.8 s
total31.2 s18.2 s333.4 s241.2 s
throughput128 files/s219 files/s139 files/s192 files/s
peak resident memory1.7 GB2.4 GB5.2 GB5.6 GB

Both large runs store the same 948,430 nodes and 7,432,367 edges. Extraction is 2.4 times faster on the large codebase and 3.0 times on ~/Public, short of the core count, presumably because the parent rebuilds every node and edge from the returned tuples one at a time; peak memory grows by the results held in both forms. At this size the build is no longer led by extraction but by three whole-graph passes in Python, edge resolution, test-edge derivation, and centrality, which take 115 s together on the large codebase against 3 s on ~/Public, and by 7.4 million edges, about 160 per file against 60 on ~/Public. Those are the next levers, measured on the same corpus.

Large graphs: the Python passes and the collector ​

Profiling the three passes on the large codebase pointed at four costs (#45). Test-edge derivation decided whether a file is a test once per edge, building a PurePosixPath 6.9 million times for 46,222 distinct paths. PageRank ran as a Python loop over 4.4 million dependency edges, 20 times. Python's cyclic garbage collector kept rescanning the millions of nodes and edges a build holds alive: edge resolution took 33 s with it running and 19.5 s with it paused, the same in both of two attempts. And the parallel merge rebuilt nodes and edges with pydantic's model_construct, which is pure Python and 2.4 times slower than validated construction in pydantic's compiled core (1.83 s against 0.76 s per million edges); the cProfile hotspot on validation in _bind had suggested the opposite, and an A/B test settled it.

Decision (shipped, #45). The test-or-not decision is made once per path, PageRank runs as the same arithmetic over numpy arrays (one bincount per iteration, equal to the dict loop within 1e-9), every node and edge is built with validated construction, and an index build runs with the cyclic collector paused, collecting once at the end; reference counting still frees everything acyclic. Tests compare the resolved edges, the test edges, and the centrality with reference implementations on both fixtures, a generated 300-module tree, and a random graph. Full index, two runs each:

PhaseLarge, beforeLarge, after~/Public, before~/Public, after
extract47.0–47.7 s29.5 s5.7 s4.6–4.8 s
resolve edges36.9–48.3 s13.6–14.2 s1.0 s0.5 s
derive test edges39.4–40.0 s4.7–5.1 s0.5 s0.1 s
centrality38.3–44.9 s7.9–8.6 s0.9 s0.2 s
total241–273 s123–128 s18.2 s14.4–15.0 s
throughput169–192 files/s360–377 files/s219 files/s266–276 files/s

The stored graph is unchanged: the same 948,430 nodes and 7,432,367 edges on the large codebase. Peak memory is not, and varies from run to run: 4.1–5.2 GB on the large codebase, as before, and 2.9 GB on ~/Public, up from 2.4 GB, since cyclic garbage now waits for the final collection. What remains is the size of the graph itself: 64% of the large codebase's edges are shared-name guesses at 0.55 (references 1.92 million, calls 1.45 million, and the 1.39 million tests edges derived from them), mostly attribute names such as key, value, and domain bound to up to 20 same-named symbols at every use. Cutting them changes what impact reaches, so it is a precision decision, not a speed one, to be measured against make quality on its own.

Graph traversal at scale: recursive SQL or DuckPGQ ​

Once a large graph exists, the second question is how impact and explain traverse it. Today the traversal is a Python breadth-first search: a visited set and a frontier, issuing one flat SELECT ... WHERE dst_id IN (...) per hop. That is correct and fine for a single repository, but at full-tree scale — on the order of 100,000 nodes and 200,000+ edges (see measured scale) — a deep or wide traversal makes many round-trips between Python and the database, each materializing an intermediate frontier, and the per-query latency grows with the depth and branching of the graph.

At that scale the traversal deserves native support, pushing the whole multi-hop walk into the engine as a single query. Two options:

  • WITH RECURSIVE (core DuckDB, no extension). A recursive common table expression expresses the reverse-dependency closure directly in SQL. It needs no new dependency, runs in one query instead of N round-trips, and keeps the package dependency-light. It is the conservative first choice.
  • DuckPGQ (community graph extension). Adds property-graph syntax (CREATE PROPERTY GRAPH, MATCH) and native path-finding. It is the fullest realization of "leveraging DuckDB's graph capabilities" and can express shortest-path and pattern queries ergonomically, but it is a community extension — an added dependency and an availability/packaging surface to own.

The verdict, decided by measurement: WITH RECURSIVE. A full ~/Public index (172,805 nodes, 1,211,129 edges) was built as the benchmark substrate and the three engines were timed on the same reverse-dependency queries:

text
seed (affected)      python BFS   WITH RECURSIVE
high fan-in (28,674) 531 ms       93 ms          (~5.7x faster)
mid fan-in (3)       19 ms        55 ms
leaf (0)             8 ms         52 ms

WITH RECURSIVE is dramatically faster on the deep/wide traversal that is actually slow, and loses only on trivial 0–3-node closures where a ~50 ms fixed query-setup cost dominates work that is already instant — irrelevant for an agent-facing tool. Results were identical across all seeds (same affected set).

DuckPGQ was ruled out, also by measurement. The extension installs and loads on DuckDB 2.0.0 but its SQL/PGQ parser does not activate — CREATE PROPERTY GRAPH and MATCH fail with parser errors, because DuckPGQ is not available on current DuckDB releases (the package is pinned to 2.0.0). Adopting it would mean pinning DuckDB back several versions and taking a community-extension dependency whose availability lags DuckDB — a poor trade for a core-only package.

Two traps the implementation surfaced. (1) A recursive CTE that carries (node_id, distance) pairs never terminates on a cyclic graph — Markdown pages link mutually, so each pass invents a new larger distance for the same node. The recursion must dedup on node_id alone (carry no distance); distance and resolution are then computed by a bounded BFS over just the reachable subgraph. (2) Fetching the reachable nodes one id at a time reintroduces the very N-round-trip cost the recursive closure removes — materialize them with a single IN (...) query. With both, impact on the 28,674-node closure runs in ~2.5 s end-to-end (closure in SQL, then one bounded subgraph pass and one node fetch), versus not finishing acceptably via per-hop round-trips at this scale.

Rule of thumb. Push the multi-hop closure into the engine as one recursive query deduped on node identity; keep the small post-pass (distance, resolution, ordering) in Python over the bounded reachable subgraph; and never fetch the result set one row at a time.

Measured scale of a large tree ​

A full index of ~/Public (4.7 GB on disk, ~12 repositories) measured twice — once with root-only .gitignore handling, then again after nested .gitignore files were honored hierarchically:

text
                       root-only ignores   nested ignores honored   change
files indexed          8,189               3,932                    -52%
nodes                  172,805             61,811                   -64%
edges                  1,211,129           174,548                  -86%
  calls                607,482             82,203                   -86%
  references           463,582             41,618                   -91%
db size                251 MB              72 MB                    -71%
index time             524 s               104 s                    -80%

The ignore scope is a first-order correctness and performance lever, not a detail. Reading only the indexed root's .gitignore meant that, across a multi-repo tree, every repository's own ignore rules were invisible — so generated code, build output (dist/), vendored trees, and caches (e.g. .vitepress/cache) were parsed into the graph as noise. Honoring each directory's ignore files (patterns relative to their own directory, git semantics) removed over half the files and ~86% of the edges, cutting index time by 80% and the database by 71%. The remaining ~62k-node / ~175k-edge graph is the real code-and-docs structure of the tree, not an inflated one.

Two lessons generalize: (1) a personal projects folder still reaches a six-figure graph, which is squarely where the indexing and traversal decisions above stop being optional; and (2) what you exclude can matter more than any engine optimization — the ~86% edge reduction here dwarfs the ~5.7x traversal speed-up, because the cheapest work is the work you never do. The traversal verdict above (WITH RECURSIVE) was measured on the larger pre-fix graph; it holds regardless, since it is a relative comparison on the same substrate.

Git history per repository. ~/Public is not a git repository itself but holds 17, counting the ones nested under _obsolete/ and temp/. Mining each one's last 500 commits takes 1.1 s in total, the slowest at 0.28 s for a repository with a full 500-commit window, so the per-repository git log calls are noise next to the 3 min 46 s full index. index_status runs one git status per repository and answers in about a second.

Does an agent benefit? Kilo Code on ~/Public ​

The sections above show that duckdb-code indexes and queries a large tree quickly. They do not show that a coding agent is better off with it. Its tool descriptions add tokens to every request, and an agent may ignore the tools and fall back on bash and file reads. This section measures the effect directly: the same questions put to Kilo Code with and without duckdb-code, comparing tokens, latency, and answer quality.

Method ​

Each question is asked in two arms that differ only in the MCP servers available:

ArmMCP servers
A (baseline)none
Bduckdb-code only, serving a prebuilt index of ~/Public (--no-startup-index, so indexing never counts toward a question's latency)

Everything else is held constant: the model, the agent, the workspace (~/Public), and the global agent instructions. The runs are driven by the kilo binary bundled with the VS Code extension (kilo run), which is the engine the extension itself runs; a small spot-check in the VS Code panel confirms that the panel behaves the same. Each run sets its MCP servers with a KILO_CONFIG_CONTENT overlay, so the global configuration is never edited between runs. The setup procedure describes the arm B configuration.

The question set is fixed in advance, and each question has a reference answer and a grading rubric:

  • nine structural questions, one per intent duckdb-code targets: find by intent, the impact of a change, related tests, similar code across repositories, a repository map, history and co-change, route↔client links, analytics (cycles, dead code), and a cross-repository dependency question;
  • one control question that a single file read answers, where the tools should add nothing;
  • one overhead probe ("reply OK"), which measures the fixed per-request cost of the tool descriptions.

Each question runs three times per arm, every time in a fresh session. The arms alternate (A, B, A, B…), so provider prompt caching and time-of-day effects fall on both.

Figures come from the session database Kilo Code already writes, opened read-only; nothing is read off the screen. For each session:

  • context tokens: input plus cache reads and writes, summed over the session's requests. They are reported separately from generated tokens because cache reads dominate the context;
  • generated tokens: output plus reasoning;
  • cost, as reported by the provider;
  • latency: wall-clock time from the question to the last completed answer;
  • steps: the number of model requests, and the tool calls by name (duckdb-code_<tool> versus built-in tools);
  • correctness: graded 0, 1, or 2 against the rubric, blind to the arm where practical.

Results are reported as the median per question per arm.

Conditions ​

ConditionValue
Kilo Code7.8.18 (VS Code extension and its bundled kilo binary)
duckdb-code261102.221350, run through uvx (a first pass of one repetition ran on 261101, before #38–#42)
Modelamazon-bedrock/global.anthropic.claude-opus-4-6-v1, pinned for every run
Agentask (read-only: edits and writes denied, read-only shell commands only), with duckdb-code_* allowed in arm B
Workspace~/Public: 17 git repositories (13 at the top level, 4 nested under _obsolete/ and temp/) plus personal folders
Index3,980 files indexed (8,496 seen, 4,516 skipped by ignore rules): 2,617 Markdown, 1,276 Python, 84 TypeScript, 3 JavaScript. 71,624 nodes and 235,613 content edges (308,829 with the derived community and co-change edges), schema v7. Built once in 44 s with the default embedding model, uvx start-up included (31 s of indexing and 1.7 GB peak memory in the #42 profile), database 225 MB
HardwareApple M1 Pro, 16 GB RAM, macOS 14.6.1
Run dates2026-11-02, 22:49–23:29 (66 runs, arms interleaved)

The model is pinned because an auto-router can change the model from one request to the next, and that would swamp the difference between the arms. It runs through Bedrock's global inference route, which can process requests outside the EU; the regional eu. route for the same model was not reachable from the account used.

The whole of ~/Public is indexed and visible to the agent in both arms, including private, client, and personal content. Only public repositories (duckdb-code, duckdb-vectors, boringsteps, ileap-mcp) are cited in the questions and reference answers, and the published results carry metrics and grades only, never answer text.

Success criteria ​

Fixed before any run:

  • On the structural questions, duckdb-code counts as a benefit if median context tokens drop by at least 25%, median latency does not rise, and correctness is no worse than the baseline.
  • The control question and the overhead probe are reported as costs, not hidden: they show what duckdb-code adds when it does not help.
  • A cheaper wrong answer is not a gain, so token and latency deltas are only read together with correctness.

Results ​

Three repetitions of every question in both arms, 66 runs, none failed; the spend was $21.36 (arm A $13.63, arm B $7.73). Medians per question, with correctness as the median grade:

QuestionContext tokens AContext tokens BΔ contextLatency A (s)Latency B (s)Δ latencyduckdb-code calls BGrade AGrade B
q01 find by intent119,577204,829+71%27.736.5+32%222
q02 impact286,666231,807−19%35.625.8−27%222
q03 related tests420,928152,789−64%45.724.5−46%422
q04 similar code124,031146,752+18%15.419.2+24%022
q05 repository map392,981376,274−4%77.663.3−18%022
q06 history612,122100,030−84%80.717.2−79%122
q07 links194,347252,962+30%25.223.1−9%422
q08 cycles597,00186,496−86%59.913.5−77%202
q09 cross-repository475,196431,450−9%36.035.1−3%022
q10 control190,113121,367−36%16.512.3−25%022
q11 overhead probe33,65039,912+19%3.23.8+20%022

Across the nine structural questions (27 runs per arm), the median context drops from 372,864 to 179,035 tokens (−52%), the median latency from 40.8 s to 24.5 s (−40%), and the median number of model requests from 7 to 4. Correctness rises: 27 of 27 answers in arm B score 2, against 23 of 27 in arm A. The four arm A failures are one repository map that described the wrong repository and all three cycle answers, which analyzed import cycles only and missed the method recursion that analytics cycles reports.

Verdict: duckdb-code is a benefit on structural questions. It clears every criterion fixed before the runs: context tokens down 52% (the bar was 25%), latency down rather than up, and correctness no worse, better on cycles. The gains concentrate where the graph answers what files alone do not: history (−84% context), cycles (−86%), and related tests (−64%).

Where it costs. Find-by-intent (q01, +71%), similar code (q04, +18%), and links (q07, +30%) cost more context in arm B for the same grade: the agent calls duckdb-code and then reads the files anyway. The overhead probe puts the fixed price of the twelve tool descriptions at about 6,300 context tokens and 0.6 s per session. In 9 of the 27 structural B runs the agent made no duckdb-code call at all, every time on q04, q05, and q09, which are also the questions with the smallest gains; better tool routing is the lever there.

Limits.

  • One model (Claude Opus 4.6 through Bedrock), one agent configuration, one workspace; three repetitions per arm give medians, not confidence intervals.
  • The control question q10 came out 36% cheaper in arm B with no duckdb-code call, so differences of that size on a single question can be noise; the structural aggregate is the robust figure.
  • Answers were graded by the agent that ran the benchmark, against the fixed rubrics, from shuffled answers with the arm hidden, with a written rationale per question audited by the author. The rubrics check the named items, so incorrect side claims, which appear in both arms, are not penalized.
  • A pilot of one repetition on 261101, before #38–#42, is not included: it ran on a different index and tool set.