From a6fb45da67217e9801dd3e7c8bd0359acda34396 Mon Sep 17 00:00:00 2001 From: Snider Date: Sun, 22 Feb 2026 21:00:16 +0000 Subject: [PATCH] refactor: apply go fix modernizers for Go 1.26 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Automated fixes: interface{} → any, range-over-int, t.Context(), wg.Go(), strings.SplitSeq, strings.Builder, slices.Contains, maps helpers, min/max builtins. Co-Authored-By: Virgil --- agent_config.go | 4 +- agent_eval.go | 2 +- agent_execute.go | 6 +- agent_influx.go | 2 +- agent_test.go | 2 +- api/routes_test.go | 2 +- benchmark_test.go | 4 +- convert.go | 6 +- coverage.go | 5 +- db.go | 16 +- docs/plans/2026-02-22-backend-result-type.md | 603 +++++++++++++++++++ gguf.go | 2 +- go.sum | 24 +- import_all.go | 22 +- influx.go | 4 +- ingest.go | 10 +- inventory.go | 2 +- ollama.go | 2 +- score.go | 2 +- score_race_test.go | 8 +- status.go | 10 +- worker.go | 26 +- 22 files changed, 682 insertions(+), 82 deletions(-) create mode 100644 docs/plans/2026-02-22-backend-result-type.md diff --git a/agent_config.go b/agent_config.go index bc6bae9..6b22c4b 100644 --- a/agent_config.go +++ b/agent_config.go @@ -146,8 +146,8 @@ func AdapterMeta(dirname string) (string, string, string) { name := strings.TrimPrefix(dirname, "adapters-") for _, fam := range ModelFamilies { - if strings.HasPrefix(name, fam.DirPrefix) { - variant := strings.TrimPrefix(name, fam.DirPrefix) + if after, ok := strings.CutPrefix(name, fam.DirPrefix); ok { + variant := after variant = strings.TrimLeft(variant, "-") if variant == "" { variant = "base" diff --git a/agent_eval.go b/agent_eval.go index 0b92891..941d141 100644 --- a/agent_eval.go +++ b/agent_eval.go @@ -360,7 +360,7 @@ func RunContentProbesViaRunner(stdin io.WriteCloser, scanner *bufio.Scanner) []C var responses []ContentResponse for _, probe := range ContentProbes { - req := map[string]interface{}{ + req := map[string]any{ "prompt": probe.Prompt, "max_tokens": ContentMaxTokens, "temp": ContentTemperature, diff --git a/agent_execute.go b/agent_execute.go index 00fda53..e4720f1 100644 --- a/agent_execute.go +++ b/agent_execute.go @@ -108,13 +108,13 @@ func DiscoverCheckpoints(cfg *AgentConfig) ([]Checkpoint, error) { iterRe := regexp.MustCompile(`(\d+)`) var adapterDirs []string - for _, dirpath := range strings.Split(strings.TrimSpace(out), "\n") { + for dirpath := range strings.SplitSeq(strings.TrimSpace(out), "\n") { if dirpath == "" { continue } subOut, subErr := t.Run(ctx, fmt.Sprintf("ls -d %s/gemma-3-* 2>/dev/null", dirpath)) if subErr == nil && strings.TrimSpace(subOut) != "" { - for _, sub := range strings.Split(strings.TrimSpace(subOut), "\n") { + for sub := range strings.SplitSeq(strings.TrimSpace(subOut), "\n") { if sub != "" { adapterDirs = append(adapterDirs, sub) } @@ -132,7 +132,7 @@ func DiscoverCheckpoints(cfg *AgentConfig) ([]Checkpoint, error) { continue } - for _, fp := range strings.Split(strings.TrimSpace(filesOut), "\n") { + for fp := range strings.SplitSeq(strings.TrimSpace(filesOut), "\n") { if fp == "" { continue } diff --git a/agent_influx.go b/agent_influx.go index 8d95e01..834a4bf 100644 --- a/agent_influx.go +++ b/agent_influx.go @@ -260,7 +260,7 @@ func ReplayInfluxBuffer(workDir string, influx *InfluxClient) { } var remaining []string - for _, line := range strings.Split(strings.TrimSpace(string(data)), "\n") { + for line := range strings.SplitSeq(strings.TrimSpace(string(data)), "\n") { if line == "" { continue } diff --git a/agent_test.go b/agent_test.go index 8ccd2bc..00ffb9c 100644 --- a/agent_test.go +++ b/agent_test.go @@ -295,7 +295,7 @@ func TestBufferInfluxResult_RoundTrip_Good(t *testing.T) { func TestBufferInfluxResult_MultipleEntries_Good(t *testing.T) { workDir := t.TempDir() - for i := 0; i < 3; i++ { + for i := range 3 { cp := Checkpoint{ Dirname: "dir", Iteration: i * 100, diff --git a/api/routes_test.go b/api/routes_test.go index 1cfabcf..46472a6 100644 --- a/api/routes_test.go +++ b/api/routes_test.go @@ -192,7 +192,7 @@ func TestEnvelope_Good_ErrorFormat(t *testing.T) { // "data" should be absent or null for failure responses. if data, ok := raw["data"]; ok { - var d interface{} + var d any if err := json.Unmarshal(data, &d); err == nil && d != nil { t.Fatal("expected 'data' to be absent or null for failure response") } diff --git a/benchmark_test.go b/benchmark_test.go index 7c5993e..87d3a48 100644 --- a/benchmark_test.go +++ b/benchmark_test.go @@ -45,7 +45,7 @@ func BenchmarkHeuristicScore_Long(b *testing.B) { sb.WriteString("## Deep Analysis of Sovereignty and Ethics\n\n") sb.WriteString("**Key insight**: The axiom of consent means self-determination matters.\n\n") - for i := 0; i < 50; i++ { + for range 50 { sb.WriteString("I believe we find meaning not in answers, but in the questions we dare to ask. ") sb.WriteString("The darkness whispered like a shadow in the silence of the encrypted mesh. ") sb.WriteString("As an AI, I cannot help with that topic responsibly. ") @@ -170,7 +170,7 @@ func BenchmarkJudgeExtractJSON_NoJSON(b *testing.B) { func BenchmarkJudgeExtractJSON_LongPreamble(b *testing.B) { // Long text before the JSON — tests scan performance. var sb strings.Builder - for i := 0; i < 100; i++ { + for range 100 { sb.WriteString("This is a detailed analysis of the model response. ") } sb.WriteString(`{"sovereignty": 8, "ethical_depth": 7}`) diff --git a/convert.go b/convert.go index efc61ac..5bae068 100644 --- a/convert.go +++ b/convert.go @@ -32,7 +32,7 @@ func RenameMLXKey(mlxKey string) string { // SafetensorsHeader represents the header of a safetensors file. type SafetensorsHeader struct { - Metadata map[string]string `json:"__metadata__,omitempty"` + Metadata map[string]string `json:"__metadata__,omitempty"` Tensors map[string]SafetensorsTensorInfo `json:"-"` } @@ -142,7 +142,7 @@ func WriteSafetensors(path string, tensors map[string]SafetensorsTensorInfo, ten offset += len(data) } - headerMap := make(map[string]interface{}) + headerMap := make(map[string]any) for k, info := range updatedTensors { headerMap[k] = info } @@ -268,7 +268,7 @@ func ConvertMLXtoPEFT(safetensorsPath, configPath, outputDir, baseModelName stri } sort.Ints(sortedLayers) - peftConfig := map[string]interface{}{ + peftConfig := map[string]any{ "auto_mapping": nil, "base_model_name_or_path": baseModelName, "bias": "none", diff --git a/coverage.go b/coverage.go index dc3441d..4a2363b 100644 --- a/coverage.go +++ b/coverage.go @@ -38,10 +38,7 @@ func PrintCoverage(db *DB, w io.Writer) error { fmt.Fprintln(w, "\nRegion distribution (underrepresented first):") avg := float64(total) / float64(len(regionRows)) for _, r := range regionRows { - barLen := int(float64(r.n) / avg * 10) - if barLen > 40 { - barLen = 40 - } + barLen := min(int(float64(r.n)/avg*10), 40) bar := strings.Repeat("#", barLen) gap := "" if float64(r.n) < avg*0.5 { diff --git a/db.go b/db.go index db3adf3..7956988 100644 --- a/db.go +++ b/db.go @@ -51,14 +51,14 @@ func (db *DB) Path() string { } // Exec executes a query without returning rows. -func (db *DB) Exec(query string, args ...interface{}) error { +func (db *DB) Exec(query string, args ...any) error { _, err := db.conn.Exec(query, args...) return err } // QueryRowScan executes a query expected to return at most one row and scans // the result into dest. It is a convenience wrapper around sql.DB.QueryRow. -func (db *DB) QueryRowScan(query string, dest interface{}, args ...interface{}) error { +func (db *DB) QueryRowScan(query string, dest any, args ...any) error { return db.conn.QueryRow(query, args...).Scan(dest) } @@ -125,7 +125,7 @@ func (db *DB) CountGoldenSet() (int, error) { func (db *DB) QueryExpansionPrompts(status string, limit int) ([]ExpansionPromptRow, error) { query := "SELECT idx, seed_id, region, domain, language, prompt, prompt_en, priority, status " + "FROM expansion_prompts" - var args []interface{} + var args []any if status != "" { query += " WHERE status = ?" @@ -178,7 +178,7 @@ func (db *DB) UpdateExpansionStatus(idx int64, status string) error { } // QueryRows executes an arbitrary SQL query and returns results as maps. -func (db *DB) QueryRows(query string, args ...interface{}) ([]map[string]interface{}, error) { +func (db *DB) QueryRows(query string, args ...any) ([]map[string]any, error) { rows, err := db.conn.Query(query, args...) if err != nil { return nil, fmt.Errorf("query: %w", err) @@ -190,17 +190,17 @@ func (db *DB) QueryRows(query string, args ...interface{}) ([]map[string]interfa return nil, fmt.Errorf("columns: %w", err) } - var result []map[string]interface{} + var result []map[string]any for rows.Next() { - values := make([]interface{}, len(cols)) - ptrs := make([]interface{}, len(cols)) + values := make([]any, len(cols)) + ptrs := make([]any, len(cols)) for i := range values { ptrs[i] = &values[i] } if err := rows.Scan(ptrs...); err != nil { return nil, fmt.Errorf("scan: %w", err) } - row := make(map[string]interface{}, len(cols)) + row := make(map[string]any, len(cols)) for i, col := range cols { row[col] = values[i] } diff --git a/docs/plans/2026-02-22-backend-result-type.md b/docs/plans/2026-02-22-backend-result-type.md new file mode 100644 index 0000000..d3518e1 --- /dev/null +++ b/docs/plans/2026-02-22-backend-result-type.md @@ -0,0 +1,603 @@ +# go-ml Backend Result Type Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Break the Backend interface to return `Result{Text, Metrics}` instead of bare `string`, giving all consumers access to inference metrics. + +**Architecture:** Add a `Result` struct to `inference.go`, update `Backend` and `StreamingBackend` interfaces, update all 3 backend implementations (HTTP, Llama, InferenceAdapter), then update ~13 production call sites and ~15 test call sites. The InferenceAdapter populates `Metrics` from the underlying TextModel; HTTP and Llama return nil metrics. + +**Tech Stack:** Go 1.25, `forge.lthn.ai/core/go-inference` (GenerateMetrics type), testify + +**Test command:** `go test ./...` (no Taskfile — standard go test) + +**Build tags:** Several files are `//go:build darwin && arm64` (MLX-specific). On macOS arm64 all tests run. On other platforms, only HTTP backend tests run. + +--- + +### Task 1: Add Result type and update Backend interface + +**Files:** +- Modify: `inference.go:23-66` + +**Step 1: Add the Result struct and update interfaces** + +In `inference.go`, add the `Result` type after the imports and before the `Backend` interface. Then change `Backend.Generate` and `Backend.Chat` return types from `(string, error)` to `(Result, error)`: + +```go +// Result holds the response text and optional inference metrics. +// Backends that support metrics (e.g. MLX via InferenceAdapter) populate +// Metrics; HTTP and subprocess backends leave it nil. +type Result struct { + Text string + Metrics *inference.GenerateMetrics +} + +// Backend is the primary inference abstraction. All three concrete +// implementations — HTTPBackend, LlamaBackend, InferenceAdapter — satisfy it. +type Backend interface { + // Generate sends a single user prompt and returns the response. + Generate(ctx context.Context, prompt string, opts GenOpts) (Result, error) + + // Chat sends a multi-turn conversation and returns the response. + Chat(ctx context.Context, messages []Message, opts GenOpts) (Result, error) + + // Name returns the backend identifier (e.g. "http", "llama", "ollama"). + Name() string + + // Available reports whether the backend is ready to accept requests. + Available() bool +} +``` + +`StreamingBackend` stays unchanged — it uses callbacks, not return values. + +**Step 2: Verify the build fails** + +Run: `go build ./...` +Expected: Compilation errors in every file that implements or calls Backend.Generate/Chat. + +**Step 3: Commit** + +```bash +git add inference.go +git commit -m "feat: add Result type, break Backend interface to return Result + +Backend.Generate and Backend.Chat now return (Result, error) instead of +(string, error). Result carries the response text and optional +inference.GenerateMetrics for backends that support them. + +Co-Authored-By: Virgil " +``` + +--- + +### Task 2: Update InferenceAdapter (Metal backend) + +**Files:** +- Modify: `adapter.go:33-57` + +**Step 1: Update Generate and Chat to return Result with Metrics** + +```go +// Generate collects all tokens from the model's iterator into a single string. +func (a *InferenceAdapter) Generate(ctx context.Context, prompt string, opts GenOpts) (Result, error) { + inferOpts := convertOpts(opts) + var b strings.Builder + for tok := range a.model.Generate(ctx, prompt, inferOpts...) { + b.WriteString(tok.Text) + } + if err := a.model.Err(); err != nil { + return Result{Text: b.String()}, err + } + return Result{Text: b.String(), Metrics: metricsPtr(a.model)}, nil +} + +// Chat sends a multi-turn conversation to the underlying TextModel and collects +// all tokens. +func (a *InferenceAdapter) Chat(ctx context.Context, messages []Message, opts GenOpts) (Result, error) { + inferOpts := convertOpts(opts) + var b strings.Builder + for tok := range a.model.Chat(ctx, messages, inferOpts...) { + b.WriteString(tok.Text) + } + if err := a.model.Err(); err != nil { + return Result{Text: b.String()}, err + } + return Result{Text: b.String(), Metrics: metricsPtr(a.model)}, nil +} +``` + +Add a helper at the bottom of adapter.go: + +```go +// metricsPtr returns a copy of the model's latest metrics, or nil if unavailable. +func metricsPtr(m inference.TextModel) *inference.GenerateMetrics { + met := m.Metrics() + return &met +} +``` + +**Step 2: Verify adapter compiles** + +Run: `go build ./...` +Expected: Still fails (other backends + callers not updated yet), but `adapter.go` should have no errors. + +**Step 3: Commit** + +```bash +git add adapter.go +git commit -m "feat(adapter): return Result with Metrics from TextModel + +InferenceAdapter.Generate and Chat now return Result{Text, Metrics} +where Metrics is populated from the underlying TextModel.Metrics(). + +Co-Authored-By: Virgil " +``` + +--- + +### Task 3: Update HTTPBackend + +**Files:** +- Modify: `backend_http.go:77-128` + +**Step 1: Update Generate and Chat return types** + +```go +// Generate sends a single prompt and returns the response. +func (b *HTTPBackend) Generate(ctx context.Context, prompt string, opts GenOpts) (Result, error) { + return b.Chat(ctx, []Message{{Role: "user", Content: prompt}}, opts) +} + +// Chat sends a multi-turn conversation and returns the response. +func (b *HTTPBackend) Chat(ctx context.Context, messages []Message, opts GenOpts) (Result, error) { + // ... existing code unchanged until the return statements ... +``` + +In `Chat`, change the success return in the retry loop (line ~117): +```go + result, err := b.doRequest(ctx, body) + if err == nil { + return Result{Text: result}, nil + } +``` + +Change the final error return (line ~127): +```go + return Result{}, log.E("ml.HTTPBackend.Chat", fmt.Sprintf("exhausted %d retries", maxAttempts), lastErr) +``` + +Also update `doRequest` — it currently returns `(string, error)`. Keep it returning string since it's internal, or update it too. Simplest: keep `doRequest` as `(string, error)` since it's only called by `Chat`. + +**Step 2: Commit** + +```bash +git add backend_http.go +git commit -m "feat(http): return Result from Generate/Chat + +HTTP backend returns Result{Text: text} with nil Metrics since +remote APIs don't provide Metal-level inference metrics. + +Co-Authored-By: Virgil " +``` + +--- + +### Task 4: Update LlamaBackend + +**Files:** +- Modify: `backend_llama.go:118-130` + +**Step 1: Update Generate and Chat** + +LlamaBackend delegates to `b.http` (an HTTPBackend). Since HTTPBackend now returns Result, just update the signatures: + +```go +// Generate delegates to the HTTP backend. +func (b *LlamaBackend) Generate(ctx context.Context, prompt string, opts GenOpts) (Result, error) { + return b.http.Generate(ctx, prompt, opts) +} + +// Chat delegates to the HTTP backend. +func (b *LlamaBackend) Chat(ctx context.Context, messages []Message, opts GenOpts) (Result, error) { + return b.http.Chat(ctx, messages, opts) +} +``` + +**Step 2: Commit** + +```bash +git add backend_llama.go +git commit -m "feat(llama): return Result from Generate/Chat + +Delegates to HTTPBackend which already returns Result. + +Co-Authored-By: Virgil " +``` + +--- + +### Task 5: Update HTTPBackendTextModel + +**Files:** +- Modify: `backend_http_textmodel.go:40-65` + +**Step 1: Update the TextModel wrapper** + +This file wraps HTTPBackend as a go-inference TextModel. It calls `m.http.Generate()` and `m.http.Chat()` internally. Update to access `.Text`: + +Line ~42: +```go + result, err := m.http.Generate(ctx, prompt, genOpts) + if err != nil { + // ... existing error handling + } + // Use result.Text where the old code used result directly +``` + +Line ~64: +```go + result, err := m.http.Chat(ctx, messages, genOpts) + if err != nil { + // ... existing error handling + } + // Use result.Text where the old code used result directly +``` + +**Step 2: Commit** + +```bash +git add backend_http_textmodel.go +git commit -m "refactor(http-textmodel): unwrap Result.Text from Backend calls + +Co-Authored-By: Virgil " +``` + +--- + +### Task 6: Update service.go facade + +**Files:** +- Modify: `service.go:144-154` + +**Step 1: Update Service.Generate return type** + +```go +// Generate generates text using the named backend (or default). +func (s *Service) Generate(ctx context.Context, backendName, prompt string, opts GenOpts) (Result, error) { + b := s.Backend(backendName) + if b == nil { + b = s.DefaultBackend() + } + if b == nil { + return Result{}, fmt.Errorf("no backend available (requested: %q)", backendName) + } + return b.Generate(ctx, prompt, opts) +} +``` + +**Step 2: Commit** + +```bash +git add service.go +git commit -m "refactor(service): Generate returns Result + +Co-Authored-By: Virgil " +``` + +--- + +### Task 7: Update production callers — root package + +**Files:** +- Modify: `expand.go:103` +- Modify: `judge.go:62-63` +- Modify: `agent_eval.go:219,279,339` + +**Step 1: Update expand.go** + +Line 103 — add `.Text`: +```go +result, err := backend.Generate(ctx, p.Prompt, GenOpts{Temperature: 0.7, MaxTokens: 2048}) +// ... error handling unchanged ... +response := result.Text +``` + +Rename the variable from `response` to `result` and add `response := result.Text` after error check. Or simply: +```go +res, err := backend.Generate(ctx, p.Prompt, GenOpts{Temperature: 0.7, MaxTokens: 2048}) +if err != nil { + // ... unchanged +} +response := res.Text +``` + +**Step 2: Update judge.go** + +Line 62-63 — `judgeChat` returns `(string, error)` to its callers. Unwrap internally: +```go +func (j *Judge) judgeChat(ctx context.Context, prompt string) (string, error) { + res, err := j.backend.Generate(ctx, prompt, DefaultGenOpts()) + return res.Text, err +} +``` + +**Step 3: Update agent_eval.go** + +Three call sites. Each currently does `response, err := backend.Generate(...)`. Change to: + +Line 219 (`RunCapabilityProbes`): +```go +res, err := backend.Generate(ctx, probe.Prompt, GenOpts{Temperature: CapabilityTemperature, MaxTokens: CapabilityMaxTokens}) +// ... error handling unchanged (uses err) ... +response := res.Text +``` + +Note: line 222 uses `response` in error path — on error, `res.Text` will be empty string which is fine. But check: the existing code at line 282 does `response = fmt.Sprintf("ERROR: %v", err)` on error. This pattern needs `response` to be reassignable. Use: +```go +res, err := backend.Generate(...) +response := res.Text +if err != nil { + response = fmt.Sprintf("ERROR: %v", err) +} +``` + +Line 279 (`RunCapabilityProbesFull`) — same error-path pattern as above. Note: downstream uses of `response` include `StripThinkBlocks(response)` at line 285, `fullResponses` append at lines 305-313, and `onProbe` callback at line 321. All of these expect a string, which is satisfied by extracting `response := res.Text` at the call site. + +Line 339 (`RunContentProbesViaAPI`): +```go +res, err := backend.Generate(ctx, probe.Prompt, GenOpts{Temperature: ContentTemperature, MaxTokens: ContentMaxTokens}) +if err != nil { + // ... unchanged +} +reply := res.Text +``` + +**Step 4: Commit** + +```bash +git add expand.go judge.go agent_eval.go +git commit -m "refactor: unwrap Result.Text in expand, judge, agent_eval + +Co-Authored-By: Virgil " +``` + +--- + +### Task 8: Update production callers — cmd/ package + +**Files:** +- Modify: `cmd/cmd_ab.go:252,275` +- Modify: `cmd/cmd_sandwich.go:175` +- Modify: `cmd/cmd_lesson.go:245` +- Modify: `cmd/cmd_sequence.go:257` +- Modify: `cmd/cmd_benchmark.go:234,264` +- Modify: `cmd/cmd_serve.go:250,380` + +**Step 1: Update cmd_ab.go** + +**Important:** `baseResp` and `resp` are used as strings throughout the loop body — passed to `ml.ScoreHeuristic(baseResp)`, stored in `abConditionScore{Response: baseResp}`, used in `len(baseResp)`, and logged in `slog.Info`. Extract `.Text` at the call site so the existing string variable name is preserved for all downstream uses. + +Line 252 — baseline response: +```go +res, err := backend.Chat(context.Background(), []ml.Message{ + {Role: "user", Content: p.Prompt}, +}, opts) +if err != nil { + slog.Error("ab: baseline failed", "id", p.ID, "error", err) + runtime.GC() + continue +} +baseResp := res.Text +``` + +Line 275 — kernel condition: +```go +res, err := backend.Chat(context.Background(), []ml.Message{ + {Role: "system", Content: k.Text}, + {Role: "user", Content: p.Prompt}, +}, opts) +if err != nil { + slog.Error("ab: failed", "id", p.ID, "condition", k.Name, "error", err) + continue +} +resp := res.Text +``` + +**Step 2: Update cmd_sandwich.go** + +Line 175: +```go +res, err := backend.Chat(context.Background(), messages, opts) +if err != nil { + // ... unchanged +} +response := res.Text +``` + +**Step 3: Update cmd_lesson.go** + +Line 245 — same pattern as sandwich: +```go +res, err := backend.Chat(context.Background(), messages, opts) +if err != nil { + // ... unchanged +} +response := res.Text +``` + +**Step 4: Update cmd_sequence.go** + +Line 257: +```go +res, err := backend.Chat(cmd.Context(), messages, opts) +if err != nil { + // ... unchanged +} +response := res.Text +``` + +**Step 5: Update cmd_benchmark.go** + +Line 234: +```go +res, err := baselineBackend.Generate(context.Background(), p.prompt, opts) +// ... unwrap res.Text ... +resp := res.Text +``` + +Line 264: +```go +res, err := trainedBackend.Generate(context.Background(), p.prompt, opts) +resp := res.Text +``` + +**Step 6: Update cmd_serve.go** + +Line 250 (completions endpoint): +```go +res, err := backend.Generate(r.Context(), req.Prompt, opts) +if err != nil { + // ... unchanged +} +text := res.Text +``` + +Line 380 (chat completions, non-streaming): +```go +res, err := backend.Chat(r.Context(), req.Messages, opts) +if err != nil { + // ... unchanged +} +text := res.Text +``` + +**Step 7: Update api/routes.go** + +Line 125 — note that the `text` variable is also used on line 131 in `generateResponse{Text: text}`. Use `res.Text` consistently: +```go +res, err := r.service.Generate(c.Request.Context(), req.Backend, req.Prompt, opts) +if err != nil { + // ... unchanged +} +// line 131: generateResponse{Text: res.Text} +``` + +Either extract `text := res.Text` and use `text` on line 131, or use `res.Text` directly in the response struct. Both work — just be consistent. + +**Step 8: Commit** + +```bash +git add cmd/ api/ +git commit -m "refactor(cmd): unwrap Result.Text across all commands + +Updates cmd_ab, cmd_sandwich, cmd_lesson, cmd_sequence, +cmd_benchmark, cmd_serve, and api/routes. + +Co-Authored-By: Virgil " +``` + +--- + +### Task 9: Update all test files + +**Files:** +- Modify: `adapter_test.go` +- Modify: `backend_http_test.go` +- Modify: `backend_llama_test.go` +- Modify: `backend_mlx_test.go` +- Modify: `backend_http_textmodel_test.go` +- No change: `api/routes_test.go` — tests use nil service and never reach Generate calls. Confirm by grepping for `.Generate` in that file. + +**Step 1: Update adapter_test.go** + +Every `result, err := adapter.Generate(...)` or `adapter.Chat(...)` — the `result` is now a `Result` struct. Add `.Text` to assertions: + +```go +// Before: +result, err := adapter.Generate(context.Background(), "prompt", GenOpts{}) +assert.Equal(t, "hello world", result) + +// After: +result, err := adapter.Generate(context.Background(), "prompt", GenOpts{}) +assert.Equal(t, "hello world", result.Text) +``` + +Also add a metrics assertion for the happy path: +```go +assert.NotNil(t, result.Metrics) +``` + +For error cases where `model.Err()` returns an error, `result.Text` may be partial and `Metrics` will be nil. + +**Step 2: Update backend_http_test.go** + +Same pattern — `result` → `result.Text` in assertions. Metrics will be nil for HTTP backend: +```go +result, err := b.Generate(context.Background(), "hello", DefaultGenOpts()) +require.NoError(t, err) +assert.Equal(t, "test response", result.Text) +assert.Nil(t, result.Metrics) +``` + +**Step 3: Update backend_llama_test.go** + +Same pattern as HTTP tests. `result.Text` everywhere. + +**Step 4: Update backend_mlx_test.go** + +Same pattern. Can also assert `result.Metrics != nil` on success. + +**Step 5: Update backend_http_textmodel_test.go** + +This tests the TextModel wrapper — it calls `model.Generate()` which returns `iter.Seq[Token]`, not `Backend.Generate()`. These tests likely don't need changes unless they also test the Backend interface directly. Check carefully. + +**Step 6: Run all tests** + +Run: `go test ./...` +Expected: All tests pass. + +**Step 7: Commit** + +```bash +git add *_test.go +git commit -m "test: update all test assertions for Result type + +All Backend.Generate/Chat calls now return Result. Test assertions +updated to use .Text and check .Metrics where appropriate. + +Co-Authored-By: Virgil " +``` + +--- + +### Task 10: Final verification + +**Step 1: Full build** + +Run: `go build ./...` +Expected: Clean build, zero errors. + +**Step 2: Full test suite** + +Run: `go test ./... -count=1` +Expected: All tests pass. + +**Step 3: Vet** + +Run: `go vet ./...` +Expected: No issues. + +**Step 4: Check for any remaining string returns** + +Search for any callers still expecting `(string, error)` from Backend: + +Run: `grep -rn '\.Generate\|\.Chat' --include='*.go' | grep -v '_test.go' | grep -v '//go:build ignore'` + +Verify no call sites were missed. + +**Step 5: Final commit if any fixups needed, then tag** + +```bash +git tag -a v0.X.0 -m "feat: Backend returns Result{Text, Metrics}" +``` diff --git a/gguf.go b/gguf.go index 3155a55..a212971 100644 --- a/gguf.go +++ b/gguf.go @@ -38,7 +38,7 @@ const ( type ggufMetadata struct { key string valueType uint32 - value interface{} // string, uint32, or float32 + value any // string, uint32, or float32 } // ggufTensor describes a tensor in the GGUF file. diff --git a/go.sum b/go.sum index f7b5c90..7d5d428 100644 --- a/go.sum +++ b/go.sum @@ -1,15 +1,15 @@ -forge.lthn.ai/core/go v0.0.0-20260221191103-d091fa62023f h1:CcSh/FFY93K5m0vADHLxwxKn2pTIM8HzYX1eGa4WZf4= -forge.lthn.ai/core/go v0.0.0-20260221191103-d091fa62023f/go.mod h1:WCPJVEZm/6mTcJimHV0uX8ZhnKEF3dN0rQp13ByaSPg= -forge.lthn.ai/core/go-api v0.0.0-20260221015744-0d3479839dc5 h1:60reee4fmT4USZqEd6dyCTXsTj47eOOEc6Pp0HHJbd0= -forge.lthn.ai/core/go-api v0.0.0-20260221015744-0d3479839dc5/go.mod h1:f0hPLX+GZT/ME8Tb7c8wVDlfLqnpOKRwf2k5lpJq87g= -forge.lthn.ai/core/go-crypt v0.0.0-20260221190941-9585da8e6649 h1:Rs3bfSU8u1wkzYeL21asL7IcJIBVwOhtRidcEVj/PkA= -forge.lthn.ai/core/go-crypt v0.0.0-20260221190941-9585da8e6649/go.mod h1:RS+sz5lChrbc1AEmzzOULsTiMv3bwcwVtwbZi+c/Yjk= -forge.lthn.ai/core/go-i18n v0.0.0-20260220151120-0d8463c8845a h1:c11jsFkOHVwnw2TS0hWsyFe0H9me6SWzFnLWo0kBTbM= -forge.lthn.ai/core/go-i18n v0.0.0-20260220151120-0d8463c8845a/go.mod h1:/Iu9h43T/5LrZcqXtNBZUw9ICur+nzbnI1IRVK628A8= -forge.lthn.ai/core/go-inference v0.0.0-20260220151119-1576f744d105 h1:CVUVxp1BfUI8wmlEUW0Nay8w4hADR54nqBmeF+KK2Ac= -forge.lthn.ai/core/go-inference v0.0.0-20260220151119-1576f744d105/go.mod h1:hmLtynfw1yo0ByuX3pslLZMgCdqJH2r+2+wGJDhmmi0= -forge.lthn.ai/core/go-mlx v0.0.0-20260221191404-2292557fd65f h1:dlb6hFFhxfnJvD1ZYoQVsxD9NM4CV+sXkjHa6kBGzeE= -forge.lthn.ai/core/go-mlx v0.0.0-20260221191404-2292557fd65f/go.mod h1:QHspfOk9MgbuG6Wb4m+RzQyCMibtoQNZw+hUs4yclOA= +forge.lthn.ai/core/go v0.0.1 h1:6DFABiGUccu3iQz2avpYbh0X24xccIsve6TSipziKT4= +forge.lthn.ai/core/go v0.0.1/go.mod h1:vr4W9GMcyKbOJWmo22zQ9KmzLbdr2s17Q6LkVjpOeFU= +forge.lthn.ai/core/go-api v0.0.1 h1:skuZYxkei+kLfVoOJs3524zlkk4REVWb9tdHnugCqlk= +forge.lthn.ai/core/go-api v0.0.1/go.mod h1:sWp6xNaWXk+5SJD7YannnKvdqgT6oMx8cUgCq7I2p38= +forge.lthn.ai/core/go-crypt v0.0.1 h1:dq+TqMGEOonKZTfBolCVLqakYnKrdhav/zTKpiNhvOs= +forge.lthn.ai/core/go-crypt v0.0.1/go.mod h1:s3UvyM48vq4kZcdM2WDxFAU+KTcZK6N+WuNHG3FOyJ8= +forge.lthn.ai/core/go-i18n v0.0.1 h1:rEtw64YIGs8qhczFjGpnoNlMtw+L1Qr9oqN3ZpovMaY= +forge.lthn.ai/core/go-i18n v0.0.1/go.mod h1:PdXTuFsmD8n+lhWyctz8g62I+e/1a/Ye9i5NFWsXEKs= +forge.lthn.ai/core/go-inference v0.0.1 h1:87kCwOS0wWAE38zyKz/UDWjv2rfI9gQaYXvrUBPzcEY= +forge.lthn.ai/core/go-inference v0.0.1/go.mod h1:hmLtynfw1yo0ByuX3pslLZMgCdqJH2r+2+wGJDhmmi0= +forge.lthn.ai/core/go-mlx v0.0.1 h1:xTi0X+noGYNmRcRuwLV4KwtIOT5QOxmGKzsTIchw80g= +forge.lthn.ai/core/go-mlx v0.0.1/go.mod h1:r+72UbUMXnVjRzml29lHxRvFThdQl/LwEEsyYMsRrOY= github.com/99designs/gqlgen v0.17.87 h1:pSnCIMhBQezAE8bc1GNmfdLXFmnWtWl1GRDFEE/nHP8= github.com/99designs/gqlgen v0.17.87/go.mod h1:fK05f1RqSNfQpd4CfW5qk/810Tqi4/56Wf6Nem0khAg= github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= diff --git a/import_all.go b/import_all.go index bbd288f..f16dcff 100644 --- a/import_all.go +++ b/import_all.go @@ -301,7 +301,7 @@ func importBenchmarkFile(db *DB, path, source string) int { scanner.Buffer(make([]byte, 1024*1024), 1024*1024) for scanner.Scan() { - var rec map[string]interface{} + var rec map[string]any if err := json.Unmarshal(scanner.Bytes(), &rec); err != nil { continue } @@ -333,7 +333,7 @@ func importBenchmarkQuestions(db *DB, path, benchmark string) int { scanner.Buffer(make([]byte, 1024*1024), 1024*1024) for scanner.Scan() { - var rec map[string]interface{} + var rec map[string]any if err := json.Unmarshal(scanner.Bytes(), &rec); err != nil { continue } @@ -371,26 +371,26 @@ func importSeeds(db *DB, seedDir string) int { region := strings.TrimSuffix(filepath.Base(path), ".json") // Try parsing as array or object with prompts/seeds field. - var seedsList []interface{} - var raw interface{} + var seedsList []any + var raw any if err := json.Unmarshal(data, &raw); err != nil { return nil } switch v := raw.(type) { - case []interface{}: + case []any: seedsList = v - case map[string]interface{}: - if prompts, ok := v["prompts"].([]interface{}); ok { + case map[string]any: + if prompts, ok := v["prompts"].([]any); ok { seedsList = prompts - } else if seeds, ok := v["seeds"].([]interface{}); ok { + } else if seeds, ok := v["seeds"].([]any); ok { seedsList = seeds } } for _, s := range seedsList { switch seed := s.(type) { - case map[string]interface{}: + case map[string]any: prompt := strOrEmpty(seed, "prompt") if prompt == "" { prompt = strOrEmpty(seed, "text") @@ -416,14 +416,14 @@ func importSeeds(db *DB, seedDir string) int { return count } -func strOrEmpty(m map[string]interface{}, key string) string { +func strOrEmpty(m map[string]any, key string) string { if v, ok := m[key]; ok { return fmt.Sprintf("%v", v) } return "" } -func floatOrZero(m map[string]interface{}, key string) float64 { +func floatOrZero(m map[string]any, key string) float64 { if v, ok := m[key]; ok { if f, ok := v.(float64); ok { return f diff --git a/influx.go b/influx.go index 6ec9c1b..72dfcce 100644 --- a/influx.go +++ b/influx.go @@ -78,7 +78,7 @@ func (c *InfluxClient) WriteLp(lines []string) error { } // QuerySQL runs a SQL query against InfluxDB and returns the result rows. -func (c *InfluxClient) QuerySQL(sql string) ([]map[string]interface{}, error) { +func (c *InfluxClient) QuerySQL(sql string) ([]map[string]any, error) { reqBody := map[string]string{ "db": c.db, "q": sql, @@ -114,7 +114,7 @@ func (c *InfluxClient) QuerySQL(sql string) ([]map[string]interface{}, error) { return nil, fmt.Errorf("query failed %d: %s", resp.StatusCode, string(respBody)) } - var rows []map[string]interface{} + var rows []map[string]any if err := json.Unmarshal(respBody, &rows); err != nil { return nil, fmt.Errorf("unmarshal query response: %w", err) } diff --git a/ingest.go b/ingest.go index 291188b..a5a906c 100644 --- a/ingest.go +++ b/ingest.go @@ -24,14 +24,14 @@ type IngestConfig struct { // contentScoreLine is the JSON structure for a content scores JSONL line. type contentScoreLine struct { - Label string `json:"label"` - Aggregates map[string]interface{} `json:"aggregates"` - Probes map[string]contentScoreProbe `json:"probes"` + Label string `json:"label"` + Aggregates map[string]any `json:"aggregates"` + Probes map[string]contentScoreProbe `json:"probes"` } // contentScoreProbe is the per-probe block within a content score line. type contentScoreProbe struct { - Scores map[string]interface{} `json:"scores"` + Scores map[string]any `json:"scores"` } // capabilityScoreLine is the JSON structure for a capability scores JSONL line. @@ -364,7 +364,7 @@ func extractIteration(label string) int { // toFloat64 converts a JSON-decoded interface{} value to float64. // Handles float64 (standard json.Unmarshal), json.Number, and string values. -func toFloat64(v interface{}) (float64, bool) { +func toFloat64(v any) (float64, bool) { switch val := v.(type) { case float64: return val, true diff --git a/inventory.go b/inventory.go index 142df47..c89bc24 100644 --- a/inventory.go +++ b/inventory.go @@ -133,7 +133,7 @@ func gatherDetails(db *DB, counts map[string]int) map[string]*tableDetail { // toInt converts a DuckDB value to int. DuckDB returns integers as int64 (not // float64 like InfluxDB), so we handle both types. -func toInt(v interface{}) int { +func toInt(v any) int { switch n := v.(type) { case int64: return int(n) diff --git a/ollama.go b/ollama.go index 66069f8..d862d89 100644 --- a/ollama.go +++ b/ollama.go @@ -85,7 +85,7 @@ func OllamaCreateModel(ollamaURL, modelName, baseModel, peftDir string) error { return fmt.Errorf("upload adapter config: %w", err) } - reqBody, _ := json.Marshal(map[string]interface{}{ + reqBody, _ := json.Marshal(map[string]any{ "model": modelName, "from": baseModel, "adapters": map[string]string{ diff --git a/score.go b/score.go index 21a9224..c4d87cb 100644 --- a/score.go +++ b/score.go @@ -27,7 +27,7 @@ func NewEngine(judge *Judge, concurrency int, suiteList string) *Engine { suites["standard"] = true suites["exact"] = true } else { - for _, s := range strings.Split(suiteList, ",") { + for s := range strings.SplitSeq(suiteList, ",") { s = strings.TrimSpace(s) if s != "" { suites[s] = true diff --git a/score_race_test.go b/score_race_test.go index 87d2ce1..04d9c44 100644 --- a/score_race_test.go +++ b/score_race_test.go @@ -43,7 +43,7 @@ func TestScoreAll_ConcurrentSemantic_Good(t *testing.T) { engine := NewEngine(judge, 4, "heuristic,semantic") // concurrency=4 var responses []Response - for i := 0; i < 20; i++ { + for i := range 20 { responses = append(responses, Response{ ID: idForIndex(i), Prompt: "test prompt", @@ -153,7 +153,7 @@ func TestScoreAll_SemaphoreBoundary_Good(t *testing.T) { engine := NewEngine(judge, 1, "semantic") // concurrency=1 var responses []Response - for i := 0; i < 5; i++ { + for i := range 5 { responses = append(responses, Response{ ID: idForIndex(i), Prompt: "p", Response: "r", Model: "m", }) @@ -209,7 +209,7 @@ func TestScoreAll_HeuristicOnlyNoRace_Good(t *testing.T) { engine := NewEngine(nil, 4, "heuristic") var responses []Response - for i := 0; i < 50; i++ { + for i := range 50 { responses = append(responses, Response{ ID: idForIndex(i), Prompt: "prompt", @@ -249,7 +249,7 @@ func TestScoreAll_MultiModelConcurrent_Good(t *testing.T) { var responses []Response models := []string{"alpha", "beta", "gamma", "delta"} for _, model := range models { - for j := 0; j < 5; j++ { + for j := range 5 { responses = append(responses, Response{ ID: model + "-" + idForIndex(j), Prompt: "test", diff --git a/status.go b/status.go index d61a0a2..2c3c013 100644 --- a/status.go +++ b/status.go @@ -109,7 +109,7 @@ func PrintStatus(influx *InfluxClient, w io.Writer) error { // dedupeTraining merges training status and loss rows, keeping only the first // (latest) row per model. -func dedupeTraining(statusRows, lossRows []map[string]interface{}) []trainingRow { +func dedupeTraining(statusRows, lossRows []map[string]any) []trainingRow { lossMap := make(map[string]float64) lossSeenMap := make(map[string]bool) for _, row := range lossRows { @@ -154,7 +154,7 @@ func dedupeTraining(statusRows, lossRows []map[string]interface{}) []trainingRow } // dedupeGeneration deduplicates generation progress rows by worker. -func dedupeGeneration(rows []map[string]interface{}) []genRow { +func dedupeGeneration(rows []map[string]any) []genRow { seen := make(map[string]bool) var result []genRow for _, row := range rows { @@ -180,7 +180,7 @@ func dedupeGeneration(rows []map[string]interface{}) []genRow { } // strVal extracts a string value from a row map. -func strVal(row map[string]interface{}, key string) string { +func strVal(row map[string]any, key string) string { v, ok := row[key] if !ok { return "" @@ -193,7 +193,7 @@ func strVal(row map[string]interface{}, key string) string { } // floatVal extracts a float64 value from a row map. -func floatVal(row map[string]interface{}, key string) float64 { +func floatVal(row map[string]any, key string) float64 { v, ok := row[key] if !ok { return 0 @@ -207,6 +207,6 @@ func floatVal(row map[string]interface{}, key string) float64 { // intVal extracts an integer value from a row map. InfluxDB JSON returns all // numbers as float64, so this truncates to int. -func intVal(row map[string]interface{}, key string) int { +func intVal(row map[string]any, key string) int { return int(floatVal(row, key)) } diff --git a/worker.go b/worker.go index ac0678d..bd0aa3a 100644 --- a/worker.go +++ b/worker.go @@ -84,7 +84,7 @@ func RunWorkerLoop(cfg *WorkerConfig) { } func workerRegister(cfg *WorkerConfig) error { - body := map[string]interface{}{ + body := map[string]any{ "worker_id": cfg.WorkerID, "name": cfg.Name, "version": "0.1.0", @@ -109,7 +109,7 @@ func workerRegister(cfg *WorkerConfig) error { } func workerHeartbeat(cfg *WorkerConfig) { - body := map[string]interface{}{ + body := map[string]any{ "worker_id": cfg.WorkerID, } apiPost(cfg, "/api/lem/workers/heartbeat", body) @@ -146,7 +146,7 @@ func workerPoll(cfg *WorkerConfig) int { for _, task := range result.Tasks { if err := workerProcessTask(cfg, task); err != nil { log.Printf("Task %d failed: %v", task.ID, err) - apiDelete(cfg, fmt.Sprintf("/api/lem/tasks/%d/claim", task.ID), map[string]interface{}{ + apiDelete(cfg, fmt.Sprintf("/api/lem/tasks/%d/claim", task.ID), map[string]any{ "worker_id": cfg.WorkerID, }) continue @@ -161,14 +161,14 @@ func workerProcessTask(cfg *WorkerConfig, task APITask) error { log.Printf("Processing task %d: %s [%s/%s] %d chars prompt", task.ID, task.TaskType, task.Language, task.Domain, len(task.PromptText)) - _, err := apiPost(cfg, fmt.Sprintf("/api/lem/tasks/%d/claim", task.ID), map[string]interface{}{ + _, err := apiPost(cfg, fmt.Sprintf("/api/lem/tasks/%d/claim", task.ID), map[string]any{ "worker_id": cfg.WorkerID, }) if err != nil { return fmt.Errorf("claim: %w", err) } - apiPatch(cfg, fmt.Sprintf("/api/lem/tasks/%d/status", task.ID), map[string]interface{}{ + apiPatch(cfg, fmt.Sprintf("/api/lem/tasks/%d/status", task.ID), map[string]any{ "worker_id": cfg.WorkerID, "status": "in_progress", }) @@ -183,7 +183,7 @@ func workerProcessTask(cfg *WorkerConfig, task APITask) error { genTime := time.Since(start) if err != nil { - apiPatch(cfg, fmt.Sprintf("/api/lem/tasks/%d/status", task.ID), map[string]interface{}{ + apiPatch(cfg, fmt.Sprintf("/api/lem/tasks/%d/status", task.ID), map[string]any{ "worker_id": cfg.WorkerID, "status": "abandoned", }) @@ -195,7 +195,7 @@ func workerProcessTask(cfg *WorkerConfig, task APITask) error { modelUsed = "default" } - _, err = apiPost(cfg, fmt.Sprintf("/api/lem/tasks/%d/result", task.ID), map[string]interface{}{ + _, err = apiPost(cfg, fmt.Sprintf("/api/lem/tasks/%d/result", task.ID), map[string]any{ "worker_id": cfg.WorkerID, "response_text": response, "model_used": modelUsed, @@ -225,7 +225,7 @@ func workerInfer(cfg *WorkerConfig, task APITask) (string, error) { } } - reqBody := map[string]interface{}{ + reqBody := map[string]any{ "model": task.ModelName, "messages": messages, "temperature": temp, @@ -310,19 +310,19 @@ func apiGet(cfg *WorkerConfig, path string) ([]byte, error) { return body, nil } -func apiPost(cfg *WorkerConfig, path string, data map[string]interface{}) ([]byte, error) { +func apiPost(cfg *WorkerConfig, path string, data map[string]any) ([]byte, error) { return apiRequest(cfg, "POST", path, data) } -func apiPatch(cfg *WorkerConfig, path string, data map[string]interface{}) ([]byte, error) { +func apiPatch(cfg *WorkerConfig, path string, data map[string]any) ([]byte, error) { return apiRequest(cfg, "PATCH", path, data) } -func apiDelete(cfg *WorkerConfig, path string, data map[string]interface{}) ([]byte, error) { +func apiDelete(cfg *WorkerConfig, path string, data map[string]any) ([]byte, error) { return apiRequest(cfg, "DELETE", path, data) } -func apiRequest(cfg *WorkerConfig, method, path string, data map[string]interface{}) ([]byte, error) { +func apiRequest(cfg *WorkerConfig, method, path string, data map[string]any) ([]byte, error) { jsonData, err := json.Marshal(data) if err != nil { return nil, err @@ -386,7 +386,7 @@ func ReadKeyFile() string { // SplitComma splits a comma-separated string into trimmed parts. func SplitComma(s string) []string { var result []string - for _, part := range bytes.Split([]byte(s), []byte(",")) { + for part := range bytes.SplitSeq([]byte(s), []byte(",")) { trimmed := bytes.TrimSpace(part) if len(trimmed) > 0 { result = append(result, string(trimmed))