go-ml/docs/development.md

308 lines
8.5 KiB
Markdown
Raw Normal View History

# go-ml Development Guide
## Prerequisites
### Required
- **Go 1.25** or later (the module uses `go 1.25.5`)
- **Go workspace** — go-ml is part of the `host-uk/core` Go workspace; `replace` directives in `go.mod` resolve sibling modules from local paths
### Required sibling modules (local paths)
| Module | Local path | Notes |
|--------|-----------|-------|
| `forge.lthn.ai/core/go` | `../go` | Framework, process management, logging |
| `forge.lthn.ai/core/go-inference` | `../go-inference` | Shared TextModel/Token interfaces |
| `forge.lthn.ai/core/go-mlx` | `../go-mlx` | Metal GPU backend |
All three must be checked out as siblings of `go-ml` (i.e. all four directories share the same parent).
### Platform-specific
- **Metal GPU (`NewMLXBackend`)** — requires macOS on Apple Silicon (darwin/arm64). The `backend_mlx.go` file carries a `//go:build darwin && arm64` build tag and is excluded on other platforms. All other features work on Linux and amd64.
- **llama-server** — the `llama-server` binary from llama.cpp must be on `PATH` or the path provided in `LlamaOpts.LlamaPath`.
- **DuckDB** — uses CGo; a C compiler (`gcc` or `clang`) is required.
---
## Getting Started
```bash
# On first checkout, populate go.sum
go mod download
# Verify the build (all platforms)
go build ./...
# Verify the build excluding Metal backend (Linux / CI)
GOFLAGS='-tags nomlx' go build ./...
```
---
## Build and Test Commands
```bash
# Run all tests
go test ./...
# Run with race detector (recommended before committing)
go test -race ./...
# Run a single test by name
go test -v -run TestHeuristic ./...
go test -v -run TestEngine_ScoreAll_ConcurrentSemantic ./...
# Run benchmarks
go test -bench=. ./...
go test -bench=BenchmarkHeuristicScore ./...
# Static analysis
go vet ./...
# Tidy dependencies
go mod tidy
```
---
## Test Patterns
### Naming convention
Tests use a `_Good`, `_Bad`, `_Ugly` suffix pattern:
- `_Good` — happy path (expected success)
- `_Bad` — expected error conditions (invalid input, unreachable server)
- `_Ugly` — panic and edge-case paths
### Mock backends
For tests that exercise `Backend`-dependent code (judge, agent, scoring engine) without a real inference server, implement `Backend` directly:
```go
type mockBackend struct {
response string
err error
}
func (m *mockBackend) Generate(_ context.Context, _ string, _ ml.GenOpts) (string, error) {
return m.response, m.err
}
func (m *mockBackend) Chat(_ context.Context, _ []ml.Message, _ ml.GenOpts) (string, error) {
return m.response, m.err
}
func (m *mockBackend) Name() string { return "mock" }
func (m *mockBackend) Available() bool { return true }
```
### Mock TextModel
For tests that exercise `InferenceAdapter` without Metal GPU hardware, implement `inference.TextModel`:
```go
type mockTextModel struct {
tokens []string
err error
}
func (m *mockTextModel) Generate(ctx context.Context, prompt string, opts ...inference.GenerateOption) iter.Seq[inference.Token] {
return func(yield func(inference.Token) bool) {
for _, t := range m.tokens {
if !yield(inference.Token{Text: t}) {
return
}
}
}
}
// ... implement remaining TextModel methods
func (m *mockTextModel) Err() error { return m.err }
```
### Mock RemoteTransport
For agent tests that would otherwise require an SSH connection:
```go
type fakeTransport struct {
outputs map[string]string
errors map[string]error
}
func (f *fakeTransport) Run(_ context.Context, cmd string) (string, error) {
if err, ok := f.errors[cmd]; ok {
return "", err
}
return f.outputs[cmd], nil
}
func (f *fakeTransport) CopyFrom(_ context.Context, _, _ string) error { return nil }
func (f *fakeTransport) CopyTo(_ context.Context, _, _ string) error { return nil }
```
Inject via `AgentConfig.Transport`:
```go
cfg := &ml.AgentConfig{
Transport: &fakeTransport{outputs: map[string]string{...}},
}
```
### HTTP mock server
For `HTTPBackend` tests, use `net/http/httptest`:
```go
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]any{
"choices": []map[string]any{
{"message": map[string]string{"role": "assistant", "content": "hello"}},
},
})
}))
defer srv.Close()
backend := ml.NewHTTPBackend(srv.URL, "test-model")
```
---
## Adding a New Backend
A backend must implement `ml.Backend`:
```go
type Backend interface {
Generate(ctx context.Context, prompt string, opts GenOpts) (string, error)
Chat(ctx context.Context, messages []Message, opts GenOpts) (string, error)
Name() string
Available() bool
}
```
### Steps
1. Create `backend_{name}.go` in the package root.
2. Add the `// SPDX-Licence-Identifier: EUPL-1.2` header.
3. Add a compile-time interface check:
```go
var _ Backend = (*MyBackend)(nil)
```
4. Implement `Generate` as a thin wrapper around `Chat` where possible (follows the pattern of `HTTPBackend`).
5. Create `backend_{name}_test.go` with `_Good`, `_Bad`, and interface-compliance tests.
6. Register the backend in `service.go`'s `OnStartup` if it warrants lifecycle management, or document that callers must register it via `Service.RegisterBackend`.
### GPU backends
If the backend wraps a `go-inference.TextModel` (e.g. a new hardware accelerator), use `InferenceAdapter` rather than re-implementing the polling/streaming logic:
```go
m, err := myBackendPackage.LoadModel(modelPath)
if err != nil {
return nil, err
}
return ml.NewInferenceAdapter(m, "my-backend"), nil
```
---
## Adding a New Scoring Suite
1. Add a new scoring function or type in a dedicated file (e.g. `my_suite.go`).
2. Add the suite name to `Engine.NewEngine`'s suite selection logic in `score.go`.
3. Add a result field to `PromptScore` in `types.go`.
4. Add the goroutine fan-out case in `Engine.ScoreAll` in `score.go`.
5. Add race condition tests in `score_race_test.go`.
---
## Coding Standards
### Language
Use **UK English** throughout: colour, organisation, centre, licence (noun), authorise. The only exception is identifiers in external APIs that use American spellings — do not rename those.
### File headers
Every new file must begin with:
```go
// SPDX-Licence-Identifier: EUPL-1.2
```
### Strict types
All parameters and return types must be explicitly typed. Avoid `interface{}` or `any` except at JSON unmarshalling boundaries.
### Import grouping
Three groups, each separated by a blank line:
```go
import (
"context" // stdlib
"fmt"
"forge.lthn.ai/core/go-inference" // forge.lthn.ai modules
"github.com/stretchr/testify/assert" // third-party
)
```
### Error wrapping
Use `fmt.Errorf("context: %w", err)` for wrapping. Use `log.E("pkg.Type.Method", "what failed", err)` from the Core framework for structured error logging with stack context.
### Concurrency
- Protect shared maps with `sync.RWMutex` or `sync.Mutex` as appropriate.
- Use semaphore channels (buffered `chan struct{}`) to bound goroutine concurrency rather than `sync.Pool` or `errgroup` with fixed limits.
- Always check `model.Err()` after exhausting a `go-inference` token iterator — the iterator itself carries no error; the error is stored on the model.
---
## Conventional Commits
Use the following scopes:
| Scope | When to use |
|-------|-------------|
| `backend` | Changes to any `backend_*.go` file or the `adapter.go` bridge |
| `scoring` | Changes to `score.go`, `heuristic.go`, `judge.go`, `exact.go` |
| `probes` | Changes to `probes.go` or capability probe definitions |
| `agent` | Changes to any `agent_*.go` file |
| `service` | Changes to `service.go` or `Options` |
| `types` | Changes to `types.go` or `inference.go` interfaces |
| `gguf` | Changes to `gguf.go` |
Examples:
```
feat(backend): add ROCm backend via go-rocm InferenceAdapter
fix(scoring): handle nil ContentScores when content probe not found
refactor(agent): replace SSHCommand with SSHTransport.Run
test(probes): add Check function coverage for all 23 probes
```
---
## Co-Author and Licence
Every commit must include:
```
Co-Authored-By: Virgil <virgil@lethean.io>
```
The licence is **EUPL-1.2**. All source files carry the SPDX identifier in the header. Do not add licence headers to test files; the package-level declaration covers them.
---
## Forge Remote
The authoritative remote is `forge.lthn.ai/core/go-ml`:
```bash
git push forge main
```
The SSH remote URL is `ssh://git@forge.lthn.ai:2223/core/go-ml.git`. HTTPS authentication is not configured — always push via SSH.