Solver Guide — writing solutions
This guide is for someone using the framework to solve Project Euler
problems. It explains the solution interface — the @runner.main decorator and
its C counterpart — and the workflow from scaffolding a solution to benchmarking it.
If you want the shell and command reference, read the User Guide. If you want to add commands to the framework, read the Developer Guide.
1. The contract: you write solve(), the runner does the rest
A solution file implements exactly one function, solve(). Everything else —
parsing arguments, running the timed loop, checking the answer is stable across
runs, and printing the line the test harness reads — is supplied by the runner
framework (packages/solver/solver/runners/). You never write a main(), an argument parser,
or a timing loop.
- Python: decorate
solve(*args: str) -> strwith@runner.main. - C: write
const char *solve(int argc, char *argv[])and#include "runner.h".
The runner passes each test case's input values as positional arguments in
order, runs solve(), and prints a single stdout line:
<runs> <avg_seconds> <result>
solve() only returns the answer as a string. It must never print that final
line itself, and it should exit nonzero on error.
2. The @runner.main decorator (Python)
from solver.runners import runner
@runner.main
def solve(*args: str) -> str:
n = runner.parse_int(args[0])
return str(sum(range(n)))
if __name__ == '__main__':
raise SystemExit(solve())
@runner.main wraps solve into a zero-argument main() that:
- Parses
--runs=Nfrom the command line (default1; non-positive or missing falls back to1). - Parses
--show, setting the module-levelrunner.showflag (see §4). - Collects positional args — everything in
argv[1:]that is not a--flag— and forwards them tosolve(*args)as strings, in order. - Runs the timed loop
Ntimes, recordingperf_counter()around each call. Only thesolve()call is timed. - Checks consistency — every run must return the same result; a differing or
Noneresult is an error written to stderr with exit code1. - Prints
<runs> <average_seconds> <result>to stdout.
Because the call is repeated N times under --runs=N, anything you build
once and keep warm between runs is mistimed. Build sieves, tables, and caches
inside solve() (or in helpers it calls fresh each time), sized to the inputs
— not at module level with a fixed bound. A module-level @lru_cache or a
precomputed global is excluded from the first run and free thereafter,
understating the real cost. See solutions/public/p0007/p0007_s0.py (a sieve sized
to n·ln n) for the pattern.
Argument helpers
solve receives raw strings; the runner provides parsers so every solution
reads its inputs the same way:
| helper | parses |
|---|---|
runner.parse_int(tok) |
an int, with power notation (2**20) and _ separators |
runner.parse_list(tok) |
a list literal, '[1, 2, 3]' → [1, 2, 3] |
runner.get_text_file(src) |
a statement-linked file, read from the cached resources/ copy |
get_text_file resolves relative to the solution script's own location (via
argv[0]), so it works regardless of the working directory the harness runs it
from.
3. The C counterpart (runner.h)
#include "runner.h"
const char *solve(int argc, char *argv[]) {
long long n = parse_int(argv[1]); /* argv[0] is the program name */
static char answer[32];
snprintf(answer, sizeof answer, "%lld", n * (n - 1) / 2);
return answer;
}
runner.h supplies main() (the --runs/--show parsing, the timing loop, the
consistency check, and the output line), plus C versions of the same helpers:
parse_int, parse_list (returns a heap array; free it), get_text_file
(returns a heap string; free it), and the show flag.
Return the answer as a NUL-terminated string that outlives the call — a
static buffer or a string literal, never a stack-local. Positional arguments
start at argv[1] (argv[0] is the program name; flags are stripped by the
runner).
A C solution must be compiled before it can run — compile-c in the shell, or
eval --clean / benchmark which rebuild as needed.
4. --show — diagnostics without polluting the result
The harness reads one line of stdout. To emit intermediate output (a chart, trace values) only when asked, gate it behind the show flag:
from solver.runners import runner
if runner.show: # Python; set by --show
print(f'partial sums: ...')
if (show) { /* C; set by --show */
printf("partial sums: ...\n");
}
Graphical output needs the show dependency group (pip install -e "packages/solver[show]").
5. Test cases
Each problem directory carries a test_cases.json listing inputs and expected
answers, tagged by category:
| tag | meaning |
|---|---|
dev |
small examples from the problem statement |
main |
the real input — its answer is the answer |
extra |
additional edge cases for extra validation |
Each case's input is a list of strings handed to solve() as positional args,
and output is the expected return value (compared as a string). eval checks
dev main by default (eval all adds extra); benchmark runs every category.
new --tc scaffolds an empty test_cases.json. You can also generate candidate
cases with claude-api test-cases — useful when the statement's examples are
implicit.
6. The new command
new scaffolds solution files for a problem, named after it and numbered after
any solutions already present (p0042_s0.py, p0042_s1.py, …). Pass the problem
number, or let it default to the current problem (the last one a command named).
solver <<'EOF'
new 42 # the next Python solution + a matching .c sibling for problem 42
new --py # a Python solution only (for the current problem)
new --c # add a .c sibling for every .py that lacks one
new --tc # create a template test_cases.json (only if one is missing)
EOF
Each generated solution is the thin runner template from §1–2: it implements only
solve(), decorated with @runner.main (Python) or backed by #include "runner.h"
(C), and the runner framework supplies the harness. new does not create
test_cases.json unless you pass --tc.
7. The solve workflow
solver <<'EOF'
show 42 # read the statement, test_cases, results, and notes in the browser
ls # list the problem's files
# ...read the problem statement, review the test cases...
new --tc # scaffold test_cases.json if needed
new --py # scaffold p0042_s0.py
# ...implement solve() in py...
# ...implement any analysis/brute-force scripts in the solution dir as non-executable files...
eval # check correctness against dev + main test cases
eval all # check correctness against dev + main + extra test cases
# check and record answers in test_cases.json
lint # check and fix linter errors
benchmark # measure timings (max 21 runs each) and record to results.json
mark # mark solved (checks the problem is solved)
new --c # scaffold p0042_s0.c
# ...implement solve() in c...
eval lang=c # check correctness against dev + main test cases
eval all lang=c # check correctness against dev + main + extra test cases
benchmark lang=c # measure timings (max 21 runs each) and record to results.json
benchmark # benchmark all solutions and all test cases
# ...write up the mathematical insight in notes.html...
commit # commit the changes to the repo (git-commit)
EOF
show 42 selects problem 42 as the current problem, so the later commands act on
it without repeating the number; it opens the problem's page in the dedicated
solver-doc browser tab, which later shows reuse instead of opening new tabs.
Files are edited in place in the problem's solution directory, and commit
persists them.
Two complementary checks:
eval answers is it right and how fast,
benchmark persists the answers in results.json.
Run eval until it passes, then benchmark to compare approaches and languages.
evalis the fast correctness loop.benchmarkalways records toresults.jsonand repeats each case an adaptive number of times — up to 21 for sub-second cases, scaling down toward 1 for slow ones (from prior recorded timings) — to average out noise without an unbounded runtime.
See the User Guide for the full options of each command and
commands-index.md for exact usage strings.
8. File layout and naming
Solutions live under solutions/, one directory per problem: plaintext problems
in solutions/public/pNNNN/ (problem 42 → solutions/public/p0042/) and encrypted
problems in solutions/private/pXXXX_YYYY/pNNNN/, bucketed by century (problem 101
→ solutions/private/p0100_0199/p0101/). Within a directory:
| file | contents |
|---|---|
p<NNNN>_s<K>.py |
a Python solution (NNNN = problem, K = index from 0) |
p<NNNN>_s<K>.c |
a C solution |
test_cases.json |
the inputs and expected answers |
results.json |
recorded benchmark timings |
notes.html |
the write-up |
statement.html |
the scraped problem statement |
resources/ |
statement-linked data files (get_text_file) |
Multiple solutions per problem are supported — new numbers the next index for
you (p0042_s0, p0042_s1, …).
Problems under solutions/public/ are plain text; those under
solutions/private/ are encrypted at rest (AES-256) by a transparent git
clean/smudge filter — ciphertext in git, but plaintext in your working tree once
you hold the master key (see the Git Filter Guide and the
User Guide's key-exchange section). Without the key the private files stay
ciphertext. For code structure and patterns, reference the plaintext solutions
under solutions/public/.