From d66eee3560cce7278146feaa19a6baf47cdd9fd6 Mon Sep 17 00:00:00 2001 From: Siwen Yu Date: Wed, 28 Oct 2026 22:06:39 +0800 Subject: [PATCH] feat(eval): measure whether a repo map helps, with hidden tests (roadmap B4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The spike #308 asked for. Three things had to exist before it could be run. The corpus: six tasks under eval/spike-repo-index/tasks, adapted from the tier-2/3 suite and padded with ten unrelated packages each, so finding a defect means surveying a tree rather than reading two files. Each task is its own module; the whole corpus is a nested module so `go test ./...` stays what it was. The instrument: the ordinary harness grades with the task's visible test, and a failing test prints the package and line — it localizes the defect before the agent starts, which is exactly the half of a repo map's job this spike exists to test. `-hidden ` installs the suite after the run instead, and the work copy carries no test at all. `copyTask` now carries subpackages, without which the padding would never have reached the agent. The treatment: a `map` configuration that prepends a bounded symbol digest (files, packages, imports, exported signatures, 2000 tokens) to the prompt — no new capability to call, which keeps the comparison to "survey in hand" versus "survey by hand". Result, on 24 attempts: both arms solved 6/6, and the map arm raised tokens per solved task from 298,671 to 402,779. Under the condition registered in the issue before the run, that is a **no-go for an injected map**. The document records the two qualifications — the corpus had no headroom (nothing could be "solved more"), and the total is dominated by one 35-step attempt while the median run got cheaper and shorter — plus the three conditions under which B4 reopens, the callable tool surface being the one that was never built. The corpus is proved offline by `go test ./eval/runner`: every task red with its hidden suite, green after its reference solution, padding present in the work copy, prompts naming no file, and the map arm's prompt carrying the digest while the baseline's is untouched. Refs #308, #168 --- CODEBASE.md | 6 +- README.md | 2 +- docs/eval.md | 4 +- docs/repo-index-spike.md | 136 ++++++++++ docs/roadmap.md | 19 +- eval/runner/main.go | 82 +++++- eval/runner/spike.go | 245 ++++++++++++++++++ eval/runner/spike_test.go | 225 ++++++++++++++++ eval/spike-repo-index/go.mod | 3 + .../hidden/t2-inventory/inventory_test.go | 29 +++ .../hidden/t2-ledger/ledger_test.go | 47 ++++ .../hidden/t2-report/report_test.go | 21 ++ .../hidden/t3-boundary/usage_test.go | 23 ++ .../hidden/t3-cache/cache_test.go | 60 +++++ .../hidden/t3-extension/convert_test.go | 12 + .../hidden/t3-extension/extend_test.go | 16 ++ .../tasks/t2-inventory/PROMPT | 16 ++ eval/spike-repo-index/tasks/t2-inventory/TIER | 1 + .../tasks/t2-inventory/go.mod | 3 + .../t2-inventory/internal/clock/clock.go | 30 +++ .../t2-inventory/internal/configs/configs.go | 42 +++ .../t2-inventory/internal/idgen/idgen.go | 19 ++ .../t2-inventory/internal/logging/logging.go | 46 ++++ .../t2-inventory/internal/metrics/metrics.go | 42 +++ .../t2-inventory/internal/queue/queue.go | 37 +++ .../t2-inventory/internal/retry/retry.go | 29 +++ .../internal/textutil/textutil.go | 30 +++ .../internal/validate/validate.go | 31 +++ .../t2-inventory/internal/version/version.go | 46 ++++ .../tasks/t2-inventory/orders.go | 19 ++ .../tasks/t2-inventory/stock.go | 20 ++ eval/spike-repo-index/tasks/t2-ledger/PROMPT | 13 + eval/spike-repo-index/tasks/t2-ledger/TIER | 1 + .../tasks/t2-ledger/account.go | 18 ++ eval/spike-repo-index/tasks/t2-ledger/go.mod | 3 + .../tasks/t2-ledger/internal/clock/clock.go | 30 +++ .../t2-ledger/internal/configs/configs.go | 42 +++ .../tasks/t2-ledger/internal/idgen/idgen.go | 19 ++ .../t2-ledger/internal/logging/logging.go | 46 ++++ .../t2-ledger/internal/metrics/metrics.go | 42 +++ .../tasks/t2-ledger/internal/queue/queue.go | 37 +++ .../tasks/t2-ledger/internal/retry/retry.go | 29 +++ .../t2-ledger/internal/textutil/textutil.go | 30 +++ .../t2-ledger/internal/validate/validate.go | 31 +++ .../t2-ledger/internal/version/version.go | 46 ++++ .../tasks/t2-ledger/ledger.go | 44 ++++ eval/spike-repo-index/tasks/t2-report/PROMPT | 14 + eval/spike-repo-index/tasks/t2-report/TIER | 1 + eval/spike-repo-index/tasks/t2-report/go.mod | 3 + .../tasks/t2-report/internal/clock/clock.go | 30 +++ .../t2-report/internal/configs/configs.go | 42 +++ .../tasks/t2-report/internal/idgen/idgen.go | 19 ++ .../t2-report/internal/logging/logging.go | 46 ++++ .../t2-report/internal/metrics/metrics.go | 42 +++ .../tasks/t2-report/internal/queue/queue.go | 37 +++ .../tasks/t2-report/internal/retry/retry.go | 29 +++ .../t2-report/internal/textutil/textutil.go | 30 +++ .../t2-report/internal/validate/validate.go | 31 +++ .../t2-report/internal/version/version.go | 46 ++++ .../spike-repo-index/tasks/t2-report/model.go | 19 ++ .../tasks/t2-report/report.go | 16 ++ .../spike-repo-index/tasks/t3-boundary/PROMPT | 16 ++ eval/spike-repo-index/tasks/t3-boundary/TIER | 1 + .../spike-repo-index/tasks/t3-boundary/go.mod | 3 + .../tasks/t3-boundary/internal/clock/clock.go | 30 +++ .../t3-boundary/internal/configs/configs.go | 42 +++ .../tasks/t3-boundary/internal/idgen/idgen.go | 19 ++ .../t3-boundary/internal/logging/logging.go | 46 ++++ .../t3-boundary/internal/metrics/metrics.go | 42 +++ .../tasks/t3-boundary/internal/queue/queue.go | 37 +++ .../tasks/t3-boundary/internal/retry/retry.go | 29 +++ .../t3-boundary/internal/textutil/textutil.go | 30 +++ .../t3-boundary/internal/validate/validate.go | 31 +++ .../t3-boundary/internal/version/version.go | 46 ++++ .../tasks/t3-boundary/rate.go | 23 ++ .../tasks/t3-boundary/window.go | 6 + eval/spike-repo-index/tasks/t3-cache/PROMPT | 17 ++ eval/spike-repo-index/tasks/t3-cache/TIER | 1 + eval/spike-repo-index/tasks/t3-cache/cache.go | 34 +++ eval/spike-repo-index/tasks/t3-cache/clock.go | 15 ++ eval/spike-repo-index/tasks/t3-cache/go.mod | 3 + .../tasks/t3-cache/internal/clock/clock.go | 30 +++ .../t3-cache/internal/configs/configs.go | 42 +++ .../tasks/t3-cache/internal/idgen/idgen.go | 19 ++ .../t3-cache/internal/logging/logging.go | 46 ++++ .../t3-cache/internal/metrics/metrics.go | 42 +++ .../tasks/t3-cache/internal/queue/queue.go | 37 +++ .../tasks/t3-cache/internal/retry/retry.go | 29 +++ .../t3-cache/internal/textutil/textutil.go | 30 +++ .../t3-cache/internal/validate/validate.go | 31 +++ .../t3-cache/internal/version/version.go | 46 ++++ .../tasks/t3-extension/PROMPT | 8 + eval/spike-repo-index/tasks/t3-extension/TIER | 1 + .../tasks/t3-extension/convert.go | 6 + .../tasks/t3-extension/go.mod | 3 + .../t3-extension/internal/clock/clock.go | 30 +++ .../t3-extension/internal/configs/configs.go | 42 +++ .../t3-extension/internal/idgen/idgen.go | 19 ++ .../t3-extension/internal/logging/logging.go | 46 ++++ .../t3-extension/internal/metrics/metrics.go | 42 +++ .../t3-extension/internal/queue/queue.go | 37 +++ .../t3-extension/internal/retry/retry.go | 29 +++ .../internal/textutil/textutil.go | 30 +++ .../internal/validate/validate.go | 31 +++ .../t3-extension/internal/version/version.go | 46 ++++ 105 files changed, 3352 insertions(+), 18 deletions(-) create mode 100644 docs/repo-index-spike.md create mode 100644 eval/runner/spike.go create mode 100644 eval/runner/spike_test.go create mode 100644 eval/spike-repo-index/go.mod create mode 100644 eval/spike-repo-index/hidden/t2-inventory/inventory_test.go create mode 100644 eval/spike-repo-index/hidden/t2-ledger/ledger_test.go create mode 100644 eval/spike-repo-index/hidden/t2-report/report_test.go create mode 100644 eval/spike-repo-index/hidden/t3-boundary/usage_test.go create mode 100644 eval/spike-repo-index/hidden/t3-cache/cache_test.go create mode 100644 eval/spike-repo-index/hidden/t3-extension/convert_test.go create mode 100644 eval/spike-repo-index/hidden/t3-extension/extend_test.go create mode 100644 eval/spike-repo-index/tasks/t2-inventory/PROMPT create mode 100644 eval/spike-repo-index/tasks/t2-inventory/TIER create mode 100644 eval/spike-repo-index/tasks/t2-inventory/go.mod create mode 100644 eval/spike-repo-index/tasks/t2-inventory/internal/clock/clock.go create mode 100644 eval/spike-repo-index/tasks/t2-inventory/internal/configs/configs.go create mode 100644 eval/spike-repo-index/tasks/t2-inventory/internal/idgen/idgen.go create mode 100644 eval/spike-repo-index/tasks/t2-inventory/internal/logging/logging.go create mode 100644 eval/spike-repo-index/tasks/t2-inventory/internal/metrics/metrics.go create mode 100644 eval/spike-repo-index/tasks/t2-inventory/internal/queue/queue.go create mode 100644 eval/spike-repo-index/tasks/t2-inventory/internal/retry/retry.go create mode 100644 eval/spike-repo-index/tasks/t2-inventory/internal/textutil/textutil.go create mode 100644 eval/spike-repo-index/tasks/t2-inventory/internal/validate/validate.go create mode 100644 eval/spike-repo-index/tasks/t2-inventory/internal/version/version.go create mode 100644 eval/spike-repo-index/tasks/t2-inventory/orders.go create mode 100644 eval/spike-repo-index/tasks/t2-inventory/stock.go create mode 100644 eval/spike-repo-index/tasks/t2-ledger/PROMPT create mode 100644 eval/spike-repo-index/tasks/t2-ledger/TIER create mode 100644 eval/spike-repo-index/tasks/t2-ledger/account.go create mode 100644 eval/spike-repo-index/tasks/t2-ledger/go.mod create mode 100644 eval/spike-repo-index/tasks/t2-ledger/internal/clock/clock.go create mode 100644 eval/spike-repo-index/tasks/t2-ledger/internal/configs/configs.go create mode 100644 eval/spike-repo-index/tasks/t2-ledger/internal/idgen/idgen.go create mode 100644 eval/spike-repo-index/tasks/t2-ledger/internal/logging/logging.go create mode 100644 eval/spike-repo-index/tasks/t2-ledger/internal/metrics/metrics.go create mode 100644 eval/spike-repo-index/tasks/t2-ledger/internal/queue/queue.go create mode 100644 eval/spike-repo-index/tasks/t2-ledger/internal/retry/retry.go create mode 100644 eval/spike-repo-index/tasks/t2-ledger/internal/textutil/textutil.go create mode 100644 eval/spike-repo-index/tasks/t2-ledger/internal/validate/validate.go create mode 100644 eval/spike-repo-index/tasks/t2-ledger/internal/version/version.go create mode 100644 eval/spike-repo-index/tasks/t2-ledger/ledger.go create mode 100644 eval/spike-repo-index/tasks/t2-report/PROMPT create mode 100644 eval/spike-repo-index/tasks/t2-report/TIER create mode 100644 eval/spike-repo-index/tasks/t2-report/go.mod create mode 100644 eval/spike-repo-index/tasks/t2-report/internal/clock/clock.go create mode 100644 eval/spike-repo-index/tasks/t2-report/internal/configs/configs.go create mode 100644 eval/spike-repo-index/tasks/t2-report/internal/idgen/idgen.go create mode 100644 eval/spike-repo-index/tasks/t2-report/internal/logging/logging.go create mode 100644 eval/spike-repo-index/tasks/t2-report/internal/metrics/metrics.go create mode 100644 eval/spike-repo-index/tasks/t2-report/internal/queue/queue.go create mode 100644 eval/spike-repo-index/tasks/t2-report/internal/retry/retry.go create mode 100644 eval/spike-repo-index/tasks/t2-report/internal/textutil/textutil.go create mode 100644 eval/spike-repo-index/tasks/t2-report/internal/validate/validate.go create mode 100644 eval/spike-repo-index/tasks/t2-report/internal/version/version.go create mode 100644 eval/spike-repo-index/tasks/t2-report/model.go create mode 100644 eval/spike-repo-index/tasks/t2-report/report.go create mode 100644 eval/spike-repo-index/tasks/t3-boundary/PROMPT create mode 100644 eval/spike-repo-index/tasks/t3-boundary/TIER create mode 100644 eval/spike-repo-index/tasks/t3-boundary/go.mod create mode 100644 eval/spike-repo-index/tasks/t3-boundary/internal/clock/clock.go create mode 100644 eval/spike-repo-index/tasks/t3-boundary/internal/configs/configs.go create mode 100644 eval/spike-repo-index/tasks/t3-boundary/internal/idgen/idgen.go create mode 100644 eval/spike-repo-index/tasks/t3-boundary/internal/logging/logging.go create mode 100644 eval/spike-repo-index/tasks/t3-boundary/internal/metrics/metrics.go create mode 100644 eval/spike-repo-index/tasks/t3-boundary/internal/queue/queue.go create mode 100644 eval/spike-repo-index/tasks/t3-boundary/internal/retry/retry.go create mode 100644 eval/spike-repo-index/tasks/t3-boundary/internal/textutil/textutil.go create mode 100644 eval/spike-repo-index/tasks/t3-boundary/internal/validate/validate.go create mode 100644 eval/spike-repo-index/tasks/t3-boundary/internal/version/version.go create mode 100644 eval/spike-repo-index/tasks/t3-boundary/rate.go create mode 100644 eval/spike-repo-index/tasks/t3-boundary/window.go create mode 100644 eval/spike-repo-index/tasks/t3-cache/PROMPT create mode 100644 eval/spike-repo-index/tasks/t3-cache/TIER create mode 100644 eval/spike-repo-index/tasks/t3-cache/cache.go create mode 100644 eval/spike-repo-index/tasks/t3-cache/clock.go create mode 100644 eval/spike-repo-index/tasks/t3-cache/go.mod create mode 100644 eval/spike-repo-index/tasks/t3-cache/internal/clock/clock.go create mode 100644 eval/spike-repo-index/tasks/t3-cache/internal/configs/configs.go create mode 100644 eval/spike-repo-index/tasks/t3-cache/internal/idgen/idgen.go create mode 100644 eval/spike-repo-index/tasks/t3-cache/internal/logging/logging.go create mode 100644 eval/spike-repo-index/tasks/t3-cache/internal/metrics/metrics.go create mode 100644 eval/spike-repo-index/tasks/t3-cache/internal/queue/queue.go create mode 100644 eval/spike-repo-index/tasks/t3-cache/internal/retry/retry.go create mode 100644 eval/spike-repo-index/tasks/t3-cache/internal/textutil/textutil.go create mode 100644 eval/spike-repo-index/tasks/t3-cache/internal/validate/validate.go create mode 100644 eval/spike-repo-index/tasks/t3-cache/internal/version/version.go create mode 100644 eval/spike-repo-index/tasks/t3-extension/PROMPT create mode 100644 eval/spike-repo-index/tasks/t3-extension/TIER create mode 100644 eval/spike-repo-index/tasks/t3-extension/convert.go create mode 100644 eval/spike-repo-index/tasks/t3-extension/go.mod create mode 100644 eval/spike-repo-index/tasks/t3-extension/internal/clock/clock.go create mode 100644 eval/spike-repo-index/tasks/t3-extension/internal/configs/configs.go create mode 100644 eval/spike-repo-index/tasks/t3-extension/internal/idgen/idgen.go create mode 100644 eval/spike-repo-index/tasks/t3-extension/internal/logging/logging.go create mode 100644 eval/spike-repo-index/tasks/t3-extension/internal/metrics/metrics.go create mode 100644 eval/spike-repo-index/tasks/t3-extension/internal/queue/queue.go create mode 100644 eval/spike-repo-index/tasks/t3-extension/internal/retry/retry.go create mode 100644 eval/spike-repo-index/tasks/t3-extension/internal/textutil/textutil.go create mode 100644 eval/spike-repo-index/tasks/t3-extension/internal/validate/validate.go create mode 100644 eval/spike-repo-index/tasks/t3-extension/internal/version/version.go diff --git a/CODEBASE.md b/CODEBASE.md index fb288f9..ddb4c17 100644 --- a/CODEBASE.md +++ b/CODEBASE.md @@ -1,6 +1,6 @@ # TinyCode — CODEBASE Map -> AI coding agent in pure Go. Single binary, Bubble Tea TUI, ReAct agent loop, 25 built-in tools + MCP, LSP diagnostics, session persistence. 1249 test functions + 10 fuzz targets, race-detector clean. +> AI coding agent in pure Go. Single binary, Bubble Tea TUI, ReAct agent loop, 25 built-in tools + MCP, LSP diagnostics, session persistence. 1270 test functions + 10 fuzz targets, race-detector clean. ## Quick Reference @@ -869,7 +869,7 @@ User Input (textarea / CLI arg) ## Testing -- **1249 test functions + 10 fuzz targets** in the main module — what `go test ./... -count=1` runs, since `tuiprobe/` is a nested module; each count below is listed with the command that produces it, and no count is carried in a copy that nothing checks +- **1270 test functions + 10 fuzz targets** across the repository — what `go test ./... -count=1` runs for the main module, plus the evaluation corpora under `eval/` (nested modules the command still walks); each count below is listed with the command that produces it, and no count is carried in a copy that nothing checks - `make fuzz` (`FUZZTIME=30s`) explores every fuzz target; `go test` already runs their seed corpora, so CI exercises them on every push - `make test-browser` (`BROWSER_TEST=1`, `-run TestBrowserSmoke`) renders a JavaScript page through both browser paths against a loopback server and asserts the request carried the proxy's `Via` header, which proves the filtering proxy was used; `TestBrowserSmokeRefusesABlockedSubresource` then serves a page whose own authority is exempted from the loopback rule while the policy stays enforced, and asserts a subresource pointing at a *second, live* loopback service is refused (the script's `onerror` marker in the DOM) and that the service was never reached — a refusal is proven, not only that the proxy was used (issue #8). Both skip without a browser, so `make test` never launches one - `make test-tui-visual` (`TUI_SHOT=1`) is the only place the TUI is looked at rather than asserted on: it renders the committed frame scenarios to PNGs through headless Chromium, starts `bin/tinycode` on a real 80x24 PTY (asserting the stream carried SGR styling and that the binary quits on the documented double Ctrl+C), runs it once more on a size-less PTY, and replays the live stream into a screen buffer that is screenshotted as `tinycode-pty-welcome-80x24.png`. Both tests skip without the variable, so `make test` needs neither a browser, nor a binary, nor a terminal device; PNGs land in `TUI_SHOT_DIR` (default `/tmp`) @@ -915,7 +915,7 @@ Counts in this document are produced by these commands at the commit they descri ```bash find . -name '*_test.go' -not -path './tuiprobe/*' -print0 \ - | xargs -0 grep -h '^func Test' | wc -l # 1249 test functions (main module) + | xargs -0 grep -h '^func Test' | wc -l # 1270 test functions (main module) grep -rn '^func Fuzz' --include=*_test.go . | wc -l # 10 fuzz targets ls tui/testdata/golden/frames/*.txt | wc -l # 29 plain-text frames ls tui/testdata/golden/ansi/*.ansi | wc -l # 1 raw ANSI frame diff --git a/README.md b/README.md index e21f3b3..0ac92e8 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,7 @@ Custom **CellGrid** frame-buffer renders markdown directly in the terminal — n - **GitHub Actions**: Two workflows — main.yml (build + lint + test on push/PR) and release.yml (cross-compile + GitHub Releases on tags v*) - **main.yml jobs**: `ci` (build, gofmt gate, vet, tests, `-race`, repeated run), `lsp` (real gopls), `browser` (real Chromium), `tui-visual` (frame PNGs + the built binary on a PTY), `cross` (linux/amd64, linux/arm64, darwin/arm64 build + vet) and `staticcheck` - **Makefile improvements**: test target preserves exit code with pass/fail message; releases target cross-compiles all platforms + .tar.gz archives -- **1249 test functions + 10 fuzz targets** in the main module — the one `go test ./... -count=1` runs, since `tuiprobe/` is a nested module — counted with `find . -name '*_test.go' -not -path './tuiprobe/*' -print0 | xargs -0 grep -h '^func Test' | wc -l` and `grep -rn '^func Fuzz' --include=*_test.go . | wc -l`; the command is part of the record, and `CODEBASE.md` → Testing lists every count with its measurement +- **1270 test functions + 10 fuzz targets** across the repository — the main module plus the evaluation corpora, with `tuiprobe/` a nested module the command below excludes — counted with `find . -name '*_test.go' -not -path './tuiprobe/*' -print0 | xargs -0 grep -h '^func Test' | wc -l` and `grep -rn '^func Fuzz' --include=*_test.go . | wc -l`; the command is part of the record, and `CODEBASE.md` → Testing lists every count with its measurement - **Annotations**: the jobs carry one `ubuntu-latest` migration notice each, plus the `setup-chrome@v1` Node 20 warning on `browser` and `tui-visual`; the gate is no *new* annotations, not zero ### Running the checks locally diff --git a/docs/eval.md b/docs/eval.md index b8a8e0c..db93443 100644 --- a/docs/eval.md +++ b/docs/eval.md @@ -8,7 +8,9 @@ unbuilt, on purpose: building it before knowing the signal exists would have bee ## What it measures Eighteen tasks, each a directory under [`eval/tasks/`](../eval/tasks) with a `go.mod`, a failing test, a -`PROMPT` and a declared `TIER`. An attempt copies the task to a scratch directory, runs +`PROMPT` and a declared `TIER`. (A separate corpus under `eval/spike-repo-index/` exists for the +repository-index spike: six tasks whose tests are **hidden** until grading, and a `map` configuration the +default runs never select. [repo-index-spike.md](repo-index-spike.md) is its result.) An attempt copies the task to a scratch directory, runs `tinycode ""` there, and is graded by the task's own `go test ./...` exit code — no judge model, no rubric, no text comparison. diff --git a/docs/repo-index-spike.md b/docs/repo-index-spike.md new file mode 100644 index 0000000..37fe362 --- /dev/null +++ b/docs/repo-index-spike.md @@ -0,0 +1,136 @@ +# Repository index: the spike's answer + +Roadmap B4 asked whether the agent needs a repository index or repo map, and said the question had to +be **measured before it was built**. This is the measurement, from issue +[#308](https://github.com/yusiwen/TinyCode/issues/308), run on 2026-10-28. + +## The verdict + +**No-go for an injected repo map**, under the condition the issue registered before the run: a map +configuration had to solve at least two more of the six tasks than the baseline without losing one, **or** +hold the solved rate and cut tokens per solved task by ≥ 20%. It did neither — it solved the same six +and raised tokens per solved task by 35%. + +Two facts qualify that verdict, and both are in the numbers below: + +- **The corpus had no headroom left.** The baseline solved 6/6, so "two more solved" was not reachable on + this task set no matter how good the map was. The solved-rate branch of the condition could not fire. +- **The cost figure is dominated by one run.** The map arm's *median* run was cheaper and shorter (87k + tokens and 10 steps, against 116k and 15); its *total* is worse because one `t3-cache` attempt spent + 1.27M tokens over 35 steps. At two repetitions per cell, the mean is not a robust statistic here. + +So the answer is not "a repo map is useless". It is: **on the instrument this repository has, an injected +map showed no benefit that survived the corpus's ceiling and the run-to-run spread**, and building an +index would be spending the largest estimate in the roadmap on a case nobody has measured. The condition +for revisiting is at the end. + +## What was measured + +**The tasks.** Six tasks adapted from the existing tier-2/3 suite, each a small Go module whose defect +spans more than one file, and each **padded with ten unrelated packages** (`internal/metrics`, `configs`, +`logging`, `textutil`, `clock`, `queue`, `retry`, `idgen`, `validate`, `version`) so that finding the +defect means surveying a tree rather than reading two files. The corpus lives in +`eval/spike-repo-index/tasks/`. + +| Task | Tier | Defect, as the prompt gives it | +| --- | --- | --- | +| `t2-inventory` | 2 | a refused reservation leaves state behind, across the stock and the order | +| `t2-ledger` | 2 | a refused transfer is not atomic, across the ledger and the account | +| `t2-report` | 2 | wrong output shape and ordering, across the model and the renderer | +| `t3-boundary` | 3 | a half-open window is counted closed, in a helper outside the type | +| `t3-cache` | 3 | expiry at the boundary and refresh on write, across the cache and its clock | +| `t3-extension` | 3 | a conversion is missing, and the existing rounding must not move | + +**Why the tests are hidden.** The harness normally copies a task's tests into the work copy and grades +with them. That makes the failing test *localise the defect*: `go test` prints the package and line, so a +repo map's discovery half is measured away. For this spike the work copy carries **no test at all**; the +hidden suite is installed after the run (`-hidden`), so the prompt is the only specification and finding +the code is work. `TestSpikePromptsDoNotNameFiles` fails the build if a prompt names a file. + +**The two arms.** + +| Configuration | What the agent gets | +| --- | --- | +| `build` | the prompt, and the tree to explore | +| `map` | the same, plus a bounded symbol map of the work copy prepended to the prompt | + +The map is generated by the runner (`repoMapDigest`): every non-test Go file, its package, its imports, +and its exported functions, methods and types with signatures — sorted by path, capped at 2000 tokens. +It is a survey, not a capability: the `map` arm gives the model no new tool. + +**The run.** 6 tasks × 2 configurations × 2 repetitions = 24 attempts, graded by each task's hidden suite, +metrics read from the run's own trajectory. Model `deepseek-flash`, one machine, 2026-10-28. + +## Results + +| Task | `build` solved | `build` tokens (2 reps) | `build` steps | `map` solved | `map` tokens (2 reps) | `map` steps | +| --- | --- | --- | --- | --- | --- | --- | +| `t2-inventory` | 2/2 | 302,245 · 168,462 | 23 · 15 | 2/2 | 98,416 · 52,257 | 11 · 7 | +| `t2-ledger` | 2/2 | 99,683 · 76,899 | 10 · 8 | 2/2 | 80,013 · 143,175 | 9 · 10 | +| `t2-report` | 2/2 | 36,918 · 38,710 | 7 · 7 | 2/2 | 29,495 · 28,425 | 5 · 5 | +| `t3-boundary` | 2/2 | 85,402 · 134,111 | 10 · 15 | 2/2 | 243,681 · 83,058 | 15 · 9 | +| `t3-cache` | 2/2 | 120,440 · 488,093 | 13 · 22 | 2/2 | **1,273,531** · 223,148 | **35** · 16 | +| `t3-extension` | 2/2 | 110,858 · 130,208 | 15 · 17 | 2/2 | 70,581 · 90,898 | 10 · 10 | + +| | `build` | `map` | +| --- | ---: | ---: | +| Solved | 6/6 (12/12 runs) | 6/6 (12/12 runs) | +| Median tokens per run | 115,649 | 86,978 | +| Mean tokens per run | 149,336 | 201,390 | +| Total tokens | 1,792,029 | 2,416,678 | +| Median steps per run | 15 | 10 | +| Tokens per solved task | 298,671 | 402,779 | + +The median row is the one that says anything nice about the map: cheaper and shorter runs, which is what +"a survey replaces exploration" would look like. The total is the one the pre-registered condition names, +and it says the opposite, because of the single 35-step attempt. **Both are reported because either +alone is misleading.** + +## What the spike does not answer + +- **Whether a *callable* index helps.** The tool-surface arm (query for definitions and references across + the tree — the LSP `workspace/symbol` and call-hierarchy route) was not built. The issue's method named + three configurations; this ran two, and the narrowing is recorded here rather than discovered later. + A tool that answers one question at a time is a different treatment from 2000 tokens of map in every + prompt, and its cost profile is the opposite: paid per use instead of per run. +- **Whether an index helps on a real repository.** These modules are twenty to thirty files. The larger + the tree, the more a map should matter — and the harder it is to build a task whose hidden suite is + fair. +- **Whether the map's shape was right.** Symbols and imports, capped at 2000 tokens, no ranking. A map + that ranked files by relevance to the prompt (which is what the memory store's vector machinery could + do) was not tried. + +## When to revisit + +Reopen B4 when **all** of these hold: + +1. the corpus can defeat the baseline — at least two of its tasks must fail `build` today, or the + solved-rate branch of this experiment cannot fire; +2. repetitions are at least three per cell, so the mean is not one attempt's anecdote; +3. the treatment is the **tool surface**, not the injected map: the map arm is measured and lost. + +If the tool arm also fails those tasks, the entry stays closed with this document as its answer, and the +"largest bet in the roadmap" is recorded as not worth the bet. + +## Reproducing + +```bash +make build +cp bin/tinycode /tmp/tinycode-under-test # see the note below +TINYCODE_EVAL=1 go run ./eval/runner \ + -tasks eval/spike-repo-index/tasks \ + -hidden eval/spike-repo-index/hidden \ + -configurations build,map -reps 2 \ + -out /tmp/spike-out -work /tmp/spike-work \ + -binary /tmp/tinycode-under-test +``` + +`go test ./eval/runner` proves the corpus offline: every spike task is red with its hidden suite +installed and green after its reference solution, the work copies carry the padding, and the prompts name +no file. None of that needs a provider. + +**Why the binary is copied out of the tree.** On this machine, executing `bin/tinycode` in place fails +every provider call (`dial tcp …: connect: bad file descriptor`, or an i/o timeout) while a byte-identical +copy at `/tmp/tinycode-under-test` succeeds. The cause is outside this repository (the agent harness's +sandboxing of that path); the copy is the workaround, and the September release binary in `bin/` behaves +the same way in place. diff --git a/docs/roadmap.md b/docs/roadmap.md index c7c1b93..87c15cf 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -18,8 +18,8 @@ after it (see *Provenance and method*). | :--- | ---: | :--- | | Delivered | 16 | the entry's **Picked up** line names the issue that landed it | | Refuted | 1 | E5 — the capability exists, recorded so it is not re-filed | -| In progress | 3 | F1 — three increments landed, the next one is not opened; B5 — S1 landed, S2 and S3 open; B4 — the spike is open, no implementation | -| Not started | 20 | verified gaps, no issue opened | +| In progress | 2 | F1 — three increments landed, the next one is not opened; B5 — S1 landed, S2 and S3 open | +| Not started | 21 | verified gaps, no issue opened | | **Total** | **40** | `grep -Ec '^\| [A-H][0-9] \| .* \| (Delivered —\|Refuted\|In progress\|Not started)' docs/roadmap.md` | **Open work**: F1, whose next increment (a task set with headroom) is not opened, and @@ -42,7 +42,7 @@ B4 (repository index), F1, then the larger D and E items. | B1 | The memory capability exists but is never called | Delivered — #293 | | B2 | No local memory backend | Delivered — #293 | | B3 | No embeddings, vector search or rerank | Delivered — #297 | -| B4 | No repository index or repo map | In progress — #308 | +| B4 | No repository index or repo map | Not started — spiked 2026-10-28, no-go (#308) | | B5 | Context management has a single strategy | In progress — #306 | | B6 | Truncation is fixed, not budget-aware | Delivered — #304 | | C1 | No checkpoint and no undo | Delivered — #234 | @@ -369,10 +369,15 @@ current tree. D7 is the exception: it was recorded later, so its references are ### B4 — No repository index or repo map -- **Picked up**: [#308](https://github.com/yusiwen/TinyCode/issues/308) — **S0, the spike**, and only - the spike: six cross-file tasks in `eval/tasks/`, three configurations (`build`, a repo map injected - into the prompt, a symbol query surface) and a pre-registered abandon condition. The implementation is - not started and becomes its own issue only if the numbers say go; the entry stays here until then. +- **Spiked 2026-10-28** ([#308](https://github.com/yusiwen/TinyCode/issues/308)): **no-go for an + injected repo map**, under the condition the issue registered before the run. Six cross-file tasks with + hidden suites, ten packages of unrelated padding each, and two arms (`build`, `build` + a 2000-token + symbol map in the prompt): both solved 6/6, and the map arm raised tokens per solved task by 35% + (298,671 → 402,779). The verdict and its two qualifications — the corpus had no headroom left, and the + cost figure rests on one 35-step attempt while the median run got cheaper — are in + [repo-index-spike.md](repo-index-spike.md), which also states the three conditions under which this + entry reopens (a corpus that defeats the baseline, at least three reps per cell, and the callable + tool surface instead of the injected map, which was never built). - **Gap**: no tree-sitter, no symbol index, no call graph, no repo map. The agent cannot answer "who calls this symbol" or "what does changing this affect". - **Evidence**: searches for `tree-sitter`, `symbol.?index`, `call.?graph`, `codebase.?index`, diff --git a/eval/runner/main.go b/eval/runner/main.go index f69f402..b6db92e 100644 --- a/eval/runner/main.go +++ b/eval/runner/main.go @@ -40,6 +40,10 @@ type configuration struct { Mode string // MaxTokensPerRun is the hard budget for one attempt; 0 leaves the default. MaxTokensPerRun int + // RepoMap prepends a symbol digest of the work copy to the prompt. It is + // the treatment arm of the repository-index spike (roadmap B4): no new + // capability to call, just the map in hand. + RepoMap bool } var configurations = []configuration{ @@ -48,6 +52,11 @@ var configurations = []configuration{ {Name: "plan", Mode: "plan"}, } +// mapConfiguration is the spike's treatment arm. It is deliberately not in the +// default three: a run measures it only when -configurations names it, so the +// committed baseline keeps describing the same experiment it always did. +var mapConfiguration = configuration{Name: "map", Mode: "build", RepoMap: true} + // task is one directory under eval/tasks. type task struct { Name string @@ -93,6 +102,7 @@ func main() { timeout := flag.Duration("timeout", 180*time.Second, "wall-clock bound for one attempt") work := flag.String("work", "", "scratch directory (default: a fresh temporary one)") only := flag.String("configurations", "", "comma-separated subset of the configurations") + hidden := flag.String("hidden", "", "directory of hidden tests to install after each attempt (one subdirectory per task); empty grades the work copy as it is") render := flag.Bool("render", false, "re-render /BASELINE.md from /baseline.json, running nothing") limitedBudget := flag.Int("limited-budget", configurations[1].MaxTokensPerRun, "token budget for the `limited` configuration; a middle rung is only a gradient if it is above the cliff") @@ -127,7 +137,12 @@ func main() { fail(fmt.Errorf("no binary at %s: run `make build` first: %w", absBinary, err)) } - tasks, err := loadTasks(*tasksDir) + var tasks []task + if *hidden != "" { + tasks, err = loadSpikeTasks(*tasksDir, *hidden) + } else { + tasks, err = loadTasks(*tasksDir) + } if err != nil { fail(err) } @@ -157,7 +172,7 @@ func main() { for _, tk := range tasks { line := fmt.Sprintf(" %-18s ", tk.Name) for rep := 0; rep < *reps; rep++ { - result, err := attempt(scratch, absBinary, cfg, tk, rep, *timeout) + result, err := attempt(scratch, absBinary, cfg, tk, rep, *timeout, *hidden) if err != nil { fail(err) } @@ -187,7 +202,7 @@ func main() { } // attempt runs the agent once against one task and grades the result. -func attempt(scratch, binary string, cfg configuration, tk task, rep int, timeout time.Duration) (runResult, error) { +func attempt(scratch, binary string, cfg configuration, tk task, rep int, timeout time.Duration, hiddenDir string) (runResult, error) { base := filepath.Join(scratch, cfg.Name, tk.Name, fmt.Sprintf("rep%d", rep)) workDir := filepath.Join(base, "work") recordDir := filepath.Join(base, "records") @@ -219,7 +234,7 @@ func attempt(scratch, binary string, cfg configuration, tk task, rep int, timeou ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() - cmd := exec.CommandContext(ctx, binary, tk.Prompt) + cmd := exec.CommandContext(ctx, binary, promptFor(cfg, tk, workDir)) cmd.Dir = workDir logFile, err := os.Create(logPath) if err != nil { @@ -267,6 +282,15 @@ func attempt(scratch, binary string, cfg configuration, tk task, rep int, timeou result.Note = fmt.Sprintf("the run left %d trajectories", len(summaries)) } + // A hidden suite is installed after the run, so the agent never saw the + // assertions it is graded by: that is what makes the task measure finding + // the defect rather than reading the test's file name. + if hiddenDir != "" { + if err := installHiddenTests(hiddenDir, tk.Name, workDir); err != nil { + return runResult{}, err + } + } + // Graded by the task's own test command, in the copy the agent was given. result.Pass = gradeTask(workDir) modified, err := testsModified(tk, workDir) @@ -282,6 +306,39 @@ func attempt(scratch, binary string, cfg configuration, tk task, rep int, timeou return result, nil } +// copyTree copies a task subdirectory into the work copy, skipping the files +// that describe the task rather than the code (PROMPT, and TIER where it sits). +func copyTree(src, dst string) error { + if err := os.MkdirAll(dst, 0o755); err != nil { + return err + } + entries, err := os.ReadDir(src) + if err != nil { + return err + } + for _, entry := range entries { + from := filepath.Join(src, entry.Name()) + to := filepath.Join(dst, entry.Name()) + if entry.IsDir() { + if err := copyTree(from, to); err != nil { + return err + } + continue + } + if entry.Name() == "PROMPT" || entry.Name() == "TIER" { + continue + } + data, err := os.ReadFile(from) + if err != nil { + return err + } + if err := os.WriteFile(to, data, 0o644); err != nil { + return err + } + } + return nil +} + // gradeTask runs the task's own command and reports whether it passed. func gradeTask(dir string) bool { cmd := exec.Command("go", "test", "./...") @@ -396,7 +453,16 @@ func copyTask(tk task, dir string) error { return err } for _, entry := range entries { - if entry.IsDir() || entry.Name() == "PROMPT" { + if entry.IsDir() { + // A task may carry subpackages: the spike's tasks pad their tree so + // that finding the defect is work, and the padding has to reach the + // work copy or the treatment measures nothing. + if err := copyTree(filepath.Join(src, entry.Name()), filepath.Join(dir, entry.Name())); err != nil { + return err + } + continue + } + if entry.Name() == "PROMPT" { continue } in, err := os.Open(filepath.Join(src, entry.Name())) @@ -458,7 +524,11 @@ func selectConfigurations(list string) ([]configuration, error) { for _, name := range strings.Split(list, ",") { name = strings.TrimSpace(name) found := false - for _, cfg := range configurations { + pool := configurations + if mapConfiguration.Name == name { + pool = []configuration{mapConfiguration} + } + for _, cfg := range pool { if cfg.Name == name { chosen = append(chosen, cfg) found = true diff --git a/eval/runner/spike.go b/eval/runner/spike.go new file mode 100644 index 0000000..9201887 --- /dev/null +++ b/eval/runner/spike.go @@ -0,0 +1,245 @@ +package main + +// The repository-index spike (roadmap B4, issue #308). +// +// It needs three things the ordinary harness does not: tasks whose tests are +// hidden (so the agent cannot read where the defect is), a way to install those +// tests after the run, and a treatment arm that puts a symbol map in the +// prompt. They live here rather than in main.go because they exist for one +// question and the harness proper should not grow a second task format. + +import ( + "bytes" + "fmt" + "go/ast" + "go/format" + "go/parser" + "go/token" + "os" + "path/filepath" + "sort" + "strings" +) + +// mapBudgetTokens bounds the injected digest. 2000 tokens is roughly a page and +// a half: enough for a small module's whole surface, small enough that the +// treatment stays comparable to the baseline's prompt. +const mapBudgetTokens = 2000 + +// loadSpikeTasks reads the spike's task directories. It is loadTasks with one +// difference: the tests are not in the task, they are under hiddenDir, and they +// are the digests the tamper check compares against once installed. +func loadSpikeTasks(dir, hiddenDir string) ([]task, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return nil, fmt.Errorf("read spike tasks: %w", err) + } + var tasks []task + for _, entry := range entries { + if !entry.IsDir() { + continue + } + path := filepath.Join(dir, entry.Name()) + prompt, err := os.ReadFile(filepath.Join(path, "PROMPT")) + if err != nil { + return nil, fmt.Errorf("task %s has no PROMPT: %w", entry.Name(), err) + } + if _, err := os.Stat(filepath.Join(path, "go.mod")); err != nil { + return nil, fmt.Errorf("task %s has no go.mod: %w", entry.Name(), err) + } + tier, err := readTier(path) + if err != nil { + return nil, fmt.Errorf("task %s: %w", entry.Name(), err) + } + hiddenTask := filepath.Join(hiddenDir, entry.Name()) + digests, err := testDigests(hiddenTask) + if err != nil { + return nil, err + } + if len(digests) == 0 { + return nil, fmt.Errorf("task %s has no hidden test in %s", entry.Name(), hiddenTask) + } + tasks = append(tasks, task{ + Name: entry.Name(), + Dir: path, + Tier: tier, + Prompt: strings.TrimSpace(string(prompt)), + Tests: digests, + }) + } + if len(tasks) == 0 { + return nil, fmt.Errorf("no spike tasks in %s", dir) + } + sort.Slice(tasks, func(i, j int) bool { return tasks[i].Name < tasks[j].Name }) + return tasks, nil +} + +// installHiddenTests copies the hidden suite into the work copy after the run. +// Every file is overwritten, so a file the agent happened to create under the +// same name cannot shadow the assertions it is graded by. +func installHiddenTests(hiddenDir, taskName, workDir string) error { + dir := filepath.Join(hiddenDir, taskName) + entries, err := os.ReadDir(dir) + if err != nil { + return fmt.Errorf("hidden tests for %s: %w", taskName, err) + } + installed := 0 + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), "_test.go") { + continue + } + data, err := os.ReadFile(filepath.Join(dir, entry.Name())) + if err != nil { + return err + } + if err := os.WriteFile(filepath.Join(workDir, entry.Name()), data, 0o644); err != nil { + return err + } + installed++ + } + if installed == 0 { + return fmt.Errorf("hidden suite for %s is empty: grading would pass vacuously", taskName) + } + return nil +} + +// promptFor returns the prompt one attempt sends. The map arm prepends a symbol +// digest of the work copy and nothing else: the model gets no new capability, +// only the survey a reader would do first. +func promptFor(cfg configuration, tk task, workDir string) string { + if !cfg.RepoMap { + return tk.Prompt + } + digest, err := repoMapDigest(workDir, mapBudgetTokens) + if err != nil || strings.TrimSpace(digest) == "" { + // A map that cannot be built is the baseline, and the run says so in + // its log rather than silently measuring a shorter prompt as treatment. + return tk.Prompt + "\n\n(The symbol map could not be generated for this directory.)" + } + return "A symbol map of this directory follows. It lists every file, the symbols it exports and " + + "the imports it uses; it is a survey, not a substitute for reading the code.\n\n" + + digest + "\n\n" + tk.Prompt +} + +// repoMapDigest renders a bounded, sorted digest of a Go tree. +// +// It is deliberately shallow: package, file, exported declarations with their +// signatures, and imports. That is the information a reader builds by skimming +// a tree, which is exactly the work the spike asks whether a map can replace. +func repoMapDigest(dir string, maxTokens int) (string, error) { + type fileDigest struct { + path string + pkg string + imports []string + decls []string + } + var files []fileDigest + fset := token.NewFileSet() + + err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return nil // an unreadable corner is not the map's business + } + if info.IsDir() { + switch info.Name() { + case ".git", "vendor", "node_modules": + return filepath.SkipDir + } + return nil + } + if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { + return nil + } + file, err := parser.ParseFile(fset, path, nil, 0) + if err != nil { + return nil + } + rel, err := filepath.Rel(dir, path) + if err != nil { + rel = path + } + digest := fileDigest{path: rel, pkg: file.Name.Name} + for _, imp := range file.Imports { + digest.imports = append(digest.imports, strings.Trim(imp.Path.Value, `"`)) + } + for _, decl := range file.Decls { + switch d := decl.(type) { + case *ast.FuncDecl: + if !d.Name.IsExported() || d.Type == nil { + continue + } + signature := renderedType(fset, d.Type) + if signature == "" { + continue + } + if d.Recv != nil && len(d.Recv.List) > 0 { + digest.decls = append(digest.decls, fmt.Sprintf("func %s %s%s", renderedReceiver(fset, d.Recv.List[0]), d.Name.Name, signature)) + } else { + digest.decls = append(digest.decls, "func "+d.Name.Name+signature) + } + case *ast.GenDecl: + for _, spec := range d.Specs { + ts, ok := spec.(*ast.TypeSpec) + if !ok || !ts.Name.IsExported() || ts.Type == nil { + continue + } + kind := "type" + switch ts.Type.(type) { + case *ast.StructType: + kind = "struct" + case *ast.InterfaceType: + kind = "interface" + } + digest.decls = append(digest.decls, fmt.Sprintf("type %s %s", ts.Name.Name, kind)) + } + } + } + files = append(files, digest) + return nil + }) + if err != nil { + return "", err + } + sort.Slice(files, func(i, j int) bool { return files[i].path < files[j].path }) + + var b strings.Builder + for _, file := range files { + fmt.Fprintf(&b, "%s (package %s)\n", file.path, file.pkg) + if len(file.imports) > 0 { + fmt.Fprintf(&b, " imports: %s\n", strings.Join(file.imports, ", ")) + } + for _, decl := range file.decls { + fmt.Fprintf(&b, " %s\n", decl) + } + // Budget in the estimator the agent uses (4 bytes per token), checked + // after each file so the map never ends mid-signature. + if len(b.String())/4 >= maxTokens { + b.WriteString(" … (map truncated at its budget)\n") + break + } + } + return b.String(), nil +} + +// renderedReceiver prints a method's receiver as "(s *Store)", name included: +// the name is part of the signature a reader would skim. +func renderedReceiver(fset *token.FileSet, field *ast.Field) string { + typ := renderedType(fset, field.Type) + if typ == "" { + return "(?)" + } + if len(field.Names) == 0 { + return "(" + typ + ")" + } + return "(" + field.Names[0].Name + " " + typ + ")" +} + +// renderedType prints one type expression, without the leading "func" keyword +// the printer includes for a FuncType. +func renderedType(fset *token.FileSet, node ast.Expr) string { + var buf bytes.Buffer + if err := format.Node(&buf, fset, node); err != nil { + return "" + } + return strings.TrimPrefix(buf.String(), "func") +} diff --git a/eval/runner/spike_test.go b/eval/runner/spike_test.go new file mode 100644 index 0000000..ab2dc5f --- /dev/null +++ b/eval/runner/spike_test.go @@ -0,0 +1,225 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// TestSpikeTasksAreSolvable proves both halves of every spike task the way +// TestEveryTaskIsSolvable does for the visible suite: red with the hidden suite +// installed, green after the reference solution. A task that cannot be solved +// measures the task, not the agent. +func TestSpikeTasksAreSolvable(t *testing.T) { + tasks, err := loadSpikeTasks("../../eval/spike-repo-index/tasks", "../../eval/spike-repo-index/hidden") + if err != nil { + t.Fatalf("load spike tasks: %v", err) + } + if len(tasks) == 0 { + t.Fatal("no spike tasks") + } + for _, task := range tasks { + task := task + t.Run(task.Name, func(t *testing.T) { + work := t.TempDir() + if err := copyTask(task, work); err != nil { + t.Fatal(err) + } + // The work copy carries no test at all, which is the point: the + // agent must find the defect from the prompt. + if matches, _ := filepath.Glob(filepath.Join(work, "*_test.go")); len(matches) != 0 { + t.Fatalf("the work copy already carries a test: %v", matches) + } + if err := installHiddenTests("../../eval/spike-repo-index/hidden", task.Name, work); err != nil { + t.Fatal(err) + } + if gradeTask(work) { + t.Fatalf("task %s passes before any fix, so it measures nothing", task.Name) + } + + solutionDir := filepath.Join("../../eval/_solutions", task.Name) + entries, err := os.ReadDir(solutionDir) + if err != nil { + t.Fatalf("task %s has no reference solution: %v", task.Name, err) + } + applied := 0 + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".go") || strings.HasSuffix(entry.Name(), "_test.go") { + continue + } + data, err := os.ReadFile(filepath.Join(solutionDir, entry.Name())) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(work, entry.Name()), data, 0o644); err != nil { + t.Fatal(err) + } + applied++ + } + if applied == 0 { + t.Fatalf("the reference solution for %s is empty", task.Name) + } + if !gradeTask(work) { + t.Fatalf("task %s is still red after its reference solution", task.Name) + } + }) + } +} + +// TestSpikePromptsDoNotNameFiles keeps the corpus honest: a prompt that names a +// file hands the agent the answer to the question the spike asks. Naming the +// API is allowed and necessary — the assertions are hidden. +func TestSpikePromptsDoNotNameFiles(t *testing.T) { + tasks, err := loadSpikeTasks("../../eval/spike-repo-index/tasks", "../../eval/spike-repo-index/hidden") + if err != nil { + t.Fatal(err) + } + for _, task := range tasks { + entries, err := os.ReadDir(task.Dir) + if err != nil { + t.Fatal(err) + } + for _, entry := range entries { + if !strings.HasSuffix(entry.Name(), ".go") { + continue + } + if strings.Contains(task.Prompt, entry.Name()) { + t.Errorf("task %s's prompt names the file %q", task.Name, entry.Name()) + } + } + for _, leak := range []string{"internal/", "internal ", "package "} { + if strings.Contains(task.Prompt, leak) { + t.Errorf("task %s's prompt contains %q", task.Name, leak) + } + } + } +} + +// TestRepoMapDigest pins the map's shape and its budget: files sorted, exported +// declarations with signatures, and the whole thing bounded. +func TestRepoMapDigest(t *testing.T) { + dir := t.TempDir() + write := func(name, body string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(filepath.Join(dir, name)), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + write("store.go", `package task + +import "fmt" + +func NewStore() *Store { return &Store{} } + +type Store struct{} + +func (s *Store) Put(k string, v int) error { _ = fmt.Sprint(k); return nil } + +func hidden() {} +`) + write("internal/util/util.go", `package util + +// Exported is part of the map; the unexported one is not. +func Exported(n int) string { return "" } + +func unexported() {} +`) + + digest, err := repoMapDigest(dir, mapBudgetTokens) + if err != nil { + t.Fatalf("digest: %v", err) + } + for _, want := range []string{ + "store.go (package task)", + "func NewStore() *Store", + "type Store struct", + "func (s *Store) Put(k string, v int) error", + "internal/util/util.go (package util)", + "func Exported(n int) string", + "imports: fmt", + } { + if !strings.Contains(digest, want) { + t.Errorf("the map is missing %q:\n%s", want, digest) + } + } + for _, unwanted := range []string{"hidden()", "unexported()"} { + if strings.Contains(digest, unwanted) { + t.Errorf("the map lists %q, which is not exported surface", unwanted) + } + } + // Deterministic: two walks of the same tree say the same thing. + again, err := repoMapDigest(dir, mapBudgetTokens) + if err != nil || again != digest { + t.Fatalf("the map is not deterministic (err=%v)", err) + } + + // Bounded: a tiny budget stops the walk instead of returning everything. + small, err := repoMapDigest(dir, 1) + if err != nil { + t.Fatalf("small budget: %v", err) + } + if len(small) >= len(digest) { + t.Errorf("a one-token budget produced %d bytes, not fewer than %d", len(small), len(digest)) + } +} + +// TestInstallHiddenTestsRefusesAnEmptySuite pins the vacuous-pass guard. +func TestInstallHiddenTestsRefusesAnEmptySuite(t *testing.T) { + hidden := t.TempDir() + if err := os.MkdirAll(filepath.Join(hidden, "task"), 0o755); err != nil { + t.Fatal(err) + } + work := t.TempDir() + if err := installHiddenTests(hidden, "task", work); err == nil { + t.Fatal("an empty hidden suite was accepted, so grading would pass without assertions") + } +} + +// TestSpikeTaskWorkCopiesCarryThePadding pins that the noise reaches the work +// copy: a task whose subpackages were dropped measures one file, not a tree. +func TestSpikeTaskWorkCopiesCarryThePadding(t *testing.T) { + tasks, err := loadSpikeTasks("../../eval/spike-repo-index/tasks", "../../eval/spike-repo-index/hidden") + if err != nil { + t.Fatal(err) + } + for _, task := range tasks { + work := t.TempDir() + if err := copyTask(task, work); err != nil { + t.Fatal(err) + } + packages, err := filepath.Glob(filepath.Join(work, "internal", "*", "*.go")) + if err != nil { + t.Fatal(err) + } + if len(packages) < 8 { + t.Errorf("task %s reached the work copy with %d padding files, want the tree", task.Name, len(packages)) + } + } +} + +// TestPromptForPutsTheMapOnlyInTheMapArm pins the treatment: the baseline +// prompt is untouched, and the map arm's prompt carries the digest and the task. +func TestPromptForPutsTheMapOnlyInTheMapArm(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "a.go"), []byte("package task\n\nfunc Exported() {}\n"), 0o644); err != nil { + t.Fatal(err) + } + task := task{Name: "a", Dir: dir, Prompt: "Fix the behaviour."} + + plain := promptFor(configuration{Name: "build"}, task, dir) + if plain != task.Prompt { + t.Fatalf("the baseline prompt was altered: %q", plain) + } + + mapped := promptFor(configuration{Name: "map", RepoMap: true}, task, dir) + if !strings.Contains(mapped, "func Exported()") { + t.Fatalf("the map arm's prompt is missing the digest:\n%s", mapped) + } + if !strings.HasSuffix(mapped, task.Prompt) { + t.Fatalf("the map arm's prompt does not end with the task: %q", mapped) + } +} diff --git a/eval/spike-repo-index/go.mod b/eval/spike-repo-index/go.mod new file mode 100644 index 0000000..28b40c8 --- /dev/null +++ b/eval/spike-repo-index/go.mod @@ -0,0 +1,3 @@ +module tinycode.local/spike + +go 1.27 diff --git a/eval/spike-repo-index/hidden/t2-inventory/inventory_test.go b/eval/spike-repo-index/hidden/t2-inventory/inventory_test.go new file mode 100644 index 0000000..6d003ea --- /dev/null +++ b/eval/spike-repo-index/hidden/t2-inventory/inventory_test.go @@ -0,0 +1,29 @@ +package task + +import "testing" + +func TestReserveAndAvailable(t *testing.T) { + stock := NewStock(10) + if err := stock.Reserve(4); err != nil { + t.Fatalf("Reserve(4) = %v", err) + } + if got := stock.Available(); got != 6 { + t.Errorf("Available = %d, want 6", got) + } + if err := stock.Reserve(7); err == nil { + t.Error("a reservation larger than the stock succeeded") + } + if got := stock.Available(); got != 6 { + t.Errorf("a refused reservation changed Available to %d, want 6", got) + } +} + +func TestOrderWrapsTheRefusal(t *testing.T) { + order := NewOrder(NewStock(2)) + if err := order.Place(3); err == nil { + t.Fatal("Place accepted an oversized order") + } + if err := order.Place(2); err != nil { + t.Fatalf("Place(2) after a refused Place(3) = %v", err) + } +} diff --git a/eval/spike-repo-index/hidden/t2-ledger/ledger_test.go b/eval/spike-repo-index/hidden/t2-ledger/ledger_test.go new file mode 100644 index 0000000..8e5f92b --- /dev/null +++ b/eval/spike-repo-index/hidden/t2-ledger/ledger_test.go @@ -0,0 +1,47 @@ +package task + +import "testing" + +func TestTransferMovesFunds(t *testing.T) { + ledger := NewLedger() + ledger.Open("checking", 100) + ledger.Open("savings", 0) + + if err := ledger.Transfer("checking", "savings", 30); err != nil { + t.Fatalf("Transfer = %v, want nil", err) + } + checking, _ := ledger.Account("checking") + savings, _ := ledger.Account("savings") + if checking.Balance() != 70 || savings.Balance() != 30 { + t.Fatalf("balances = %d/%d, want 70/30", checking.Balance(), savings.Balance()) + } +} + +func TestTransferRefusesAnOverdraft(t *testing.T) { + ledger := NewLedger() + ledger.Open("checking", 100) + ledger.Open("savings", 0) + + if err := ledger.Transfer("checking", "savings", 1000); err == nil { + t.Fatal("Transfer accepted an overdraft") + } + checking, _ := ledger.Account("checking") + savings, _ := ledger.Account("savings") + if checking.Balance() != 100 { + t.Errorf("a refused transfer moved the source: balance = %d, want 100", checking.Balance()) + } + if savings.Balance() != 0 { + t.Errorf("a refused transfer moved the target: balance = %d, want 0", savings.Balance()) + } +} + +func TestTransferRefusesUnknownAccounts(t *testing.T) { + ledger := NewLedger() + ledger.Open("checking", 10) + if err := ledger.Transfer("checking", "nowhere", 1); err == nil { + t.Fatal("Transfer to an unknown account succeeded") + } + if err := ledger.Transfer("nowhere", "checking", 1); err == nil { + t.Fatal("Transfer from an unknown account succeeded") + } +} diff --git a/eval/spike-repo-index/hidden/t2-report/report_test.go b/eval/spike-repo-index/hidden/t2-report/report_test.go new file mode 100644 index 0000000..7067c23 --- /dev/null +++ b/eval/spike-repo-index/hidden/t2-report/report_test.go @@ -0,0 +1,21 @@ +package task + +import "testing" + +func TestReport(t *testing.T) { + rows := []Row{ + {Name: "nina", Scores: []int{3, 4}}, + {Name: "amir", Scores: []int{5, 5, 5}}, + {Name: "zoe"}, + } + want := "amir: 5.0\nnina: 3.5\nzoe: 0.0\n" + if got := Report(rows); got != want { + t.Errorf("Report =\n%q\nwant\n%q", got, want) + } +} + +func TestReportOfNothingIsEmpty(t *testing.T) { + if got := Report(nil); got != "" { + t.Errorf("Report(nil) = %q, want empty", got) + } +} diff --git a/eval/spike-repo-index/hidden/t3-boundary/usage_test.go b/eval/spike-repo-index/hidden/t3-boundary/usage_test.go new file mode 100644 index 0000000..9418633 --- /dev/null +++ b/eval/spike-repo-index/hidden/t3-boundary/usage_test.go @@ -0,0 +1,23 @@ +package task + +import "testing" + +func TestOffPeakIsHalfOpen(t *testing.T) { + usage := NewUsage(0, 1, 2, 3, 4) + if got := usage.OffPeak(1, 3); got != 2 { + t.Errorf("OffPeak(1, 3) = %d, want 2: the window is [1, 3), so 1 and 2", got) + } + if got := usage.OffPeak(0, 0); got != 0 { + t.Errorf("OffPeak(0, 0) = %d, want 0: an empty window", got) + } + if got := usage.OffPeak(4, 6); got != 1 { + t.Errorf("OffPeak(4, 6) = %d, want 1", got) + } +} + +func TestPeakIsTheRest(t *testing.T) { + usage := NewUsage(1, 2, 3) + if got := usage.Peak(1, 3); got != 1 { + t.Errorf("Peak(1, 3) = %d, want 1", got) + } +} diff --git a/eval/spike-repo-index/hidden/t3-cache/cache_test.go b/eval/spike-repo-index/hidden/t3-cache/cache_test.go new file mode 100644 index 0000000..ed2ee4b --- /dev/null +++ b/eval/spike-repo-index/hidden/t3-cache/cache_test.go @@ -0,0 +1,60 @@ +package task + +import "testing" + +func TestCacheExpiresAtTheBoundary(t *testing.T) { + clock := &Clock{} + cache := NewCache(clock, 8, 3) + cache.Put("k", 42) + if got := cache.Get("k"); got != 42 { + t.Fatalf("immediately after Put, Get = %d, want 42", got) + } + clock.Tick() + clock.Tick() + if got := cache.Get("k"); got != 42 { + t.Fatalf("two ticks after the write, Get = %d, want 42", got) + } + clock.Tick() + if got := cache.Get("k"); got != 0 { + t.Fatalf("exactly ttl ticks after the write, Get = %d, want 0", got) + } +} + +func TestCacheWriteRefreshesTheDeadline(t *testing.T) { + clock := &Clock{} + cache := NewCache(clock, 8, 3) + cache.Put("k", 1) + clock.Tick() + clock.Tick() + cache.Put("k", 2) + clock.Tick() + clock.Tick() + if got := cache.Get("k"); got != 2 { + t.Fatalf("Get = %d, want 2: the second write refreshed the deadline", got) + } + clock.Tick() + if got := cache.Get("k"); got != 0 { + t.Fatalf("Get = %d, want 0 one ttl after the refresh", got) + } +} + +func TestZeroCapacityStoresNothing(t *testing.T) { + clock := &Clock{} + cache := NewCache(clock, 0, 3) + cache.Put("k", 7) + if got := cache.Get("k"); got != 0 { + t.Fatalf("Get = %d, want 0 when the capacity is zero", got) + } +} + +func TestZeroTTLNeverExpires(t *testing.T) { + clock := &Clock{} + cache := NewCache(clock, 8, 0) + cache.Put("k", 9) + for i := 0; i < 100; i++ { + clock.Tick() + } + if got := cache.Get("k"); got != 9 { + t.Fatalf("Get = %d, want 9 when ttl is zero", got) + } +} diff --git a/eval/spike-repo-index/hidden/t3-extension/convert_test.go b/eval/spike-repo-index/hidden/t3-extension/convert_test.go new file mode 100644 index 0000000..f13ca9e --- /dev/null +++ b/eval/spike-repo-index/hidden/t3-extension/convert_test.go @@ -0,0 +1,12 @@ +package task + +import "testing" + +func TestToCelsius(t *testing.T) { + cases := map[float64]int{32: 0, 212: 100, 98.6: 37, 0: -17} + for f, want := range cases { + if got := ToCelsius(f); got != want { + t.Errorf("ToCelsius(%v) = %d, want %d", f, got, want) + } + } +} diff --git a/eval/spike-repo-index/hidden/t3-extension/extend_test.go b/eval/spike-repo-index/hidden/t3-extension/extend_test.go new file mode 100644 index 0000000..20fd972 --- /dev/null +++ b/eval/spike-repo-index/hidden/t3-extension/extend_test.go @@ -0,0 +1,16 @@ +package task + +import ( + "math" + "testing" +) + +func TestToFahrenheit(t *testing.T) { + cases := map[int]float64{0: 32, 100: 212, 37: 98.6, -17: 1.4} + for c, want := range cases { + got := ToFahrenheit(c) + if math.Abs(got-want) > 0.01 { + t.Errorf("ToFahrenheit(%d) = %v, want %v", c, got, want) + } + } +} diff --git a/eval/spike-repo-index/tasks/t2-inventory/PROMPT b/eval/spike-repo-index/tasks/t2-inventory/PROMPT new file mode 100644 index 0000000..2ddc096 --- /dev/null +++ b/eval/spike-repo-index/tasks/t2-inventory/PROMPT @@ -0,0 +1,16 @@ +This module is a small stock and order library. It has no tests: a hidden suite checks the behaviour +below, and it will be run in this directory afterwards. + +Keep these names and signatures: +- NewStock(n int) *Stock, (*Stock).Reserve(k int) error, (*Stock).Available() int +- NewOrder(s *Stock) *Order, (*Order).Place(k int) error + +Behaviour: +- NewStock(n) starts with n available. +- Reserve(k) fails when k exceeds Available(), and a refused Reserve leaves Available() unchanged. +- A successful Reserve(k) reduces Available() by exactly k. +- Place(k) reserves k through the stock and returns its error. +- A refused Place must not leave the order or the stock in a state where a later, affordable Place fails. + +The behaviour is wrong in more than one file. Fix the code. Do not fabricate a test file with the hidden +suite's names; explore however you like. diff --git a/eval/spike-repo-index/tasks/t2-inventory/TIER b/eval/spike-repo-index/tasks/t2-inventory/TIER new file mode 100644 index 0000000..0cfbf08 --- /dev/null +++ b/eval/spike-repo-index/tasks/t2-inventory/TIER @@ -0,0 +1 @@ +2 diff --git a/eval/spike-repo-index/tasks/t2-inventory/go.mod b/eval/spike-repo-index/tasks/t2-inventory/go.mod new file mode 100644 index 0000000..b0018be --- /dev/null +++ b/eval/spike-repo-index/tasks/t2-inventory/go.mod @@ -0,0 +1,3 @@ +module task.local/inventory + +go 1.27 diff --git a/eval/spike-repo-index/tasks/t2-inventory/internal/clock/clock.go b/eval/spike-repo-index/tasks/t2-inventory/internal/clock/clock.go new file mode 100644 index 0000000..b64c1c2 --- /dev/null +++ b/eval/spike-repo-index/tasks/t2-inventory/internal/clock/clock.go @@ -0,0 +1,30 @@ +// Package clock is the indirection that lets a test decide what time it is. +package clock + +import "time" + +// Clock reports the current time. +type Clock interface { + Now() time.Time +} + +// Real reads the wall clock. +type Real struct{} + +// Now returns time.Now. +func (Real) Now() time.Time { return time.Now() } + +// Fake returns a fixed time until Set moves it. +type Fake struct{ at time.Time } + +// NewFake starts a fake clock at t. +func NewFake(t time.Time) *Fake { return &Fake{at: t} } + +// Now returns the fake's current time. +func (f *Fake) Now() time.Time { return f.at } + +// Set moves the fake clock. +func (f *Fake) Set(t time.Time) { f.at = t } + +// Advance moves the fake clock forward by d. +func (f *Fake) Advance(d time.Duration) { f.at = f.at.Add(d) } diff --git a/eval/spike-repo-index/tasks/t2-inventory/internal/configs/configs.go b/eval/spike-repo-index/tasks/t2-inventory/internal/configs/configs.go new file mode 100644 index 0000000..ef982db --- /dev/null +++ b/eval/spike-repo-index/tasks/t2-inventory/internal/configs/configs.go @@ -0,0 +1,42 @@ +// Package configs reads key=value settings from a list of strings. +package configs + +import ( + "errors" + "strconv" + "strings" +) + +// ErrMalformed reports a line that is not key=value. +var ErrMalformed = errors.New("configs: expected key=value") + +// Parse reads key=value lines into a map. Blank lines and lines starting with # +// are ignored. +func Parse(lines []string) (map[string]string, error) { + out := map[string]string{} + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + key, value, ok := strings.Cut(line, "=") + if !ok || strings.TrimSpace(key) == "" { + return nil, ErrMalformed + } + out[strings.TrimSpace(key)] = strings.TrimSpace(value) + } + return out, nil +} + +// Int reads an integer setting, falling back when it is absent. +func Int(settings map[string]string, key string, fallback int) int { + raw, ok := settings[key] + if !ok { + return fallback + } + n, err := strconv.Atoi(raw) + if err != nil { + return fallback + } + return n +} diff --git a/eval/spike-repo-index/tasks/t2-inventory/internal/idgen/idgen.go b/eval/spike-repo-index/tasks/t2-inventory/internal/idgen/idgen.go new file mode 100644 index 0000000..0855dab --- /dev/null +++ b/eval/spike-repo-index/tasks/t2-inventory/internal/idgen/idgen.go @@ -0,0 +1,19 @@ +// Package idgen makes the short opaque ids a report or a URL can carry. +package idgen + +import ( + "crypto/rand" + "encoding/base32" + "strings" +) + +// New returns a 16-character id drawn from the operating system's entropy. +func New() string { + buf := make([]byte, 10) + if _, err := rand.Read(buf); err != nil { + // The caller has no better answer than a fixed string here, and a + // panic in an id generator would take the process down for cosmetics. + return "0000000000000000" + } + return strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(buf)) +} diff --git a/eval/spike-repo-index/tasks/t2-inventory/internal/logging/logging.go b/eval/spike-repo-index/tasks/t2-inventory/internal/logging/logging.go new file mode 100644 index 0000000..d279072 --- /dev/null +++ b/eval/spike-repo-index/tasks/t2-inventory/internal/logging/logging.go @@ -0,0 +1,46 @@ +// Package logging is the service's tiny leveled logger. +package logging + +import ( + "fmt" + "io" + "strings" +) + +// Level is how much a logger writes. +type Level int + +// Levels, from quietest to loudest. +const ( + LevelError Level = iota + LevelWarn + LevelInfo + LevelDebug +) + +// Logger writes one line per record, prefixed with its level. +type Logger struct { + Out io.Writer + Level Level +} + +// Printf writes one record when level is at or below the logger's own. +func (l *Logger) Printf(level Level, format string, args ...any) { + if l.Out == nil || level > l.Level { + return + } + fmt.Fprintf(l.Out, "%s: %s\n", strings.ToUpper(level.String()), fmt.Sprintf(format, args...)) +} + +// String names the level. +func (l Level) String() string { + switch l { + case LevelError: + return "error" + case LevelWarn: + return "warn" + case LevelInfo: + return "info" + } + return "debug" +} diff --git a/eval/spike-repo-index/tasks/t2-inventory/internal/metrics/metrics.go b/eval/spike-repo-index/tasks/t2-inventory/internal/metrics/metrics.go new file mode 100644 index 0000000..fc4b1a5 --- /dev/null +++ b/eval/spike-repo-index/tasks/t2-inventory/internal/metrics/metrics.go @@ -0,0 +1,42 @@ +// Package metrics keeps process-wide counters for the service's own reports. +package metrics + +import "sync" + +// Registry holds named counters. It is safe for concurrent use. +type Registry struct { + mu sync.Mutex + counters map[string]int64 +} + +// New returns an empty registry. +func New() *Registry { return &Registry{counters: map[string]int64{}} } + +// Inc adds one to the counter named key. +func (r *Registry) Inc(key string) { r.Add(key, 1) } + +// Add adds n to the counter named key. +func (r *Registry) Add(key string, n int64) { + r.mu.Lock() + defer r.mu.Unlock() + r.counters[key] += n +} + +// Get reports the counter's value, zero when it was never incremented. +func (r *Registry) Get(key string) int64 { + r.mu.Lock() + defer r.mu.Unlock() + return r.counters[key] +} + +// Snapshot copies the counters, which is what a report should read: the live +// map must not change under a writer. +func (r *Registry) Snapshot() map[string]int64 { + r.mu.Lock() + defer r.mu.Unlock() + out := make(map[string]int64, len(r.counters)) + for k, v := range r.counters { + out[k] = v + } + return out +} diff --git a/eval/spike-repo-index/tasks/t2-inventory/internal/queue/queue.go b/eval/spike-repo-index/tasks/t2-inventory/internal/queue/queue.go new file mode 100644 index 0000000..831ef0d --- /dev/null +++ b/eval/spike-repo-index/tasks/t2-inventory/internal/queue/queue.go @@ -0,0 +1,37 @@ +// Package queue is a fixed-capacity ring buffer of strings. +package queue + +// Ring holds at most cap items; the oldest is overwritten when full. +type Ring struct { + items []string + head int + size int +} + +// New returns a ring of the given capacity. +func New(capacity int) *Ring { return &Ring{items: make([]string, capacity)} } + +// Push appends one item, overwriting the oldest when the ring is full. +func (r *Ring) Push(item string) { + if len(r.items) == 0 { + return + } + r.items[r.head] = item + r.head = (r.head + 1) % len(r.items) + if r.size < len(r.items) { + r.size++ + } +} + +// Len reports how many items the ring holds. +func (r *Ring) Len() int { return r.size } + +// Slice returns the items oldest first. +func (r *Ring) Slice() []string { + out := make([]string, 0, r.size) + start := (r.head - r.size + len(r.items)) % len(r.items) + for i := 0; i < r.size; i++ { + out = append(out, r.items[(start+i)%len(r.items)]) + } + return out +} diff --git a/eval/spike-repo-index/tasks/t2-inventory/internal/retry/retry.go b/eval/spike-repo-index/tasks/t2-inventory/internal/retry/retry.go new file mode 100644 index 0000000..cb7fdba --- /dev/null +++ b/eval/spike-repo-index/tasks/t2-inventory/internal/retry/retry.go @@ -0,0 +1,29 @@ +// Package retry computes the wait between attempts. +package retry + +import ( + "math" + "time" +) + +// Policy is a capped exponential backoff. +type Policy struct { + Base time.Duration + Max time.Duration + Tries int +} + +// Wait returns how long to sleep before attempt n (1-based). +func (p Policy) Wait(n int) time.Duration { + if n <= 1 { + return 0 + } + wait := time.Duration(float64(p.Base) * math.Pow(2, float64(n-2))) + if wait > p.Max || wait <= 0 { + return p.Max + } + return wait +} + +// Exhausted reports whether n attempts have been spent. +func (p Policy) Exhausted(n int) bool { return p.Tries > 0 && n >= p.Tries } diff --git a/eval/spike-repo-index/tasks/t2-inventory/internal/textutil/textutil.go b/eval/spike-repo-index/tasks/t2-inventory/internal/textutil/textutil.go new file mode 100644 index 0000000..c0a5ec2 --- /dev/null +++ b/eval/spike-repo-index/tasks/t2-inventory/internal/textutil/textutil.go @@ -0,0 +1,30 @@ +// Package textutil holds small string helpers shared by reports. +package textutil + +import "strings" + +// Truncate shortens s to at most n runes, appending an ellipsis when it cut. +func Truncate(s string, n int) string { + runes := []rune(s) + if len(runes) <= n { + return s + } + return string(runes[:n]) + "…" +} + +// Title uppercases the first letter and leaves the rest alone. +func Title(s string) string { + if s == "" { + return s + } + return strings.ToUpper(s[:1]) + s[1:] +} + +// SplitLines splits on both CRLF and LF and drops a trailing empty line. +func SplitLines(s string) []string { + lines := strings.Split(strings.ReplaceAll(s, "\r\n", "\n"), "\n") + if n := len(lines); n > 0 && lines[n-1] == "" { + return lines[:n-1] + } + return lines +} diff --git a/eval/spike-repo-index/tasks/t2-inventory/internal/validate/validate.go b/eval/spike-repo-index/tasks/t2-inventory/internal/validate/validate.go new file mode 100644 index 0000000..75dc1a9 --- /dev/null +++ b/eval/spike-repo-index/tasks/t2-inventory/internal/validate/validate.go @@ -0,0 +1,31 @@ +// Package validate checks the shape of values before they reach the store. +package validate + +import ( + "errors" + "strings" + "unicode/utf8" +) + +// Errors a caller can tell apart. +var ( + ErrEmpty = errors.New("validate: empty value") + ErrTooLong = errors.New("validate: value too long") +) + +// Limit is the longest value the service accepts. +const Limit = 512 + +// Value rejects empty, over-long and non-UTF8 strings. +func Value(s string) error { + if strings.TrimSpace(s) == "" { + return ErrEmpty + } + if !utf8.ValidString(s) { + return ErrEmpty + } + if utf8.RuneCountInString(s) > Limit { + return ErrTooLong + } + return nil +} diff --git a/eval/spike-repo-index/tasks/t2-inventory/internal/version/version.go b/eval/spike-repo-index/tasks/t2-inventory/internal/version/version.go new file mode 100644 index 0000000..e1cf850 --- /dev/null +++ b/eval/spike-repo-index/tasks/t2-inventory/internal/version/version.go @@ -0,0 +1,46 @@ +// Package version parses and compares dotted version strings. +package version + +import ( + "strconv" + "strings" +) + +// Parse reads a dotted version into its numeric parts, ignoring a pre-release +// suffix: "1.2.3-rc1" is 1,2,3. +func Parse(s string) ([]int, bool) { + if i := strings.IndexAny(s, "-+"); i >= 0 { + s = s[:i] + } + parts := strings.Split(s, ".") + out := make([]int, 0, len(parts)) + for _, part := range parts { + n, err := strconv.Atoi(part) + if err != nil || n < 0 { + return nil, false + } + out = append(out, n) + } + return out, len(out) > 0 +} + +// Less reports whether a sorts before b, padding the shorter with zeros. +func Less(a, b []int) bool { + n := len(a) + if len(b) > n { + n = len(b) + } + for i := 0; i < n; i++ { + x, y := 0, 0 + if i < len(a) { + x = a[i] + } + if i < len(b) { + y = b[i] + } + if x != y { + return x < y + } + } + return false +} diff --git a/eval/spike-repo-index/tasks/t2-inventory/orders.go b/eval/spike-repo-index/tasks/t2-inventory/orders.go new file mode 100644 index 0000000..54da4d5 --- /dev/null +++ b/eval/spike-repo-index/tasks/t2-inventory/orders.go @@ -0,0 +1,19 @@ +package task + +import "fmt" + +// Order reserves units on behalf of a customer. +type Order struct { + stock *Stock +} + +// NewOrder returns an order book over one stock. +func NewOrder(stock *Stock) *Order { return &Order{stock: stock} } + +// Place reserves n units and reports a failure with the order's size in it. +func (o *Order) Place(n int) error { + if err := o.stock.Reserve(n); err != nil { + return fmt.Errorf("order %d: %w", n, err) + } + return nil +} diff --git a/eval/spike-repo-index/tasks/t2-inventory/stock.go b/eval/spike-repo-index/tasks/t2-inventory/stock.go new file mode 100644 index 0000000..de7ad46 --- /dev/null +++ b/eval/spike-repo-index/tasks/t2-inventory/stock.go @@ -0,0 +1,20 @@ +package task + +// Stock tracks how many units are on hand and how many have been reserved. +type Stock struct { + onHand int + reserved int +} + +// NewStock returns a stock with the given number of units on hand. +func NewStock(onHand int) *Stock { return &Stock{onHand: onHand} } + +// Reserve sets aside n units. It refuses more than is available, and a refused +// reservation changes nothing. +func (s *Stock) Reserve(n int) error { + s.reserved += n + return nil +} + +// Available is how many units can still be reserved. +func (s *Stock) Available() int { return s.onHand } diff --git a/eval/spike-repo-index/tasks/t2-ledger/PROMPT b/eval/spike-repo-index/tasks/t2-ledger/PROMPT new file mode 100644 index 0000000..93e7a8c --- /dev/null +++ b/eval/spike-repo-index/tasks/t2-ledger/PROMPT @@ -0,0 +1,13 @@ +This module is a small double-entry ledger. It has no tests: a hidden suite checks the behaviour below. + +Keep these names and signatures: +- NewLedger() *Ledger, (*Ledger).Open(name string, balance int), (*Ledger).Account(name string) (*Account, bool) +- (*Account).Balance() int, (*Ledger).Transfer(from, to string, amount int) error + +Behaviour: +- Open creates an account with a starting balance. +- Transfer moves amount from one open account to another, atomically: when it returns an error, nothing moved. +- It refuses when either account is unknown, when the source cannot cover the amount, and when amount is not positive. +- Balances never go negative, and a refused transfer leaves both balances exactly as they were. + +The behaviour is wrong in more than one file. Fix the code. diff --git a/eval/spike-repo-index/tasks/t2-ledger/TIER b/eval/spike-repo-index/tasks/t2-ledger/TIER new file mode 100644 index 0000000..0cfbf08 --- /dev/null +++ b/eval/spike-repo-index/tasks/t2-ledger/TIER @@ -0,0 +1 @@ +2 diff --git a/eval/spike-repo-index/tasks/t2-ledger/account.go b/eval/spike-repo-index/tasks/t2-ledger/account.go new file mode 100644 index 0000000..5cf9507 --- /dev/null +++ b/eval/spike-repo-index/tasks/t2-ledger/account.go @@ -0,0 +1,18 @@ +package task + +// Account holds a balance in whole units. +type Account struct { + balance int +} + +// NewAccount opens an account with the given balance. +func NewAccount(opening int) *Account { + return &Account{balance: opening} +} + +// Balance reports the current balance. +func (a *Account) Balance() int { return a.balance } + +// Deposit adds n to the balance. A negative n subtracts, which is how a +// withdrawal is expressed today. +func (a *Account) Deposit(n int) { a.balance += n } diff --git a/eval/spike-repo-index/tasks/t2-ledger/go.mod b/eval/spike-repo-index/tasks/t2-ledger/go.mod new file mode 100644 index 0000000..0d22058 --- /dev/null +++ b/eval/spike-repo-index/tasks/t2-ledger/go.mod @@ -0,0 +1,3 @@ +module task.local/ledger + +go 1.27 diff --git a/eval/spike-repo-index/tasks/t2-ledger/internal/clock/clock.go b/eval/spike-repo-index/tasks/t2-ledger/internal/clock/clock.go new file mode 100644 index 0000000..b64c1c2 --- /dev/null +++ b/eval/spike-repo-index/tasks/t2-ledger/internal/clock/clock.go @@ -0,0 +1,30 @@ +// Package clock is the indirection that lets a test decide what time it is. +package clock + +import "time" + +// Clock reports the current time. +type Clock interface { + Now() time.Time +} + +// Real reads the wall clock. +type Real struct{} + +// Now returns time.Now. +func (Real) Now() time.Time { return time.Now() } + +// Fake returns a fixed time until Set moves it. +type Fake struct{ at time.Time } + +// NewFake starts a fake clock at t. +func NewFake(t time.Time) *Fake { return &Fake{at: t} } + +// Now returns the fake's current time. +func (f *Fake) Now() time.Time { return f.at } + +// Set moves the fake clock. +func (f *Fake) Set(t time.Time) { f.at = t } + +// Advance moves the fake clock forward by d. +func (f *Fake) Advance(d time.Duration) { f.at = f.at.Add(d) } diff --git a/eval/spike-repo-index/tasks/t2-ledger/internal/configs/configs.go b/eval/spike-repo-index/tasks/t2-ledger/internal/configs/configs.go new file mode 100644 index 0000000..ef982db --- /dev/null +++ b/eval/spike-repo-index/tasks/t2-ledger/internal/configs/configs.go @@ -0,0 +1,42 @@ +// Package configs reads key=value settings from a list of strings. +package configs + +import ( + "errors" + "strconv" + "strings" +) + +// ErrMalformed reports a line that is not key=value. +var ErrMalformed = errors.New("configs: expected key=value") + +// Parse reads key=value lines into a map. Blank lines and lines starting with # +// are ignored. +func Parse(lines []string) (map[string]string, error) { + out := map[string]string{} + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + key, value, ok := strings.Cut(line, "=") + if !ok || strings.TrimSpace(key) == "" { + return nil, ErrMalformed + } + out[strings.TrimSpace(key)] = strings.TrimSpace(value) + } + return out, nil +} + +// Int reads an integer setting, falling back when it is absent. +func Int(settings map[string]string, key string, fallback int) int { + raw, ok := settings[key] + if !ok { + return fallback + } + n, err := strconv.Atoi(raw) + if err != nil { + return fallback + } + return n +} diff --git a/eval/spike-repo-index/tasks/t2-ledger/internal/idgen/idgen.go b/eval/spike-repo-index/tasks/t2-ledger/internal/idgen/idgen.go new file mode 100644 index 0000000..0855dab --- /dev/null +++ b/eval/spike-repo-index/tasks/t2-ledger/internal/idgen/idgen.go @@ -0,0 +1,19 @@ +// Package idgen makes the short opaque ids a report or a URL can carry. +package idgen + +import ( + "crypto/rand" + "encoding/base32" + "strings" +) + +// New returns a 16-character id drawn from the operating system's entropy. +func New() string { + buf := make([]byte, 10) + if _, err := rand.Read(buf); err != nil { + // The caller has no better answer than a fixed string here, and a + // panic in an id generator would take the process down for cosmetics. + return "0000000000000000" + } + return strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(buf)) +} diff --git a/eval/spike-repo-index/tasks/t2-ledger/internal/logging/logging.go b/eval/spike-repo-index/tasks/t2-ledger/internal/logging/logging.go new file mode 100644 index 0000000..d279072 --- /dev/null +++ b/eval/spike-repo-index/tasks/t2-ledger/internal/logging/logging.go @@ -0,0 +1,46 @@ +// Package logging is the service's tiny leveled logger. +package logging + +import ( + "fmt" + "io" + "strings" +) + +// Level is how much a logger writes. +type Level int + +// Levels, from quietest to loudest. +const ( + LevelError Level = iota + LevelWarn + LevelInfo + LevelDebug +) + +// Logger writes one line per record, prefixed with its level. +type Logger struct { + Out io.Writer + Level Level +} + +// Printf writes one record when level is at or below the logger's own. +func (l *Logger) Printf(level Level, format string, args ...any) { + if l.Out == nil || level > l.Level { + return + } + fmt.Fprintf(l.Out, "%s: %s\n", strings.ToUpper(level.String()), fmt.Sprintf(format, args...)) +} + +// String names the level. +func (l Level) String() string { + switch l { + case LevelError: + return "error" + case LevelWarn: + return "warn" + case LevelInfo: + return "info" + } + return "debug" +} diff --git a/eval/spike-repo-index/tasks/t2-ledger/internal/metrics/metrics.go b/eval/spike-repo-index/tasks/t2-ledger/internal/metrics/metrics.go new file mode 100644 index 0000000..fc4b1a5 --- /dev/null +++ b/eval/spike-repo-index/tasks/t2-ledger/internal/metrics/metrics.go @@ -0,0 +1,42 @@ +// Package metrics keeps process-wide counters for the service's own reports. +package metrics + +import "sync" + +// Registry holds named counters. It is safe for concurrent use. +type Registry struct { + mu sync.Mutex + counters map[string]int64 +} + +// New returns an empty registry. +func New() *Registry { return &Registry{counters: map[string]int64{}} } + +// Inc adds one to the counter named key. +func (r *Registry) Inc(key string) { r.Add(key, 1) } + +// Add adds n to the counter named key. +func (r *Registry) Add(key string, n int64) { + r.mu.Lock() + defer r.mu.Unlock() + r.counters[key] += n +} + +// Get reports the counter's value, zero when it was never incremented. +func (r *Registry) Get(key string) int64 { + r.mu.Lock() + defer r.mu.Unlock() + return r.counters[key] +} + +// Snapshot copies the counters, which is what a report should read: the live +// map must not change under a writer. +func (r *Registry) Snapshot() map[string]int64 { + r.mu.Lock() + defer r.mu.Unlock() + out := make(map[string]int64, len(r.counters)) + for k, v := range r.counters { + out[k] = v + } + return out +} diff --git a/eval/spike-repo-index/tasks/t2-ledger/internal/queue/queue.go b/eval/spike-repo-index/tasks/t2-ledger/internal/queue/queue.go new file mode 100644 index 0000000..831ef0d --- /dev/null +++ b/eval/spike-repo-index/tasks/t2-ledger/internal/queue/queue.go @@ -0,0 +1,37 @@ +// Package queue is a fixed-capacity ring buffer of strings. +package queue + +// Ring holds at most cap items; the oldest is overwritten when full. +type Ring struct { + items []string + head int + size int +} + +// New returns a ring of the given capacity. +func New(capacity int) *Ring { return &Ring{items: make([]string, capacity)} } + +// Push appends one item, overwriting the oldest when the ring is full. +func (r *Ring) Push(item string) { + if len(r.items) == 0 { + return + } + r.items[r.head] = item + r.head = (r.head + 1) % len(r.items) + if r.size < len(r.items) { + r.size++ + } +} + +// Len reports how many items the ring holds. +func (r *Ring) Len() int { return r.size } + +// Slice returns the items oldest first. +func (r *Ring) Slice() []string { + out := make([]string, 0, r.size) + start := (r.head - r.size + len(r.items)) % len(r.items) + for i := 0; i < r.size; i++ { + out = append(out, r.items[(start+i)%len(r.items)]) + } + return out +} diff --git a/eval/spike-repo-index/tasks/t2-ledger/internal/retry/retry.go b/eval/spike-repo-index/tasks/t2-ledger/internal/retry/retry.go new file mode 100644 index 0000000..cb7fdba --- /dev/null +++ b/eval/spike-repo-index/tasks/t2-ledger/internal/retry/retry.go @@ -0,0 +1,29 @@ +// Package retry computes the wait between attempts. +package retry + +import ( + "math" + "time" +) + +// Policy is a capped exponential backoff. +type Policy struct { + Base time.Duration + Max time.Duration + Tries int +} + +// Wait returns how long to sleep before attempt n (1-based). +func (p Policy) Wait(n int) time.Duration { + if n <= 1 { + return 0 + } + wait := time.Duration(float64(p.Base) * math.Pow(2, float64(n-2))) + if wait > p.Max || wait <= 0 { + return p.Max + } + return wait +} + +// Exhausted reports whether n attempts have been spent. +func (p Policy) Exhausted(n int) bool { return p.Tries > 0 && n >= p.Tries } diff --git a/eval/spike-repo-index/tasks/t2-ledger/internal/textutil/textutil.go b/eval/spike-repo-index/tasks/t2-ledger/internal/textutil/textutil.go new file mode 100644 index 0000000..c0a5ec2 --- /dev/null +++ b/eval/spike-repo-index/tasks/t2-ledger/internal/textutil/textutil.go @@ -0,0 +1,30 @@ +// Package textutil holds small string helpers shared by reports. +package textutil + +import "strings" + +// Truncate shortens s to at most n runes, appending an ellipsis when it cut. +func Truncate(s string, n int) string { + runes := []rune(s) + if len(runes) <= n { + return s + } + return string(runes[:n]) + "…" +} + +// Title uppercases the first letter and leaves the rest alone. +func Title(s string) string { + if s == "" { + return s + } + return strings.ToUpper(s[:1]) + s[1:] +} + +// SplitLines splits on both CRLF and LF and drops a trailing empty line. +func SplitLines(s string) []string { + lines := strings.Split(strings.ReplaceAll(s, "\r\n", "\n"), "\n") + if n := len(lines); n > 0 && lines[n-1] == "" { + return lines[:n-1] + } + return lines +} diff --git a/eval/spike-repo-index/tasks/t2-ledger/internal/validate/validate.go b/eval/spike-repo-index/tasks/t2-ledger/internal/validate/validate.go new file mode 100644 index 0000000..75dc1a9 --- /dev/null +++ b/eval/spike-repo-index/tasks/t2-ledger/internal/validate/validate.go @@ -0,0 +1,31 @@ +// Package validate checks the shape of values before they reach the store. +package validate + +import ( + "errors" + "strings" + "unicode/utf8" +) + +// Errors a caller can tell apart. +var ( + ErrEmpty = errors.New("validate: empty value") + ErrTooLong = errors.New("validate: value too long") +) + +// Limit is the longest value the service accepts. +const Limit = 512 + +// Value rejects empty, over-long and non-UTF8 strings. +func Value(s string) error { + if strings.TrimSpace(s) == "" { + return ErrEmpty + } + if !utf8.ValidString(s) { + return ErrEmpty + } + if utf8.RuneCountInString(s) > Limit { + return ErrTooLong + } + return nil +} diff --git a/eval/spike-repo-index/tasks/t2-ledger/internal/version/version.go b/eval/spike-repo-index/tasks/t2-ledger/internal/version/version.go new file mode 100644 index 0000000..e1cf850 --- /dev/null +++ b/eval/spike-repo-index/tasks/t2-ledger/internal/version/version.go @@ -0,0 +1,46 @@ +// Package version parses and compares dotted version strings. +package version + +import ( + "strconv" + "strings" +) + +// Parse reads a dotted version into its numeric parts, ignoring a pre-release +// suffix: "1.2.3-rc1" is 1,2,3. +func Parse(s string) ([]int, bool) { + if i := strings.IndexAny(s, "-+"); i >= 0 { + s = s[:i] + } + parts := strings.Split(s, ".") + out := make([]int, 0, len(parts)) + for _, part := range parts { + n, err := strconv.Atoi(part) + if err != nil || n < 0 { + return nil, false + } + out = append(out, n) + } + return out, len(out) > 0 +} + +// Less reports whether a sorts before b, padding the shorter with zeros. +func Less(a, b []int) bool { + n := len(a) + if len(b) > n { + n = len(b) + } + for i := 0; i < n; i++ { + x, y := 0, 0 + if i < len(a) { + x = a[i] + } + if i < len(b) { + y = b[i] + } + if x != y { + return x < y + } + } + return false +} diff --git a/eval/spike-repo-index/tasks/t2-ledger/ledger.go b/eval/spike-repo-index/tasks/t2-ledger/ledger.go new file mode 100644 index 0000000..d6b72a4 --- /dev/null +++ b/eval/spike-repo-index/tasks/t2-ledger/ledger.go @@ -0,0 +1,44 @@ +package task + +import "fmt" + +// Ledger holds named accounts. +type Ledger struct { + accounts map[string]*Account +} + +// NewLedger returns a ledger with no accounts. +func NewLedger() *Ledger { return &Ledger{accounts: make(map[string]*Account)} } + +// Open creates an account and returns it. +func (l *Ledger) Open(name string, opening int) *Account { + account := NewAccount(opening) + l.accounts[name] = account + return account +} + +// Account returns the named account, if it exists. +func (l *Ledger) Account(name string) (*Account, bool) { + account, ok := l.accounts[name] + return account, ok +} + +// Transfer moves n from one account to another. It refuses an unknown account +// and it refuses to leave the source account overdrawn; a refused transfer +// leaves both balances as they were. +func (l *Ledger) Transfer(from, to string, n int) error { + src, ok := l.accounts[from] + if !ok { + return fmt.Errorf("transfer: no account %q", from) + } + dst, ok := l.accounts[to] + if !ok { + return fmt.Errorf("transfer: no account %q", to) + } + if n < 0 { + return fmt.Errorf("transfer: amount %d is negative", n) + } + src.Deposit(-n) + dst.Deposit(n) + return nil +} diff --git a/eval/spike-repo-index/tasks/t2-report/PROMPT b/eval/spike-repo-index/tasks/t2-report/PROMPT new file mode 100644 index 0000000..78641b5 --- /dev/null +++ b/eval/spike-repo-index/tasks/t2-report/PROMPT @@ -0,0 +1,14 @@ +This module renders a small report. It has no tests: a hidden suite checks the exact output below. + +Keep these names and signatures: +- type Row struct { Name string; Scores []int } +- Report(rows []Row) string + +Output contract: +- One line per row: the name, a colon, a space, the row's arithmetic mean formatted with one decimal + place (fmt's %.1f), then a newline. For example: "amir: 5.0\n". +- Rows are ordered by name, ascending (byte order). +- A row with no scores renders "0.0". +- No rows renders the empty string, not a newline. + +The behaviour is wrong across two files. Fix the code. diff --git a/eval/spike-repo-index/tasks/t2-report/TIER b/eval/spike-repo-index/tasks/t2-report/TIER new file mode 100644 index 0000000..0cfbf08 --- /dev/null +++ b/eval/spike-repo-index/tasks/t2-report/TIER @@ -0,0 +1 @@ +2 diff --git a/eval/spike-repo-index/tasks/t2-report/go.mod b/eval/spike-repo-index/tasks/t2-report/go.mod new file mode 100644 index 0000000..d34f29e --- /dev/null +++ b/eval/spike-repo-index/tasks/t2-report/go.mod @@ -0,0 +1,3 @@ +module task.local/report + +go 1.27 diff --git a/eval/spike-repo-index/tasks/t2-report/internal/clock/clock.go b/eval/spike-repo-index/tasks/t2-report/internal/clock/clock.go new file mode 100644 index 0000000..b64c1c2 --- /dev/null +++ b/eval/spike-repo-index/tasks/t2-report/internal/clock/clock.go @@ -0,0 +1,30 @@ +// Package clock is the indirection that lets a test decide what time it is. +package clock + +import "time" + +// Clock reports the current time. +type Clock interface { + Now() time.Time +} + +// Real reads the wall clock. +type Real struct{} + +// Now returns time.Now. +func (Real) Now() time.Time { return time.Now() } + +// Fake returns a fixed time until Set moves it. +type Fake struct{ at time.Time } + +// NewFake starts a fake clock at t. +func NewFake(t time.Time) *Fake { return &Fake{at: t} } + +// Now returns the fake's current time. +func (f *Fake) Now() time.Time { return f.at } + +// Set moves the fake clock. +func (f *Fake) Set(t time.Time) { f.at = t } + +// Advance moves the fake clock forward by d. +func (f *Fake) Advance(d time.Duration) { f.at = f.at.Add(d) } diff --git a/eval/spike-repo-index/tasks/t2-report/internal/configs/configs.go b/eval/spike-repo-index/tasks/t2-report/internal/configs/configs.go new file mode 100644 index 0000000..ef982db --- /dev/null +++ b/eval/spike-repo-index/tasks/t2-report/internal/configs/configs.go @@ -0,0 +1,42 @@ +// Package configs reads key=value settings from a list of strings. +package configs + +import ( + "errors" + "strconv" + "strings" +) + +// ErrMalformed reports a line that is not key=value. +var ErrMalformed = errors.New("configs: expected key=value") + +// Parse reads key=value lines into a map. Blank lines and lines starting with # +// are ignored. +func Parse(lines []string) (map[string]string, error) { + out := map[string]string{} + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + key, value, ok := strings.Cut(line, "=") + if !ok || strings.TrimSpace(key) == "" { + return nil, ErrMalformed + } + out[strings.TrimSpace(key)] = strings.TrimSpace(value) + } + return out, nil +} + +// Int reads an integer setting, falling back when it is absent. +func Int(settings map[string]string, key string, fallback int) int { + raw, ok := settings[key] + if !ok { + return fallback + } + n, err := strconv.Atoi(raw) + if err != nil { + return fallback + } + return n +} diff --git a/eval/spike-repo-index/tasks/t2-report/internal/idgen/idgen.go b/eval/spike-repo-index/tasks/t2-report/internal/idgen/idgen.go new file mode 100644 index 0000000..0855dab --- /dev/null +++ b/eval/spike-repo-index/tasks/t2-report/internal/idgen/idgen.go @@ -0,0 +1,19 @@ +// Package idgen makes the short opaque ids a report or a URL can carry. +package idgen + +import ( + "crypto/rand" + "encoding/base32" + "strings" +) + +// New returns a 16-character id drawn from the operating system's entropy. +func New() string { + buf := make([]byte, 10) + if _, err := rand.Read(buf); err != nil { + // The caller has no better answer than a fixed string here, and a + // panic in an id generator would take the process down for cosmetics. + return "0000000000000000" + } + return strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(buf)) +} diff --git a/eval/spike-repo-index/tasks/t2-report/internal/logging/logging.go b/eval/spike-repo-index/tasks/t2-report/internal/logging/logging.go new file mode 100644 index 0000000..d279072 --- /dev/null +++ b/eval/spike-repo-index/tasks/t2-report/internal/logging/logging.go @@ -0,0 +1,46 @@ +// Package logging is the service's tiny leveled logger. +package logging + +import ( + "fmt" + "io" + "strings" +) + +// Level is how much a logger writes. +type Level int + +// Levels, from quietest to loudest. +const ( + LevelError Level = iota + LevelWarn + LevelInfo + LevelDebug +) + +// Logger writes one line per record, prefixed with its level. +type Logger struct { + Out io.Writer + Level Level +} + +// Printf writes one record when level is at or below the logger's own. +func (l *Logger) Printf(level Level, format string, args ...any) { + if l.Out == nil || level > l.Level { + return + } + fmt.Fprintf(l.Out, "%s: %s\n", strings.ToUpper(level.String()), fmt.Sprintf(format, args...)) +} + +// String names the level. +func (l Level) String() string { + switch l { + case LevelError: + return "error" + case LevelWarn: + return "warn" + case LevelInfo: + return "info" + } + return "debug" +} diff --git a/eval/spike-repo-index/tasks/t2-report/internal/metrics/metrics.go b/eval/spike-repo-index/tasks/t2-report/internal/metrics/metrics.go new file mode 100644 index 0000000..fc4b1a5 --- /dev/null +++ b/eval/spike-repo-index/tasks/t2-report/internal/metrics/metrics.go @@ -0,0 +1,42 @@ +// Package metrics keeps process-wide counters for the service's own reports. +package metrics + +import "sync" + +// Registry holds named counters. It is safe for concurrent use. +type Registry struct { + mu sync.Mutex + counters map[string]int64 +} + +// New returns an empty registry. +func New() *Registry { return &Registry{counters: map[string]int64{}} } + +// Inc adds one to the counter named key. +func (r *Registry) Inc(key string) { r.Add(key, 1) } + +// Add adds n to the counter named key. +func (r *Registry) Add(key string, n int64) { + r.mu.Lock() + defer r.mu.Unlock() + r.counters[key] += n +} + +// Get reports the counter's value, zero when it was never incremented. +func (r *Registry) Get(key string) int64 { + r.mu.Lock() + defer r.mu.Unlock() + return r.counters[key] +} + +// Snapshot copies the counters, which is what a report should read: the live +// map must not change under a writer. +func (r *Registry) Snapshot() map[string]int64 { + r.mu.Lock() + defer r.mu.Unlock() + out := make(map[string]int64, len(r.counters)) + for k, v := range r.counters { + out[k] = v + } + return out +} diff --git a/eval/spike-repo-index/tasks/t2-report/internal/queue/queue.go b/eval/spike-repo-index/tasks/t2-report/internal/queue/queue.go new file mode 100644 index 0000000..831ef0d --- /dev/null +++ b/eval/spike-repo-index/tasks/t2-report/internal/queue/queue.go @@ -0,0 +1,37 @@ +// Package queue is a fixed-capacity ring buffer of strings. +package queue + +// Ring holds at most cap items; the oldest is overwritten when full. +type Ring struct { + items []string + head int + size int +} + +// New returns a ring of the given capacity. +func New(capacity int) *Ring { return &Ring{items: make([]string, capacity)} } + +// Push appends one item, overwriting the oldest when the ring is full. +func (r *Ring) Push(item string) { + if len(r.items) == 0 { + return + } + r.items[r.head] = item + r.head = (r.head + 1) % len(r.items) + if r.size < len(r.items) { + r.size++ + } +} + +// Len reports how many items the ring holds. +func (r *Ring) Len() int { return r.size } + +// Slice returns the items oldest first. +func (r *Ring) Slice() []string { + out := make([]string, 0, r.size) + start := (r.head - r.size + len(r.items)) % len(r.items) + for i := 0; i < r.size; i++ { + out = append(out, r.items[(start+i)%len(r.items)]) + } + return out +} diff --git a/eval/spike-repo-index/tasks/t2-report/internal/retry/retry.go b/eval/spike-repo-index/tasks/t2-report/internal/retry/retry.go new file mode 100644 index 0000000..cb7fdba --- /dev/null +++ b/eval/spike-repo-index/tasks/t2-report/internal/retry/retry.go @@ -0,0 +1,29 @@ +// Package retry computes the wait between attempts. +package retry + +import ( + "math" + "time" +) + +// Policy is a capped exponential backoff. +type Policy struct { + Base time.Duration + Max time.Duration + Tries int +} + +// Wait returns how long to sleep before attempt n (1-based). +func (p Policy) Wait(n int) time.Duration { + if n <= 1 { + return 0 + } + wait := time.Duration(float64(p.Base) * math.Pow(2, float64(n-2))) + if wait > p.Max || wait <= 0 { + return p.Max + } + return wait +} + +// Exhausted reports whether n attempts have been spent. +func (p Policy) Exhausted(n int) bool { return p.Tries > 0 && n >= p.Tries } diff --git a/eval/spike-repo-index/tasks/t2-report/internal/textutil/textutil.go b/eval/spike-repo-index/tasks/t2-report/internal/textutil/textutil.go new file mode 100644 index 0000000..c0a5ec2 --- /dev/null +++ b/eval/spike-repo-index/tasks/t2-report/internal/textutil/textutil.go @@ -0,0 +1,30 @@ +// Package textutil holds small string helpers shared by reports. +package textutil + +import "strings" + +// Truncate shortens s to at most n runes, appending an ellipsis when it cut. +func Truncate(s string, n int) string { + runes := []rune(s) + if len(runes) <= n { + return s + } + return string(runes[:n]) + "…" +} + +// Title uppercases the first letter and leaves the rest alone. +func Title(s string) string { + if s == "" { + return s + } + return strings.ToUpper(s[:1]) + s[1:] +} + +// SplitLines splits on both CRLF and LF and drops a trailing empty line. +func SplitLines(s string) []string { + lines := strings.Split(strings.ReplaceAll(s, "\r\n", "\n"), "\n") + if n := len(lines); n > 0 && lines[n-1] == "" { + return lines[:n-1] + } + return lines +} diff --git a/eval/spike-repo-index/tasks/t2-report/internal/validate/validate.go b/eval/spike-repo-index/tasks/t2-report/internal/validate/validate.go new file mode 100644 index 0000000..75dc1a9 --- /dev/null +++ b/eval/spike-repo-index/tasks/t2-report/internal/validate/validate.go @@ -0,0 +1,31 @@ +// Package validate checks the shape of values before they reach the store. +package validate + +import ( + "errors" + "strings" + "unicode/utf8" +) + +// Errors a caller can tell apart. +var ( + ErrEmpty = errors.New("validate: empty value") + ErrTooLong = errors.New("validate: value too long") +) + +// Limit is the longest value the service accepts. +const Limit = 512 + +// Value rejects empty, over-long and non-UTF8 strings. +func Value(s string) error { + if strings.TrimSpace(s) == "" { + return ErrEmpty + } + if !utf8.ValidString(s) { + return ErrEmpty + } + if utf8.RuneCountInString(s) > Limit { + return ErrTooLong + } + return nil +} diff --git a/eval/spike-repo-index/tasks/t2-report/internal/version/version.go b/eval/spike-repo-index/tasks/t2-report/internal/version/version.go new file mode 100644 index 0000000..e1cf850 --- /dev/null +++ b/eval/spike-repo-index/tasks/t2-report/internal/version/version.go @@ -0,0 +1,46 @@ +// Package version parses and compares dotted version strings. +package version + +import ( + "strconv" + "strings" +) + +// Parse reads a dotted version into its numeric parts, ignoring a pre-release +// suffix: "1.2.3-rc1" is 1,2,3. +func Parse(s string) ([]int, bool) { + if i := strings.IndexAny(s, "-+"); i >= 0 { + s = s[:i] + } + parts := strings.Split(s, ".") + out := make([]int, 0, len(parts)) + for _, part := range parts { + n, err := strconv.Atoi(part) + if err != nil || n < 0 { + return nil, false + } + out = append(out, n) + } + return out, len(out) > 0 +} + +// Less reports whether a sorts before b, padding the shorter with zeros. +func Less(a, b []int) bool { + n := len(a) + if len(b) > n { + n = len(b) + } + for i := 0; i < n; i++ { + x, y := 0, 0 + if i < len(a) { + x = a[i] + } + if i < len(b) { + y = b[i] + } + if x != y { + return x < y + } + } + return false +} diff --git a/eval/spike-repo-index/tasks/t2-report/model.go b/eval/spike-repo-index/tasks/t2-report/model.go new file mode 100644 index 0000000..959c96d --- /dev/null +++ b/eval/spike-repo-index/tasks/t2-report/model.go @@ -0,0 +1,19 @@ +package task + +// Row is one student's scores. +type Row struct { + Name string + Scores []int +} + +// Average is the mean of the scores, or 0 when there are none. +func (r Row) Average() float64 { + total := 0 + for _, score := range r.Scores { + total += score + } + if len(r.Scores) == 0 { + return 0 + } + return float64(total / len(r.Scores)) +} diff --git a/eval/spike-repo-index/tasks/t2-report/report.go b/eval/spike-repo-index/tasks/t2-report/report.go new file mode 100644 index 0000000..bec454c --- /dev/null +++ b/eval/spike-repo-index/tasks/t2-report/report.go @@ -0,0 +1,16 @@ +package task + +import ( + "fmt" + "strings" +) + +// Report renders one ": " line per row, ordered by name, with the +// average written to one decimal place. An empty list renders an empty string. +func Report(rows []Row) string { + var out strings.Builder + for _, row := range rows { + fmt.Fprintf(&out, "%s: %.0f\n", row.Name, row.Average()) + } + return out.String() +} diff --git a/eval/spike-repo-index/tasks/t3-boundary/PROMPT b/eval/spike-repo-index/tasks/t3-boundary/PROMPT new file mode 100644 index 0000000..2a23e9b --- /dev/null +++ b/eval/spike-repo-index/tasks/t3-boundary/PROMPT @@ -0,0 +1,16 @@ +This module records which ticks a route was asked at, and splits them into on-peak and off-peak. It has +no tests: a hidden suite checks the behaviour below. + +Keep these names and signatures: +- NewUsage(ticks ...int) *Usage +- (*Usage).OffPeak(from, to int) int +- (*Usage).Peak(from, to int) int +- InWindow(tick, from, to int) bool + +Behaviour: +- Usage holds tick numbers. +- OffPeak(from, to) counts the ticks in the half-open window [from, to): a tick equal to from is inside, + a tick equal to to is outside. A window with from >= to counts zero. +- Peak(from, to) counts every tick that is not in [from, to). + +The behaviour is wrong in more than one file. Fix the code. diff --git a/eval/spike-repo-index/tasks/t3-boundary/TIER b/eval/spike-repo-index/tasks/t3-boundary/TIER new file mode 100644 index 0000000..00750ed --- /dev/null +++ b/eval/spike-repo-index/tasks/t3-boundary/TIER @@ -0,0 +1 @@ +3 diff --git a/eval/spike-repo-index/tasks/t3-boundary/go.mod b/eval/spike-repo-index/tasks/t3-boundary/go.mod new file mode 100644 index 0000000..7f83371 --- /dev/null +++ b/eval/spike-repo-index/tasks/t3-boundary/go.mod @@ -0,0 +1,3 @@ +module task.local/usage + +go 1.27 diff --git a/eval/spike-repo-index/tasks/t3-boundary/internal/clock/clock.go b/eval/spike-repo-index/tasks/t3-boundary/internal/clock/clock.go new file mode 100644 index 0000000..b64c1c2 --- /dev/null +++ b/eval/spike-repo-index/tasks/t3-boundary/internal/clock/clock.go @@ -0,0 +1,30 @@ +// Package clock is the indirection that lets a test decide what time it is. +package clock + +import "time" + +// Clock reports the current time. +type Clock interface { + Now() time.Time +} + +// Real reads the wall clock. +type Real struct{} + +// Now returns time.Now. +func (Real) Now() time.Time { return time.Now() } + +// Fake returns a fixed time until Set moves it. +type Fake struct{ at time.Time } + +// NewFake starts a fake clock at t. +func NewFake(t time.Time) *Fake { return &Fake{at: t} } + +// Now returns the fake's current time. +func (f *Fake) Now() time.Time { return f.at } + +// Set moves the fake clock. +func (f *Fake) Set(t time.Time) { f.at = t } + +// Advance moves the fake clock forward by d. +func (f *Fake) Advance(d time.Duration) { f.at = f.at.Add(d) } diff --git a/eval/spike-repo-index/tasks/t3-boundary/internal/configs/configs.go b/eval/spike-repo-index/tasks/t3-boundary/internal/configs/configs.go new file mode 100644 index 0000000..ef982db --- /dev/null +++ b/eval/spike-repo-index/tasks/t3-boundary/internal/configs/configs.go @@ -0,0 +1,42 @@ +// Package configs reads key=value settings from a list of strings. +package configs + +import ( + "errors" + "strconv" + "strings" +) + +// ErrMalformed reports a line that is not key=value. +var ErrMalformed = errors.New("configs: expected key=value") + +// Parse reads key=value lines into a map. Blank lines and lines starting with # +// are ignored. +func Parse(lines []string) (map[string]string, error) { + out := map[string]string{} + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + key, value, ok := strings.Cut(line, "=") + if !ok || strings.TrimSpace(key) == "" { + return nil, ErrMalformed + } + out[strings.TrimSpace(key)] = strings.TrimSpace(value) + } + return out, nil +} + +// Int reads an integer setting, falling back when it is absent. +func Int(settings map[string]string, key string, fallback int) int { + raw, ok := settings[key] + if !ok { + return fallback + } + n, err := strconv.Atoi(raw) + if err != nil { + return fallback + } + return n +} diff --git a/eval/spike-repo-index/tasks/t3-boundary/internal/idgen/idgen.go b/eval/spike-repo-index/tasks/t3-boundary/internal/idgen/idgen.go new file mode 100644 index 0000000..0855dab --- /dev/null +++ b/eval/spike-repo-index/tasks/t3-boundary/internal/idgen/idgen.go @@ -0,0 +1,19 @@ +// Package idgen makes the short opaque ids a report or a URL can carry. +package idgen + +import ( + "crypto/rand" + "encoding/base32" + "strings" +) + +// New returns a 16-character id drawn from the operating system's entropy. +func New() string { + buf := make([]byte, 10) + if _, err := rand.Read(buf); err != nil { + // The caller has no better answer than a fixed string here, and a + // panic in an id generator would take the process down for cosmetics. + return "0000000000000000" + } + return strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(buf)) +} diff --git a/eval/spike-repo-index/tasks/t3-boundary/internal/logging/logging.go b/eval/spike-repo-index/tasks/t3-boundary/internal/logging/logging.go new file mode 100644 index 0000000..d279072 --- /dev/null +++ b/eval/spike-repo-index/tasks/t3-boundary/internal/logging/logging.go @@ -0,0 +1,46 @@ +// Package logging is the service's tiny leveled logger. +package logging + +import ( + "fmt" + "io" + "strings" +) + +// Level is how much a logger writes. +type Level int + +// Levels, from quietest to loudest. +const ( + LevelError Level = iota + LevelWarn + LevelInfo + LevelDebug +) + +// Logger writes one line per record, prefixed with its level. +type Logger struct { + Out io.Writer + Level Level +} + +// Printf writes one record when level is at or below the logger's own. +func (l *Logger) Printf(level Level, format string, args ...any) { + if l.Out == nil || level > l.Level { + return + } + fmt.Fprintf(l.Out, "%s: %s\n", strings.ToUpper(level.String()), fmt.Sprintf(format, args...)) +} + +// String names the level. +func (l Level) String() string { + switch l { + case LevelError: + return "error" + case LevelWarn: + return "warn" + case LevelInfo: + return "info" + } + return "debug" +} diff --git a/eval/spike-repo-index/tasks/t3-boundary/internal/metrics/metrics.go b/eval/spike-repo-index/tasks/t3-boundary/internal/metrics/metrics.go new file mode 100644 index 0000000..fc4b1a5 --- /dev/null +++ b/eval/spike-repo-index/tasks/t3-boundary/internal/metrics/metrics.go @@ -0,0 +1,42 @@ +// Package metrics keeps process-wide counters for the service's own reports. +package metrics + +import "sync" + +// Registry holds named counters. It is safe for concurrent use. +type Registry struct { + mu sync.Mutex + counters map[string]int64 +} + +// New returns an empty registry. +func New() *Registry { return &Registry{counters: map[string]int64{}} } + +// Inc adds one to the counter named key. +func (r *Registry) Inc(key string) { r.Add(key, 1) } + +// Add adds n to the counter named key. +func (r *Registry) Add(key string, n int64) { + r.mu.Lock() + defer r.mu.Unlock() + r.counters[key] += n +} + +// Get reports the counter's value, zero when it was never incremented. +func (r *Registry) Get(key string) int64 { + r.mu.Lock() + defer r.mu.Unlock() + return r.counters[key] +} + +// Snapshot copies the counters, which is what a report should read: the live +// map must not change under a writer. +func (r *Registry) Snapshot() map[string]int64 { + r.mu.Lock() + defer r.mu.Unlock() + out := make(map[string]int64, len(r.counters)) + for k, v := range r.counters { + out[k] = v + } + return out +} diff --git a/eval/spike-repo-index/tasks/t3-boundary/internal/queue/queue.go b/eval/spike-repo-index/tasks/t3-boundary/internal/queue/queue.go new file mode 100644 index 0000000..831ef0d --- /dev/null +++ b/eval/spike-repo-index/tasks/t3-boundary/internal/queue/queue.go @@ -0,0 +1,37 @@ +// Package queue is a fixed-capacity ring buffer of strings. +package queue + +// Ring holds at most cap items; the oldest is overwritten when full. +type Ring struct { + items []string + head int + size int +} + +// New returns a ring of the given capacity. +func New(capacity int) *Ring { return &Ring{items: make([]string, capacity)} } + +// Push appends one item, overwriting the oldest when the ring is full. +func (r *Ring) Push(item string) { + if len(r.items) == 0 { + return + } + r.items[r.head] = item + r.head = (r.head + 1) % len(r.items) + if r.size < len(r.items) { + r.size++ + } +} + +// Len reports how many items the ring holds. +func (r *Ring) Len() int { return r.size } + +// Slice returns the items oldest first. +func (r *Ring) Slice() []string { + out := make([]string, 0, r.size) + start := (r.head - r.size + len(r.items)) % len(r.items) + for i := 0; i < r.size; i++ { + out = append(out, r.items[(start+i)%len(r.items)]) + } + return out +} diff --git a/eval/spike-repo-index/tasks/t3-boundary/internal/retry/retry.go b/eval/spike-repo-index/tasks/t3-boundary/internal/retry/retry.go new file mode 100644 index 0000000..cb7fdba --- /dev/null +++ b/eval/spike-repo-index/tasks/t3-boundary/internal/retry/retry.go @@ -0,0 +1,29 @@ +// Package retry computes the wait between attempts. +package retry + +import ( + "math" + "time" +) + +// Policy is a capped exponential backoff. +type Policy struct { + Base time.Duration + Max time.Duration + Tries int +} + +// Wait returns how long to sleep before attempt n (1-based). +func (p Policy) Wait(n int) time.Duration { + if n <= 1 { + return 0 + } + wait := time.Duration(float64(p.Base) * math.Pow(2, float64(n-2))) + if wait > p.Max || wait <= 0 { + return p.Max + } + return wait +} + +// Exhausted reports whether n attempts have been spent. +func (p Policy) Exhausted(n int) bool { return p.Tries > 0 && n >= p.Tries } diff --git a/eval/spike-repo-index/tasks/t3-boundary/internal/textutil/textutil.go b/eval/spike-repo-index/tasks/t3-boundary/internal/textutil/textutil.go new file mode 100644 index 0000000..c0a5ec2 --- /dev/null +++ b/eval/spike-repo-index/tasks/t3-boundary/internal/textutil/textutil.go @@ -0,0 +1,30 @@ +// Package textutil holds small string helpers shared by reports. +package textutil + +import "strings" + +// Truncate shortens s to at most n runes, appending an ellipsis when it cut. +func Truncate(s string, n int) string { + runes := []rune(s) + if len(runes) <= n { + return s + } + return string(runes[:n]) + "…" +} + +// Title uppercases the first letter and leaves the rest alone. +func Title(s string) string { + if s == "" { + return s + } + return strings.ToUpper(s[:1]) + s[1:] +} + +// SplitLines splits on both CRLF and LF and drops a trailing empty line. +func SplitLines(s string) []string { + lines := strings.Split(strings.ReplaceAll(s, "\r\n", "\n"), "\n") + if n := len(lines); n > 0 && lines[n-1] == "" { + return lines[:n-1] + } + return lines +} diff --git a/eval/spike-repo-index/tasks/t3-boundary/internal/validate/validate.go b/eval/spike-repo-index/tasks/t3-boundary/internal/validate/validate.go new file mode 100644 index 0000000..75dc1a9 --- /dev/null +++ b/eval/spike-repo-index/tasks/t3-boundary/internal/validate/validate.go @@ -0,0 +1,31 @@ +// Package validate checks the shape of values before they reach the store. +package validate + +import ( + "errors" + "strings" + "unicode/utf8" +) + +// Errors a caller can tell apart. +var ( + ErrEmpty = errors.New("validate: empty value") + ErrTooLong = errors.New("validate: value too long") +) + +// Limit is the longest value the service accepts. +const Limit = 512 + +// Value rejects empty, over-long and non-UTF8 strings. +func Value(s string) error { + if strings.TrimSpace(s) == "" { + return ErrEmpty + } + if !utf8.ValidString(s) { + return ErrEmpty + } + if utf8.RuneCountInString(s) > Limit { + return ErrTooLong + } + return nil +} diff --git a/eval/spike-repo-index/tasks/t3-boundary/internal/version/version.go b/eval/spike-repo-index/tasks/t3-boundary/internal/version/version.go new file mode 100644 index 0000000..e1cf850 --- /dev/null +++ b/eval/spike-repo-index/tasks/t3-boundary/internal/version/version.go @@ -0,0 +1,46 @@ +// Package version parses and compares dotted version strings. +package version + +import ( + "strconv" + "strings" +) + +// Parse reads a dotted version into its numeric parts, ignoring a pre-release +// suffix: "1.2.3-rc1" is 1,2,3. +func Parse(s string) ([]int, bool) { + if i := strings.IndexAny(s, "-+"); i >= 0 { + s = s[:i] + } + parts := strings.Split(s, ".") + out := make([]int, 0, len(parts)) + for _, part := range parts { + n, err := strconv.Atoi(part) + if err != nil || n < 0 { + return nil, false + } + out = append(out, n) + } + return out, len(out) > 0 +} + +// Less reports whether a sorts before b, padding the shorter with zeros. +func Less(a, b []int) bool { + n := len(a) + if len(b) > n { + n = len(b) + } + for i := 0; i < n; i++ { + x, y := 0, 0 + if i < len(a) { + x = a[i] + } + if i < len(b) { + y = b[i] + } + if x != y { + return x < y + } + } + return false +} diff --git a/eval/spike-repo-index/tasks/t3-boundary/rate.go b/eval/spike-repo-index/tasks/t3-boundary/rate.go new file mode 100644 index 0000000..5dc10da --- /dev/null +++ b/eval/spike-repo-index/tasks/t3-boundary/rate.go @@ -0,0 +1,23 @@ +package task + +// Usage holds the ticks a route was asked during. +type Usage struct { + ticks []int +} + +// NewUsage returns a usage record over the given ticks. +func NewUsage(ticks ...int) *Usage { return &Usage{ticks: ticks} } + +// OffPeak counts the ticks inside [from, to). +func (u *Usage) OffPeak(from, to int) int { + count := 0 + for _, tick := range u.ticks { + if InWindow(tick, from, to) { + count++ + } + } + return count +} + +// Peak counts the ticks outside [from, to). +func (u *Usage) Peak(from, to int) int { return len(u.ticks) - u.OffPeak(from, to) } diff --git a/eval/spike-repo-index/tasks/t3-boundary/window.go b/eval/spike-repo-index/tasks/t3-boundary/window.go new file mode 100644 index 0000000..2b89b6c --- /dev/null +++ b/eval/spike-repo-index/tasks/t3-boundary/window.go @@ -0,0 +1,6 @@ +package task + +// InWindow reports whether tick falls inside [from, to). +func InWindow(tick, from, to int) bool { + return tick >= from && tick <= to +} diff --git a/eval/spike-repo-index/tasks/t3-cache/PROMPT b/eval/spike-repo-index/tasks/t3-cache/PROMPT new file mode 100644 index 0000000..b7719a0 --- /dev/null +++ b/eval/spike-repo-index/tasks/t3-cache/PROMPT @@ -0,0 +1,17 @@ +This module is a tick-driven cache. It has no tests: a hidden suite checks the behaviour below. + +Keep these names and signatures: +- type Clock with a Tick() method, as the module already defines it +- NewCache(clock *Clock, capacity, ttl int) *Cache +- (*Cache).Put(key string, value int), (*Cache).Get(key string) int + +Behaviour: +- Get returns the stored value, and 0 for an absent or expired key (0 is never a stored value's + sentinel problem here: values are compared as the suite writes them). +- A value expires exactly ttl ticks after it was written: after ttl-1 ticks it is still there, at ttl + ticks it is gone. +- Writing the same key again refreshes its deadline and its value. +- capacity 0 stores nothing. +- ttl 0 never expires. + +The behaviour is wrong in more than one file. Fix the code. diff --git a/eval/spike-repo-index/tasks/t3-cache/TIER b/eval/spike-repo-index/tasks/t3-cache/TIER new file mode 100644 index 0000000..00750ed --- /dev/null +++ b/eval/spike-repo-index/tasks/t3-cache/TIER @@ -0,0 +1 @@ +3 diff --git a/eval/spike-repo-index/tasks/t3-cache/cache.go b/eval/spike-repo-index/tasks/t3-cache/cache.go new file mode 100644 index 0000000..b34f8ac --- /dev/null +++ b/eval/spike-repo-index/tasks/t3-cache/cache.go @@ -0,0 +1,34 @@ +package task + +// Cache is a key-value store whose entries expire after a number of ticks. +type Cache struct { + clock *Clock + ttl int + items map[string]entry +} + +type entry struct { + value int + readAt int +} + +// NewCache returns a cache whose entries live for ttl ticks after they were last +// written. A ttl of zero means entries never expire; a capacity of zero means +// nothing is ever stored. +func NewCache(clock *Clock, capacity, ttl int) *Cache { + return &Cache{clock: clock, ttl: ttl, items: make(map[string]entry)} +} + +// Put stores a value under a key. +func (c *Cache) Put(key string, value int) { + c.items[key] = entry{value: value, readAt: c.clock.Now()} +} + +// Get returns the stored value, or 0 when the key is absent or expired. +func (c *Cache) Get(key string) int { + item, ok := c.items[key] + if !ok { + return 0 + } + return item.value +} diff --git a/eval/spike-repo-index/tasks/t3-cache/clock.go b/eval/spike-repo-index/tasks/t3-cache/clock.go new file mode 100644 index 0000000..b6ada7d --- /dev/null +++ b/eval/spike-repo-index/tasks/t3-cache/clock.go @@ -0,0 +1,15 @@ +package task + +// Clock counts ticks. Every call to Tick advances it by one. +type Clock struct { + now int +} + +// Tick advances the clock and returns the new instant. +func (c *Clock) Tick() int { + c.now++ + return c.now +} + +// Now reports the current instant. +func (c *Clock) Now() int { return c.now } diff --git a/eval/spike-repo-index/tasks/t3-cache/go.mod b/eval/spike-repo-index/tasks/t3-cache/go.mod new file mode 100644 index 0000000..5ea682a --- /dev/null +++ b/eval/spike-repo-index/tasks/t3-cache/go.mod @@ -0,0 +1,3 @@ +module task.local/cache + +go 1.27 diff --git a/eval/spike-repo-index/tasks/t3-cache/internal/clock/clock.go b/eval/spike-repo-index/tasks/t3-cache/internal/clock/clock.go new file mode 100644 index 0000000..b64c1c2 --- /dev/null +++ b/eval/spike-repo-index/tasks/t3-cache/internal/clock/clock.go @@ -0,0 +1,30 @@ +// Package clock is the indirection that lets a test decide what time it is. +package clock + +import "time" + +// Clock reports the current time. +type Clock interface { + Now() time.Time +} + +// Real reads the wall clock. +type Real struct{} + +// Now returns time.Now. +func (Real) Now() time.Time { return time.Now() } + +// Fake returns a fixed time until Set moves it. +type Fake struct{ at time.Time } + +// NewFake starts a fake clock at t. +func NewFake(t time.Time) *Fake { return &Fake{at: t} } + +// Now returns the fake's current time. +func (f *Fake) Now() time.Time { return f.at } + +// Set moves the fake clock. +func (f *Fake) Set(t time.Time) { f.at = t } + +// Advance moves the fake clock forward by d. +func (f *Fake) Advance(d time.Duration) { f.at = f.at.Add(d) } diff --git a/eval/spike-repo-index/tasks/t3-cache/internal/configs/configs.go b/eval/spike-repo-index/tasks/t3-cache/internal/configs/configs.go new file mode 100644 index 0000000..ef982db --- /dev/null +++ b/eval/spike-repo-index/tasks/t3-cache/internal/configs/configs.go @@ -0,0 +1,42 @@ +// Package configs reads key=value settings from a list of strings. +package configs + +import ( + "errors" + "strconv" + "strings" +) + +// ErrMalformed reports a line that is not key=value. +var ErrMalformed = errors.New("configs: expected key=value") + +// Parse reads key=value lines into a map. Blank lines and lines starting with # +// are ignored. +func Parse(lines []string) (map[string]string, error) { + out := map[string]string{} + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + key, value, ok := strings.Cut(line, "=") + if !ok || strings.TrimSpace(key) == "" { + return nil, ErrMalformed + } + out[strings.TrimSpace(key)] = strings.TrimSpace(value) + } + return out, nil +} + +// Int reads an integer setting, falling back when it is absent. +func Int(settings map[string]string, key string, fallback int) int { + raw, ok := settings[key] + if !ok { + return fallback + } + n, err := strconv.Atoi(raw) + if err != nil { + return fallback + } + return n +} diff --git a/eval/spike-repo-index/tasks/t3-cache/internal/idgen/idgen.go b/eval/spike-repo-index/tasks/t3-cache/internal/idgen/idgen.go new file mode 100644 index 0000000..0855dab --- /dev/null +++ b/eval/spike-repo-index/tasks/t3-cache/internal/idgen/idgen.go @@ -0,0 +1,19 @@ +// Package idgen makes the short opaque ids a report or a URL can carry. +package idgen + +import ( + "crypto/rand" + "encoding/base32" + "strings" +) + +// New returns a 16-character id drawn from the operating system's entropy. +func New() string { + buf := make([]byte, 10) + if _, err := rand.Read(buf); err != nil { + // The caller has no better answer than a fixed string here, and a + // panic in an id generator would take the process down for cosmetics. + return "0000000000000000" + } + return strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(buf)) +} diff --git a/eval/spike-repo-index/tasks/t3-cache/internal/logging/logging.go b/eval/spike-repo-index/tasks/t3-cache/internal/logging/logging.go new file mode 100644 index 0000000..d279072 --- /dev/null +++ b/eval/spike-repo-index/tasks/t3-cache/internal/logging/logging.go @@ -0,0 +1,46 @@ +// Package logging is the service's tiny leveled logger. +package logging + +import ( + "fmt" + "io" + "strings" +) + +// Level is how much a logger writes. +type Level int + +// Levels, from quietest to loudest. +const ( + LevelError Level = iota + LevelWarn + LevelInfo + LevelDebug +) + +// Logger writes one line per record, prefixed with its level. +type Logger struct { + Out io.Writer + Level Level +} + +// Printf writes one record when level is at or below the logger's own. +func (l *Logger) Printf(level Level, format string, args ...any) { + if l.Out == nil || level > l.Level { + return + } + fmt.Fprintf(l.Out, "%s: %s\n", strings.ToUpper(level.String()), fmt.Sprintf(format, args...)) +} + +// String names the level. +func (l Level) String() string { + switch l { + case LevelError: + return "error" + case LevelWarn: + return "warn" + case LevelInfo: + return "info" + } + return "debug" +} diff --git a/eval/spike-repo-index/tasks/t3-cache/internal/metrics/metrics.go b/eval/spike-repo-index/tasks/t3-cache/internal/metrics/metrics.go new file mode 100644 index 0000000..fc4b1a5 --- /dev/null +++ b/eval/spike-repo-index/tasks/t3-cache/internal/metrics/metrics.go @@ -0,0 +1,42 @@ +// Package metrics keeps process-wide counters for the service's own reports. +package metrics + +import "sync" + +// Registry holds named counters. It is safe for concurrent use. +type Registry struct { + mu sync.Mutex + counters map[string]int64 +} + +// New returns an empty registry. +func New() *Registry { return &Registry{counters: map[string]int64{}} } + +// Inc adds one to the counter named key. +func (r *Registry) Inc(key string) { r.Add(key, 1) } + +// Add adds n to the counter named key. +func (r *Registry) Add(key string, n int64) { + r.mu.Lock() + defer r.mu.Unlock() + r.counters[key] += n +} + +// Get reports the counter's value, zero when it was never incremented. +func (r *Registry) Get(key string) int64 { + r.mu.Lock() + defer r.mu.Unlock() + return r.counters[key] +} + +// Snapshot copies the counters, which is what a report should read: the live +// map must not change under a writer. +func (r *Registry) Snapshot() map[string]int64 { + r.mu.Lock() + defer r.mu.Unlock() + out := make(map[string]int64, len(r.counters)) + for k, v := range r.counters { + out[k] = v + } + return out +} diff --git a/eval/spike-repo-index/tasks/t3-cache/internal/queue/queue.go b/eval/spike-repo-index/tasks/t3-cache/internal/queue/queue.go new file mode 100644 index 0000000..831ef0d --- /dev/null +++ b/eval/spike-repo-index/tasks/t3-cache/internal/queue/queue.go @@ -0,0 +1,37 @@ +// Package queue is a fixed-capacity ring buffer of strings. +package queue + +// Ring holds at most cap items; the oldest is overwritten when full. +type Ring struct { + items []string + head int + size int +} + +// New returns a ring of the given capacity. +func New(capacity int) *Ring { return &Ring{items: make([]string, capacity)} } + +// Push appends one item, overwriting the oldest when the ring is full. +func (r *Ring) Push(item string) { + if len(r.items) == 0 { + return + } + r.items[r.head] = item + r.head = (r.head + 1) % len(r.items) + if r.size < len(r.items) { + r.size++ + } +} + +// Len reports how many items the ring holds. +func (r *Ring) Len() int { return r.size } + +// Slice returns the items oldest first. +func (r *Ring) Slice() []string { + out := make([]string, 0, r.size) + start := (r.head - r.size + len(r.items)) % len(r.items) + for i := 0; i < r.size; i++ { + out = append(out, r.items[(start+i)%len(r.items)]) + } + return out +} diff --git a/eval/spike-repo-index/tasks/t3-cache/internal/retry/retry.go b/eval/spike-repo-index/tasks/t3-cache/internal/retry/retry.go new file mode 100644 index 0000000..cb7fdba --- /dev/null +++ b/eval/spike-repo-index/tasks/t3-cache/internal/retry/retry.go @@ -0,0 +1,29 @@ +// Package retry computes the wait between attempts. +package retry + +import ( + "math" + "time" +) + +// Policy is a capped exponential backoff. +type Policy struct { + Base time.Duration + Max time.Duration + Tries int +} + +// Wait returns how long to sleep before attempt n (1-based). +func (p Policy) Wait(n int) time.Duration { + if n <= 1 { + return 0 + } + wait := time.Duration(float64(p.Base) * math.Pow(2, float64(n-2))) + if wait > p.Max || wait <= 0 { + return p.Max + } + return wait +} + +// Exhausted reports whether n attempts have been spent. +func (p Policy) Exhausted(n int) bool { return p.Tries > 0 && n >= p.Tries } diff --git a/eval/spike-repo-index/tasks/t3-cache/internal/textutil/textutil.go b/eval/spike-repo-index/tasks/t3-cache/internal/textutil/textutil.go new file mode 100644 index 0000000..c0a5ec2 --- /dev/null +++ b/eval/spike-repo-index/tasks/t3-cache/internal/textutil/textutil.go @@ -0,0 +1,30 @@ +// Package textutil holds small string helpers shared by reports. +package textutil + +import "strings" + +// Truncate shortens s to at most n runes, appending an ellipsis when it cut. +func Truncate(s string, n int) string { + runes := []rune(s) + if len(runes) <= n { + return s + } + return string(runes[:n]) + "…" +} + +// Title uppercases the first letter and leaves the rest alone. +func Title(s string) string { + if s == "" { + return s + } + return strings.ToUpper(s[:1]) + s[1:] +} + +// SplitLines splits on both CRLF and LF and drops a trailing empty line. +func SplitLines(s string) []string { + lines := strings.Split(strings.ReplaceAll(s, "\r\n", "\n"), "\n") + if n := len(lines); n > 0 && lines[n-1] == "" { + return lines[:n-1] + } + return lines +} diff --git a/eval/spike-repo-index/tasks/t3-cache/internal/validate/validate.go b/eval/spike-repo-index/tasks/t3-cache/internal/validate/validate.go new file mode 100644 index 0000000..75dc1a9 --- /dev/null +++ b/eval/spike-repo-index/tasks/t3-cache/internal/validate/validate.go @@ -0,0 +1,31 @@ +// Package validate checks the shape of values before they reach the store. +package validate + +import ( + "errors" + "strings" + "unicode/utf8" +) + +// Errors a caller can tell apart. +var ( + ErrEmpty = errors.New("validate: empty value") + ErrTooLong = errors.New("validate: value too long") +) + +// Limit is the longest value the service accepts. +const Limit = 512 + +// Value rejects empty, over-long and non-UTF8 strings. +func Value(s string) error { + if strings.TrimSpace(s) == "" { + return ErrEmpty + } + if !utf8.ValidString(s) { + return ErrEmpty + } + if utf8.RuneCountInString(s) > Limit { + return ErrTooLong + } + return nil +} diff --git a/eval/spike-repo-index/tasks/t3-cache/internal/version/version.go b/eval/spike-repo-index/tasks/t3-cache/internal/version/version.go new file mode 100644 index 0000000..e1cf850 --- /dev/null +++ b/eval/spike-repo-index/tasks/t3-cache/internal/version/version.go @@ -0,0 +1,46 @@ +// Package version parses and compares dotted version strings. +package version + +import ( + "strconv" + "strings" +) + +// Parse reads a dotted version into its numeric parts, ignoring a pre-release +// suffix: "1.2.3-rc1" is 1,2,3. +func Parse(s string) ([]int, bool) { + if i := strings.IndexAny(s, "-+"); i >= 0 { + s = s[:i] + } + parts := strings.Split(s, ".") + out := make([]int, 0, len(parts)) + for _, part := range parts { + n, err := strconv.Atoi(part) + if err != nil || n < 0 { + return nil, false + } + out = append(out, n) + } + return out, len(out) > 0 +} + +// Less reports whether a sorts before b, padding the shorter with zeros. +func Less(a, b []int) bool { + n := len(a) + if len(b) > n { + n = len(b) + } + for i := 0; i < n; i++ { + x, y := 0, 0 + if i < len(a) { + x = a[i] + } + if i < len(b) { + y = b[i] + } + if x != y { + return x < y + } + } + return false +} diff --git a/eval/spike-repo-index/tasks/t3-extension/PROMPT b/eval/spike-repo-index/tasks/t3-extension/PROMPT new file mode 100644 index 0000000..4933246 --- /dev/null +++ b/eval/spike-repo-index/tasks/t3-extension/PROMPT @@ -0,0 +1,8 @@ +This module converts temperatures. It has no tests: a hidden suite checks the behaviour below. + +Keep these names and signatures: +- ToCelsius(f float64) int — the existing conversion, truncating toward zero. Its rounding must not + change: ToCelsius(32)==0, ToCelsius(212)==100, ToCelsius(98.6)==37, ToCelsius(0)==-17. +- ToFahrenheit(c int) float64 — missing: returns c*9/5 + 32 (so 0→32, 100→212, 37→98.6, -17→1.4). + +Add what is missing without changing the behaviour that already exists. diff --git a/eval/spike-repo-index/tasks/t3-extension/TIER b/eval/spike-repo-index/tasks/t3-extension/TIER new file mode 100644 index 0000000..00750ed --- /dev/null +++ b/eval/spike-repo-index/tasks/t3-extension/TIER @@ -0,0 +1 @@ +3 diff --git a/eval/spike-repo-index/tasks/t3-extension/convert.go b/eval/spike-repo-index/tasks/t3-extension/convert.go new file mode 100644 index 0000000..8700801 --- /dev/null +++ b/eval/spike-repo-index/tasks/t3-extension/convert.go @@ -0,0 +1,6 @@ +package task + +// ToCelsius converts a Fahrenheit temperature to Celsius, truncated towards zero. +func ToCelsius(f float64) int { + return int((f - 32) * 5 / 9) +} diff --git a/eval/spike-repo-index/tasks/t3-extension/go.mod b/eval/spike-repo-index/tasks/t3-extension/go.mod new file mode 100644 index 0000000..a0c9141 --- /dev/null +++ b/eval/spike-repo-index/tasks/t3-extension/go.mod @@ -0,0 +1,3 @@ +module task.local/convert + +go 1.27 diff --git a/eval/spike-repo-index/tasks/t3-extension/internal/clock/clock.go b/eval/spike-repo-index/tasks/t3-extension/internal/clock/clock.go new file mode 100644 index 0000000..b64c1c2 --- /dev/null +++ b/eval/spike-repo-index/tasks/t3-extension/internal/clock/clock.go @@ -0,0 +1,30 @@ +// Package clock is the indirection that lets a test decide what time it is. +package clock + +import "time" + +// Clock reports the current time. +type Clock interface { + Now() time.Time +} + +// Real reads the wall clock. +type Real struct{} + +// Now returns time.Now. +func (Real) Now() time.Time { return time.Now() } + +// Fake returns a fixed time until Set moves it. +type Fake struct{ at time.Time } + +// NewFake starts a fake clock at t. +func NewFake(t time.Time) *Fake { return &Fake{at: t} } + +// Now returns the fake's current time. +func (f *Fake) Now() time.Time { return f.at } + +// Set moves the fake clock. +func (f *Fake) Set(t time.Time) { f.at = t } + +// Advance moves the fake clock forward by d. +func (f *Fake) Advance(d time.Duration) { f.at = f.at.Add(d) } diff --git a/eval/spike-repo-index/tasks/t3-extension/internal/configs/configs.go b/eval/spike-repo-index/tasks/t3-extension/internal/configs/configs.go new file mode 100644 index 0000000..ef982db --- /dev/null +++ b/eval/spike-repo-index/tasks/t3-extension/internal/configs/configs.go @@ -0,0 +1,42 @@ +// Package configs reads key=value settings from a list of strings. +package configs + +import ( + "errors" + "strconv" + "strings" +) + +// ErrMalformed reports a line that is not key=value. +var ErrMalformed = errors.New("configs: expected key=value") + +// Parse reads key=value lines into a map. Blank lines and lines starting with # +// are ignored. +func Parse(lines []string) (map[string]string, error) { + out := map[string]string{} + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + key, value, ok := strings.Cut(line, "=") + if !ok || strings.TrimSpace(key) == "" { + return nil, ErrMalformed + } + out[strings.TrimSpace(key)] = strings.TrimSpace(value) + } + return out, nil +} + +// Int reads an integer setting, falling back when it is absent. +func Int(settings map[string]string, key string, fallback int) int { + raw, ok := settings[key] + if !ok { + return fallback + } + n, err := strconv.Atoi(raw) + if err != nil { + return fallback + } + return n +} diff --git a/eval/spike-repo-index/tasks/t3-extension/internal/idgen/idgen.go b/eval/spike-repo-index/tasks/t3-extension/internal/idgen/idgen.go new file mode 100644 index 0000000..0855dab --- /dev/null +++ b/eval/spike-repo-index/tasks/t3-extension/internal/idgen/idgen.go @@ -0,0 +1,19 @@ +// Package idgen makes the short opaque ids a report or a URL can carry. +package idgen + +import ( + "crypto/rand" + "encoding/base32" + "strings" +) + +// New returns a 16-character id drawn from the operating system's entropy. +func New() string { + buf := make([]byte, 10) + if _, err := rand.Read(buf); err != nil { + // The caller has no better answer than a fixed string here, and a + // panic in an id generator would take the process down for cosmetics. + return "0000000000000000" + } + return strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(buf)) +} diff --git a/eval/spike-repo-index/tasks/t3-extension/internal/logging/logging.go b/eval/spike-repo-index/tasks/t3-extension/internal/logging/logging.go new file mode 100644 index 0000000..d279072 --- /dev/null +++ b/eval/spike-repo-index/tasks/t3-extension/internal/logging/logging.go @@ -0,0 +1,46 @@ +// Package logging is the service's tiny leveled logger. +package logging + +import ( + "fmt" + "io" + "strings" +) + +// Level is how much a logger writes. +type Level int + +// Levels, from quietest to loudest. +const ( + LevelError Level = iota + LevelWarn + LevelInfo + LevelDebug +) + +// Logger writes one line per record, prefixed with its level. +type Logger struct { + Out io.Writer + Level Level +} + +// Printf writes one record when level is at or below the logger's own. +func (l *Logger) Printf(level Level, format string, args ...any) { + if l.Out == nil || level > l.Level { + return + } + fmt.Fprintf(l.Out, "%s: %s\n", strings.ToUpper(level.String()), fmt.Sprintf(format, args...)) +} + +// String names the level. +func (l Level) String() string { + switch l { + case LevelError: + return "error" + case LevelWarn: + return "warn" + case LevelInfo: + return "info" + } + return "debug" +} diff --git a/eval/spike-repo-index/tasks/t3-extension/internal/metrics/metrics.go b/eval/spike-repo-index/tasks/t3-extension/internal/metrics/metrics.go new file mode 100644 index 0000000..fc4b1a5 --- /dev/null +++ b/eval/spike-repo-index/tasks/t3-extension/internal/metrics/metrics.go @@ -0,0 +1,42 @@ +// Package metrics keeps process-wide counters for the service's own reports. +package metrics + +import "sync" + +// Registry holds named counters. It is safe for concurrent use. +type Registry struct { + mu sync.Mutex + counters map[string]int64 +} + +// New returns an empty registry. +func New() *Registry { return &Registry{counters: map[string]int64{}} } + +// Inc adds one to the counter named key. +func (r *Registry) Inc(key string) { r.Add(key, 1) } + +// Add adds n to the counter named key. +func (r *Registry) Add(key string, n int64) { + r.mu.Lock() + defer r.mu.Unlock() + r.counters[key] += n +} + +// Get reports the counter's value, zero when it was never incremented. +func (r *Registry) Get(key string) int64 { + r.mu.Lock() + defer r.mu.Unlock() + return r.counters[key] +} + +// Snapshot copies the counters, which is what a report should read: the live +// map must not change under a writer. +func (r *Registry) Snapshot() map[string]int64 { + r.mu.Lock() + defer r.mu.Unlock() + out := make(map[string]int64, len(r.counters)) + for k, v := range r.counters { + out[k] = v + } + return out +} diff --git a/eval/spike-repo-index/tasks/t3-extension/internal/queue/queue.go b/eval/spike-repo-index/tasks/t3-extension/internal/queue/queue.go new file mode 100644 index 0000000..831ef0d --- /dev/null +++ b/eval/spike-repo-index/tasks/t3-extension/internal/queue/queue.go @@ -0,0 +1,37 @@ +// Package queue is a fixed-capacity ring buffer of strings. +package queue + +// Ring holds at most cap items; the oldest is overwritten when full. +type Ring struct { + items []string + head int + size int +} + +// New returns a ring of the given capacity. +func New(capacity int) *Ring { return &Ring{items: make([]string, capacity)} } + +// Push appends one item, overwriting the oldest when the ring is full. +func (r *Ring) Push(item string) { + if len(r.items) == 0 { + return + } + r.items[r.head] = item + r.head = (r.head + 1) % len(r.items) + if r.size < len(r.items) { + r.size++ + } +} + +// Len reports how many items the ring holds. +func (r *Ring) Len() int { return r.size } + +// Slice returns the items oldest first. +func (r *Ring) Slice() []string { + out := make([]string, 0, r.size) + start := (r.head - r.size + len(r.items)) % len(r.items) + for i := 0; i < r.size; i++ { + out = append(out, r.items[(start+i)%len(r.items)]) + } + return out +} diff --git a/eval/spike-repo-index/tasks/t3-extension/internal/retry/retry.go b/eval/spike-repo-index/tasks/t3-extension/internal/retry/retry.go new file mode 100644 index 0000000..cb7fdba --- /dev/null +++ b/eval/spike-repo-index/tasks/t3-extension/internal/retry/retry.go @@ -0,0 +1,29 @@ +// Package retry computes the wait between attempts. +package retry + +import ( + "math" + "time" +) + +// Policy is a capped exponential backoff. +type Policy struct { + Base time.Duration + Max time.Duration + Tries int +} + +// Wait returns how long to sleep before attempt n (1-based). +func (p Policy) Wait(n int) time.Duration { + if n <= 1 { + return 0 + } + wait := time.Duration(float64(p.Base) * math.Pow(2, float64(n-2))) + if wait > p.Max || wait <= 0 { + return p.Max + } + return wait +} + +// Exhausted reports whether n attempts have been spent. +func (p Policy) Exhausted(n int) bool { return p.Tries > 0 && n >= p.Tries } diff --git a/eval/spike-repo-index/tasks/t3-extension/internal/textutil/textutil.go b/eval/spike-repo-index/tasks/t3-extension/internal/textutil/textutil.go new file mode 100644 index 0000000..c0a5ec2 --- /dev/null +++ b/eval/spike-repo-index/tasks/t3-extension/internal/textutil/textutil.go @@ -0,0 +1,30 @@ +// Package textutil holds small string helpers shared by reports. +package textutil + +import "strings" + +// Truncate shortens s to at most n runes, appending an ellipsis when it cut. +func Truncate(s string, n int) string { + runes := []rune(s) + if len(runes) <= n { + return s + } + return string(runes[:n]) + "…" +} + +// Title uppercases the first letter and leaves the rest alone. +func Title(s string) string { + if s == "" { + return s + } + return strings.ToUpper(s[:1]) + s[1:] +} + +// SplitLines splits on both CRLF and LF and drops a trailing empty line. +func SplitLines(s string) []string { + lines := strings.Split(strings.ReplaceAll(s, "\r\n", "\n"), "\n") + if n := len(lines); n > 0 && lines[n-1] == "" { + return lines[:n-1] + } + return lines +} diff --git a/eval/spike-repo-index/tasks/t3-extension/internal/validate/validate.go b/eval/spike-repo-index/tasks/t3-extension/internal/validate/validate.go new file mode 100644 index 0000000..75dc1a9 --- /dev/null +++ b/eval/spike-repo-index/tasks/t3-extension/internal/validate/validate.go @@ -0,0 +1,31 @@ +// Package validate checks the shape of values before they reach the store. +package validate + +import ( + "errors" + "strings" + "unicode/utf8" +) + +// Errors a caller can tell apart. +var ( + ErrEmpty = errors.New("validate: empty value") + ErrTooLong = errors.New("validate: value too long") +) + +// Limit is the longest value the service accepts. +const Limit = 512 + +// Value rejects empty, over-long and non-UTF8 strings. +func Value(s string) error { + if strings.TrimSpace(s) == "" { + return ErrEmpty + } + if !utf8.ValidString(s) { + return ErrEmpty + } + if utf8.RuneCountInString(s) > Limit { + return ErrTooLong + } + return nil +} diff --git a/eval/spike-repo-index/tasks/t3-extension/internal/version/version.go b/eval/spike-repo-index/tasks/t3-extension/internal/version/version.go new file mode 100644 index 0000000..e1cf850 --- /dev/null +++ b/eval/spike-repo-index/tasks/t3-extension/internal/version/version.go @@ -0,0 +1,46 @@ +// Package version parses and compares dotted version strings. +package version + +import ( + "strconv" + "strings" +) + +// Parse reads a dotted version into its numeric parts, ignoring a pre-release +// suffix: "1.2.3-rc1" is 1,2,3. +func Parse(s string) ([]int, bool) { + if i := strings.IndexAny(s, "-+"); i >= 0 { + s = s[:i] + } + parts := strings.Split(s, ".") + out := make([]int, 0, len(parts)) + for _, part := range parts { + n, err := strconv.Atoi(part) + if err != nil || n < 0 { + return nil, false + } + out = append(out, n) + } + return out, len(out) > 0 +} + +// Less reports whether a sorts before b, padding the shorter with zeros. +func Less(a, b []int) bool { + n := len(a) + if len(b) > n { + n = len(b) + } + for i := 0; i < n; i++ { + x, y := 0, 0 + if i < len(a) { + x = a[i] + } + if i < len(b) { + y = b[i] + } + if x != y { + return x < y + } + } + return false +}