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 +}