Replace internal task tracking (TODO.md, FINDINGS.md) with structured documentation in docs/. Trim CLAUDE.md to agent instructions only. Co-Authored-By: Virgil <virgil@lethean.io>
253 lines
8.1 KiB
Markdown
253 lines
8.1 KiB
Markdown
# Development Guide — go-inference
|
|
|
|
## Prerequisites
|
|
|
|
- Go 1.25 or later (uses `iter.Seq` from Go 1.23 and range-over-function from 1.22)
|
|
- No CGO, no build tags, no external tools required
|
|
- The package compiles on macOS, Linux, and Windows without modification
|
|
|
|
## Commands
|
|
|
|
```bash
|
|
# Run all tests
|
|
go test ./...
|
|
|
|
# Run a single test by name
|
|
go test -run TestDefault_Good_Metal ./...
|
|
|
|
# Vet for common mistakes
|
|
go vet ./...
|
|
|
|
# View test coverage
|
|
go test -coverprofile=coverage.out ./...
|
|
go tool cover -html=coverage.out
|
|
```
|
|
|
|
There is no Taskfile in this package; it is small enough that direct `go` invocations suffice. The parent workspace (`/Users/snider/Code/host-uk/core`) uses Task for cross-repo operations.
|
|
|
|
## Go Workspace
|
|
|
|
This package is part of the `host-uk/core` Go workspace. After adding or changing module dependencies:
|
|
|
|
```bash
|
|
go work sync
|
|
```
|
|
|
|
The workspace root is `/Users/snider/Code/host-uk/core`. The workspace file (`go.work`) includes this module alongside `cmd/core-gui`, `cmd/bugseti`, and others.
|
|
|
|
## Module Path
|
|
|
|
```
|
|
forge.lthn.ai/core/go-inference
|
|
```
|
|
|
|
Import it in consumers:
|
|
|
|
```go
|
|
import "forge.lthn.ai/core/go-inference"
|
|
```
|
|
|
|
Remote: `ssh://git@forge.lthn.ai:2223/core/go-inference.git`
|
|
|
|
## Repository Layout
|
|
|
|
```
|
|
go-inference/
|
|
├── inference.go # TextModel, Backend, Token, Message, registry, LoadModel
|
|
├── options.go # GenerateConfig, LoadConfig, all With* options
|
|
├── discover.go # Discover() and DiscoveredModel
|
|
├── inference_test.go # Tests for registry, LoadModel, all types
|
|
├── options_test.go # Tests for GenerateConfig, LoadConfig, all options
|
|
├── discover_test.go # Tests for Discover()
|
|
├── go.mod
|
|
├── go.sum
|
|
├── CLAUDE.md # Agent instructions
|
|
├── README.md
|
|
└── docs/
|
|
├── architecture.md
|
|
├── development.md
|
|
└── history.md
|
|
```
|
|
|
|
## Test Patterns
|
|
|
|
Tests follow the `_Good`, `_Bad`, `_Ugly` suffix convention used across the Core Go ecosystem:
|
|
|
|
- `_Good` — happy path; confirms the documented behaviour works correctly
|
|
- `_Bad` — expected error conditions; confirms errors are returned with useful messages
|
|
- `_Ugly` — edge cases, panics, surprising-but-valid behaviour (e.g. last-option-wins, registry overwrites)
|
|
|
|
```go
|
|
func TestDefault_Good_Metal(t *testing.T) { ... }
|
|
func TestDefault_Bad_NoBackends(t *testing.T) { ... }
|
|
func TestDefault_Ugly_SkipsUnavailablePreferred(t *testing.T) { ... }
|
|
```
|
|
|
|
### Backend Registry Isolation
|
|
|
|
Tests that touch the global backend registry call `resetBackends(t)` first. This helper clears the map and is defined in `inference_test.go`:
|
|
|
|
```go
|
|
func resetBackends(t *testing.T) {
|
|
t.Helper()
|
|
backendsMu.Lock()
|
|
defer backendsMu.Unlock()
|
|
backends = map[string]Backend{}
|
|
}
|
|
```
|
|
|
|
Because `resetBackends` is in the `inference` package (not `inference_test`), it has direct access to the unexported `backends` map. Tests must not rely on registration order across test functions; each test that uses the registry must call `resetBackends` at the top.
|
|
|
|
### Stub Implementations
|
|
|
|
`inference_test.go` provides `stubBackend` and `stubTextModel` — minimal implementations of `Backend` and `TextModel` for use in registry and routing tests. These are in the `inference` package itself (not a separate `_test` package) to allow access to unexported fields.
|
|
|
|
When writing new tests, use the existing stubs rather than creating new ones unless you need behaviour the stubs do not support.
|
|
|
|
### Table-Driven Tests
|
|
|
|
Prefer table-driven tests for options and configuration variants. The existing `TestApplyGenerateOpts_Good`, `TestWithTemperature_Good`, and `TestDefault_Good_PriorityOrder` tests demonstrate the pattern:
|
|
|
|
```go
|
|
tests := []struct {
|
|
name string
|
|
val float32
|
|
want float32
|
|
}{
|
|
{"greedy", 0.0, 0.0},
|
|
{"low", 0.3, 0.3},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
cfg := ApplyGenerateOpts([]GenerateOption{WithTemperature(tt.val)})
|
|
assert.InDelta(t, tt.want, cfg.Temperature, 0.0001)
|
|
})
|
|
}
|
|
```
|
|
|
|
### Assertions
|
|
|
|
Use `testify/assert` and `testify/require`:
|
|
|
|
- `require` for preconditions where failure makes subsequent assertions meaningless (e.g. `require.NoError(t, err)` before using the returned value)
|
|
- `assert` for all other checks
|
|
- `assert.InDelta` for float32/float64 comparisons (never `==`)
|
|
|
|
## Coding Standards
|
|
|
|
### Language
|
|
|
|
UK English throughout: colour, organisation, centre, licence (noun), serialise, recognise. American spellings are not accepted in comments, documentation, or error messages.
|
|
|
|
### Formatting
|
|
|
|
Standard `gofmt` formatting. No custom style rules. Run `gofmt -w .` or `go fmt ./...` before committing.
|
|
|
|
### Error Messages
|
|
|
|
Error strings start with the package name and a colon, lowercase, no trailing period:
|
|
|
|
```go
|
|
fmt.Errorf("inference: no backends registered (import a backend package)")
|
|
fmt.Errorf("inference: backend %q not registered", cfg.Backend)
|
|
fmt.Errorf("inference: backend %q not available on this hardware", cfg.Backend)
|
|
```
|
|
|
|
This convention matches the Go standard library and makes `errors.Is`/`errors.As` wrapping straightforward.
|
|
|
|
### Strict Types
|
|
|
|
All parameters and return types are explicitly typed. No `interface{}` or `any` outside of test helpers where unavoidable.
|
|
|
|
### Dependencies
|
|
|
|
No new external dependencies may be added to the production code. The `go.mod` `require` block must remain stdlib-only for non-test code. `testify` is the only permitted test dependency.
|
|
|
|
If you find yourself wanting an external library, reconsider the approach. This package is intentionally minimal.
|
|
|
|
### Licence Header
|
|
|
|
Every new `.go` file must carry the EUPL-1.2 licence header:
|
|
|
|
```go
|
|
// Copyright (c) Lethean Technologies Ltd. All rights reserved.
|
|
// SPDX-License-Identifier: EUPL-1.2
|
|
```
|
|
|
|
Existing files without this header will be updated in a future housekeeping pass.
|
|
|
|
## Commit Guidelines
|
|
|
|
Use conventional commits:
|
|
|
|
```
|
|
type(scope): short imperative description
|
|
|
|
Longer explanation if needed. UK English. Wrap at 72 characters.
|
|
```
|
|
|
|
Types: `feat`, `fix`, `test`, `docs`, `refactor`, `chore`
|
|
|
|
Scope: `inference`, `options`, `discover`, or omit for cross-cutting changes.
|
|
|
|
Examples:
|
|
|
|
```
|
|
feat(inference): add WithParallelSlots load option
|
|
fix(discover): handle config.json with invalid JSON gracefully
|
|
test(options): add table-driven tests for WithTopP
|
|
docs: expand architecture section on registry priority
|
|
```
|
|
|
|
Always include the co-author trailer:
|
|
|
|
```
|
|
Co-Authored-By: Virgil <virgil@lethean.io>
|
|
```
|
|
|
|
## Implementing a Backend
|
|
|
|
To implement a new backend (e.g. `go-vulkan` for cross-platform GPU inference):
|
|
|
|
1. Import `forge.lthn.ai/core/go-inference` in the new module.
|
|
2. Implement `inference.Backend`:
|
|
|
|
```go
|
|
type vulkanBackend struct{}
|
|
|
|
func (b *vulkanBackend) Name() string { return "vulkan" }
|
|
|
|
func (b *vulkanBackend) Available() bool {
|
|
// Check whether Vulkan runtime is present on this host.
|
|
return vulkan.IsAvailable()
|
|
}
|
|
|
|
func (b *vulkanBackend) LoadModel(path string, opts ...inference.LoadOption) (inference.TextModel, error) {
|
|
cfg := inference.ApplyLoadOpts(opts)
|
|
// Load model using cfg.ContextLen, cfg.GPULayers, etc.
|
|
return &vulkanModel{...}, nil
|
|
}
|
|
```
|
|
|
|
3. Implement `inference.TextModel` (all nine methods).
|
|
4. Register in `init()`, guarded by the appropriate build tag:
|
|
|
|
```go
|
|
//go:build linux && (amd64 || arm64)
|
|
|
|
func init() { inference.Register(&vulkanBackend{}) }
|
|
```
|
|
|
|
5. Write stub-based tests to confirm the backend registers and `LoadModel` routes correctly without requiring real GPU hardware in CI.
|
|
|
|
## Extending the Interface
|
|
|
|
Before adding a method to `TextModel` or `Backend`, consider:
|
|
|
|
- Do two or more existing consumers require this capability right now?
|
|
- Can the capability be expressed as a separate interface that embeds `TextModel`?
|
|
- Will adding this method break existing backend implementations that do not yet provide it?
|
|
|
|
If the answer to the first question is no, defer the addition. If a separate interface is sufficient, prefer that approach. See `docs/architecture.md` for the stability contract.
|
|
|
|
When a new method is genuinely necessary, coordinate with the owners of go-mlx, go-rocm, and go-ml before merging, since all three must implement the new method simultaneously or the interface will be broken at build time.
|