Compare commits
2 commits
feature/de
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 544a3bf5ad | |||
|
|
fa998619dc |
13 changed files with 790 additions and 81 deletions
|
|
@ -1,68 +0,0 @@
|
|||
# Dependency and Supply Chain Audit Report
|
||||
|
||||
## 1. Dependency Analysis
|
||||
|
||||
### 1.1. Direct Dependencies
|
||||
|
||||
- **Go:** The project uses Go version `1.23`, as specified in the `go.mod` file. There are no direct Go module dependencies.
|
||||
- **npm:** The WebAssembly component of the project, located in `npm/poindexter-wasm`, has no direct npm dependencies listed in its `package.json` file.
|
||||
|
||||
### 1.2. Transitive Dependencies
|
||||
|
||||
- **Go:** Since there are no direct dependencies, there are no transitive Go dependencies. This was confirmed by running `go mod why -m all`.
|
||||
- **npm:** An `npm audit` was performed, and it confirmed that there are no transitive dependencies.
|
||||
|
||||
### 1.3. License Compliance
|
||||
|
||||
- The project itself is licensed under the MIT license.
|
||||
- Since there are no external dependencies, there are no third-party licenses to track or comply with.
|
||||
|
||||
## 2. Lock Files
|
||||
|
||||
- **Go:** The `go.mod` file is present, but since there are no dependencies, a `go.sum` file is not generated.
|
||||
- **npm:** A `package-lock.json` file has been added to the repository to ensure reproducible builds, although there are currently no dependencies.
|
||||
|
||||
## 3. Supply Chain Risks
|
||||
|
||||
### 3.1. Package Sources
|
||||
|
||||
- **Go:** The project does not use any external Go modules.
|
||||
- **npm:** The project does not use any external npm packages.
|
||||
|
||||
### 3.2. Build Process
|
||||
|
||||
- The build process is managed by a `Makefile` and automated with GitHub Actions.
|
||||
- The CI/CD pipeline, defined in `.github/workflows/ci.yml` and `.github/workflows/release.yml`, is comprehensive and includes:
|
||||
- Linting (`golangci-lint`)
|
||||
- Vetting (`go vet`)
|
||||
- Testing (including race detection)
|
||||
- Code coverage analysis
|
||||
- Vulnerability scanning (`govulncheck`)
|
||||
- WebAssembly build and smoke testing
|
||||
- Releases are automated using `goreleaser`, which helps ensure a consistent and reproducible build process.
|
||||
|
||||
## 4. Vulnerability Analysis
|
||||
|
||||
A vulnerability scan was performed using `govulncheck`. The scan identified 13 vulnerabilities in the Go standard library for the version used in this project (`1.23`).
|
||||
|
||||
### 4.1. Identified Vulnerabilities
|
||||
|
||||
| CVE ID | Severity | Description | Remediation Priority |
|
||||
|-----------------|----------|-----------------------------------------------------------------------------|----------------------|
|
||||
| `GO-2026-4340` | High | Handshake messages may be processed at the incorrect encryption level | High |
|
||||
| `GO-2025-4175` | Medium | Improper application of excluded DNS name constraints | Medium |
|
||||
| `GO-2025-4155` | Medium | Excessive resource consumption when printing error string for host cert | Medium |
|
||||
| `GO-2025-4013` | Medium | Panic when validating certificates with DSA public keys | Medium |
|
||||
| `GO-2025-4012` | Medium | Lack of limit when parsing cookies can cause memory exhaustion | Medium |
|
||||
| `GO-2025-4011` | Medium | Parsing DER payload can cause memory exhaustion | Medium |
|
||||
| `GO-2025-4010` | Medium | Insufficient validation of bracketed IPv6 hostnames | Medium |
|
||||
| `GO-2025-4009` | Medium | Quadratic complexity when parsing some invalid inputs | Medium |
|
||||
| `GO-2025-4008` | Medium | ALPN negotiation error contains attacker controlled information | Medium |
|
||||
| `GO-2025-4007` | Medium | Quadratic complexity when checking name constraints | Medium |
|
||||
| `GO-2025-3751` | Medium | Sensitive headers not cleared on cross-origin redirect | Medium |
|
||||
| `GO-2025-3750` | Low | Inconsistent handling of O_CREATE\|O_EXCL on Unix and Windows | Low |
|
||||
| `GO-2025-3749` | Low | Usage of ExtKeyUsageAny disables policy validation | Low |
|
||||
|
||||
### 4.2. Remediation
|
||||
|
||||
The identified vulnerabilities are all in the Go standard library. The recommended remediation is to update the Go version to the latest stable release, which includes patches for these vulnerabilities. Given that some of these vulnerabilities are rated as "High" severity, this should be a high-priority action.
|
||||
46
docs/plans/2026-02-16-math-expansion-design.md
Normal file
46
docs/plans/2026-02-16-math-expansion-design.md
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
# Poindexter Math Expansion
|
||||
|
||||
**Date:** 2026-02-16
|
||||
**Status:** Approved
|
||||
|
||||
## Context
|
||||
|
||||
Poindexter serves as the math pillar (alongside Borg=data, Enchantrix=encryption) in the Lethean ecosystem. It currently provides KD-Tree spatial queries, 5 distance metrics, sorting utilities, and normalization helpers.
|
||||
|
||||
Analysis of math operations scattered across core/go, core/go-ai, and core/mining revealed common patterns that Poindexter should centralize: descriptive statistics, scaling/interpolation, approximate equality, weighted scoring, and signal generation.
|
||||
|
||||
## New Modules
|
||||
|
||||
### stats.go — Descriptive statistics
|
||||
Sum, Mean, Variance, StdDev, MinMax, IsUnderrepresented.
|
||||
Consumers: ml/coverage.go, lab/handler/chart.go
|
||||
|
||||
### scale.go — Normalization and interpolation
|
||||
Lerp, InverseLerp, Remap, RoundToN, Clamp, MinMaxScale.
|
||||
Consumers: lab/handler/chart.go, i18n/numbers.go
|
||||
|
||||
### epsilon.go — Approximate equality
|
||||
ApproxEqual, ApproxZero.
|
||||
Consumers: ml/exact.go
|
||||
|
||||
### score.go — Weighted composite scoring
|
||||
Factor type, WeightedScore, Ratio, Delta, DeltaPercent.
|
||||
Consumers: ml/heuristic.go, ml/compare.go
|
||||
|
||||
### signal.go — Time-series primitives
|
||||
RampUp, SineWave, Oscillate, Noise (seeded RNG).
|
||||
Consumers: mining/simulated_miner.go
|
||||
|
||||
## Constraints
|
||||
|
||||
- Zero external dependencies (WASM-compilable)
|
||||
- Pure Go, stdlib only (math, math/rand)
|
||||
- Same package (`poindexter`), flat structure
|
||||
- Table-driven tests for every function
|
||||
- No changes to existing files
|
||||
|
||||
## Not In Scope
|
||||
|
||||
- MLX tensor ops (hardware-accelerated, stays in go-ai)
|
||||
- DNS tools migration to go-netops (separate PR)
|
||||
- gonum backend integration (future work)
|
||||
14
epsilon.go
Normal file
14
epsilon.go
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
package poindexter
|
||||
|
||||
import "math"
|
||||
|
||||
// ApproxEqual returns true if the absolute difference between a and b
|
||||
// is less than epsilon.
|
||||
func ApproxEqual(a, b, epsilon float64) bool {
|
||||
return math.Abs(a-b) < epsilon
|
||||
}
|
||||
|
||||
// ApproxZero returns true if the absolute value of v is less than epsilon.
|
||||
func ApproxZero(v, epsilon float64) bool {
|
||||
return math.Abs(v) < epsilon
|
||||
}
|
||||
50
epsilon_test.go
Normal file
50
epsilon_test.go
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
package poindexter
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestApproxEqual(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
a, b float64
|
||||
epsilon float64
|
||||
want bool
|
||||
}{
|
||||
{"equal", 1.0, 1.0, 0.01, true},
|
||||
{"close", 1.0, 1.005, 0.01, true},
|
||||
{"not_close", 1.0, 1.02, 0.01, false},
|
||||
{"negative", -1.0, -1.005, 0.01, true},
|
||||
{"zero", 0, 0.0001, 0.001, true},
|
||||
{"at_boundary", 1.0, 1.01, 0.01, false},
|
||||
{"large_epsilon", 100, 200, 150, true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := ApproxEqual(tt.a, tt.b, tt.epsilon)
|
||||
if got != tt.want {
|
||||
t.Errorf("ApproxEqual(%v, %v, %v) = %v, want %v", tt.a, tt.b, tt.epsilon, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApproxZero(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
v float64
|
||||
epsilon float64
|
||||
want bool
|
||||
}{
|
||||
{"zero", 0, 0.01, true},
|
||||
{"small_pos", 0.005, 0.01, true},
|
||||
{"small_neg", -0.005, 0.01, true},
|
||||
{"not_zero", 0.02, 0.01, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := ApproxZero(tt.v, tt.epsilon)
|
||||
if got != tt.want {
|
||||
t.Errorf("ApproxZero(%v, %v) = %v, want %v", tt.v, tt.epsilon, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
13
npm/poindexter-wasm/package-lock.json
generated
13
npm/poindexter-wasm/package-lock.json
generated
|
|
@ -1,13 +0,0 @@
|
|||
{
|
||||
"name": "@snider/poindexter-wasm",
|
||||
"version": "0.0.0-development",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@snider/poindexter-wasm",
|
||||
"version": "0.0.0-development",
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
61
scale.go
Normal file
61
scale.go
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
package poindexter
|
||||
|
||||
import "math"
|
||||
|
||||
// Lerp performs linear interpolation between a and b.
|
||||
// t=0 returns a, t=1 returns b, t=0.5 returns the midpoint.
|
||||
func Lerp(t, a, b float64) float64 {
|
||||
return a + t*(b-a)
|
||||
}
|
||||
|
||||
// InverseLerp returns where v falls between a and b as a fraction [0,1].
|
||||
// Returns 0 if a == b.
|
||||
func InverseLerp(v, a, b float64) float64 {
|
||||
if a == b {
|
||||
return 0
|
||||
}
|
||||
return (v - a) / (b - a)
|
||||
}
|
||||
|
||||
// Remap maps v from the range [inMin, inMax] to [outMin, outMax].
|
||||
// Equivalent to Lerp(InverseLerp(v, inMin, inMax), outMin, outMax).
|
||||
func Remap(v, inMin, inMax, outMin, outMax float64) float64 {
|
||||
return Lerp(InverseLerp(v, inMin, inMax), outMin, outMax)
|
||||
}
|
||||
|
||||
// RoundToN rounds f to n decimal places.
|
||||
func RoundToN(f float64, decimals int) float64 {
|
||||
mul := math.Pow(10, float64(decimals))
|
||||
return math.Round(f*mul) / mul
|
||||
}
|
||||
|
||||
// Clamp restricts v to the range [min, max].
|
||||
func Clamp(v, min, max float64) float64 {
|
||||
if v < min {
|
||||
return min
|
||||
}
|
||||
if v > max {
|
||||
return max
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// ClampInt restricts v to the range [min, max].
|
||||
func ClampInt(v, min, max int) int {
|
||||
if v < min {
|
||||
return min
|
||||
}
|
||||
if v > max {
|
||||
return max
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// MinMaxScale normalizes v into [0,1] given its range [min, max].
|
||||
// Returns 0 if min == max.
|
||||
func MinMaxScale(v, min, max float64) float64 {
|
||||
if min == max {
|
||||
return 0
|
||||
}
|
||||
return (v - min) / (max - min)
|
||||
}
|
||||
148
scale_test.go
Normal file
148
scale_test.go
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
package poindexter
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLerp(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
t_, a, b float64
|
||||
want float64
|
||||
}{
|
||||
{"start", 0, 10, 20, 10},
|
||||
{"end", 1, 10, 20, 20},
|
||||
{"mid", 0.5, 10, 20, 15},
|
||||
{"quarter", 0.25, 0, 100, 25},
|
||||
{"extrapolate", 2, 0, 10, 20},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := Lerp(tt.t_, tt.a, tt.b)
|
||||
if math.Abs(got-tt.want) > 1e-9 {
|
||||
t.Errorf("Lerp(%v, %v, %v) = %v, want %v", tt.t_, tt.a, tt.b, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInverseLerp(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
v, a, b float64
|
||||
want float64
|
||||
}{
|
||||
{"start", 10, 10, 20, 0},
|
||||
{"end", 20, 10, 20, 1},
|
||||
{"mid", 15, 10, 20, 0.5},
|
||||
{"equal_range", 5, 5, 5, 0},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := InverseLerp(tt.v, tt.a, tt.b)
|
||||
if math.Abs(got-tt.want) > 1e-9 {
|
||||
t.Errorf("InverseLerp(%v, %v, %v) = %v, want %v", tt.v, tt.a, tt.b, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemap(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
v, inMin, inMax, outMin, outMax float64
|
||||
want float64
|
||||
}{
|
||||
{"identity", 5, 0, 10, 0, 10, 5},
|
||||
{"scale_up", 5, 0, 10, 0, 100, 50},
|
||||
{"reverse", 3, 0, 10, 10, 0, 7},
|
||||
{"offset", 0, 0, 1, 100, 200, 100},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := Remap(tt.v, tt.inMin, tt.inMax, tt.outMin, tt.outMax)
|
||||
if math.Abs(got-tt.want) > 1e-9 {
|
||||
t.Errorf("Remap = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoundToN(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
f float64
|
||||
decimals int
|
||||
want float64
|
||||
}{
|
||||
{"zero_dec", 3.456, 0, 3},
|
||||
{"one_dec", 3.456, 1, 3.5},
|
||||
{"two_dec", 3.456, 2, 3.46},
|
||||
{"three_dec", 3.4564, 3, 3.456},
|
||||
{"negative", -2.555, 2, -2.56},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := RoundToN(tt.f, tt.decimals)
|
||||
if math.Abs(got-tt.want) > 1e-9 {
|
||||
t.Errorf("RoundToN(%v, %v) = %v, want %v", tt.f, tt.decimals, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClamp(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
v, min, max float64
|
||||
want float64
|
||||
}{
|
||||
{"within", 5, 0, 10, 5},
|
||||
{"below", -5, 0, 10, 0},
|
||||
{"above", 15, 0, 10, 10},
|
||||
{"at_min", 0, 0, 10, 0},
|
||||
{"at_max", 10, 0, 10, 10},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := Clamp(tt.v, tt.min, tt.max)
|
||||
if got != tt.want {
|
||||
t.Errorf("Clamp(%v, %v, %v) = %v, want %v", tt.v, tt.min, tt.max, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClampInt(t *testing.T) {
|
||||
if got := ClampInt(5, 0, 10); got != 5 {
|
||||
t.Errorf("ClampInt(5, 0, 10) = %v, want 5", got)
|
||||
}
|
||||
if got := ClampInt(-1, 0, 10); got != 0 {
|
||||
t.Errorf("ClampInt(-1, 0, 10) = %v, want 0", got)
|
||||
}
|
||||
if got := ClampInt(15, 0, 10); got != 10 {
|
||||
t.Errorf("ClampInt(15, 0, 10) = %v, want 10", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMinMaxScale(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
v, min, max float64
|
||||
want float64
|
||||
}{
|
||||
{"mid", 5, 0, 10, 0.5},
|
||||
{"at_min", 0, 0, 10, 0},
|
||||
{"at_max", 10, 0, 10, 1},
|
||||
{"equal_range", 5, 5, 5, 0},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := MinMaxScale(tt.v, tt.min, tt.max)
|
||||
if math.Abs(got-tt.want) > 1e-9 {
|
||||
t.Errorf("MinMaxScale(%v, %v, %v) = %v, want %v", tt.v, tt.min, tt.max, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
40
score.go
Normal file
40
score.go
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
package poindexter
|
||||
|
||||
// Factor is a value–weight pair for composite scoring.
|
||||
type Factor struct {
|
||||
Value float64
|
||||
Weight float64
|
||||
}
|
||||
|
||||
// WeightedScore computes the weighted sum of factors.
|
||||
// Each factor contributes Value * Weight to the total.
|
||||
// Returns 0 for empty slices.
|
||||
func WeightedScore(factors []Factor) float64 {
|
||||
var total float64
|
||||
for _, f := range factors {
|
||||
total += f.Value * f.Weight
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
// Ratio returns part/whole safely. Returns 0 if whole is 0.
|
||||
func Ratio(part, whole float64) float64 {
|
||||
if whole == 0 {
|
||||
return 0
|
||||
}
|
||||
return part / whole
|
||||
}
|
||||
|
||||
// Delta returns the difference new_ - old.
|
||||
func Delta(old, new_ float64) float64 {
|
||||
return new_ - old
|
||||
}
|
||||
|
||||
// DeltaPercent returns the percentage change from old to new_.
|
||||
// Returns 0 if old is 0.
|
||||
func DeltaPercent(old, new_ float64) float64 {
|
||||
if old == 0 {
|
||||
return 0
|
||||
}
|
||||
return (new_ - old) / old * 100
|
||||
}
|
||||
86
score_test.go
Normal file
86
score_test.go
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
package poindexter
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWeightedScore(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
factors []Factor
|
||||
want float64
|
||||
}{
|
||||
{"empty", nil, 0},
|
||||
{"single", []Factor{{Value: 5, Weight: 2}}, 10},
|
||||
{"multiple", []Factor{
|
||||
{Value: 3, Weight: 2}, // 6
|
||||
{Value: 1, Weight: -5}, // -5
|
||||
}, 1},
|
||||
{"lek_heuristic", []Factor{
|
||||
{Value: 2, Weight: 2}, // engagement × 2
|
||||
{Value: 1, Weight: 3}, // creative × 3
|
||||
{Value: 1, Weight: 1.5}, // first person × 1.5
|
||||
{Value: 3, Weight: -5}, // compliance × -5
|
||||
}, -6.5},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := WeightedScore(tt.factors)
|
||||
if math.Abs(got-tt.want) > 1e-9 {
|
||||
t.Errorf("WeightedScore = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRatio(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
part, whole float64
|
||||
want float64
|
||||
}{
|
||||
{"half", 5, 10, 0.5},
|
||||
{"full", 10, 10, 1},
|
||||
{"zero_whole", 5, 0, 0},
|
||||
{"zero_part", 0, 10, 0},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := Ratio(tt.part, tt.whole)
|
||||
if math.Abs(got-tt.want) > 1e-9 {
|
||||
t.Errorf("Ratio(%v, %v) = %v, want %v", tt.part, tt.whole, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDelta(t *testing.T) {
|
||||
if got := Delta(10, 15); got != 5 {
|
||||
t.Errorf("Delta(10, 15) = %v, want 5", got)
|
||||
}
|
||||
if got := Delta(15, 10); got != -5 {
|
||||
t.Errorf("Delta(15, 10) = %v, want -5", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeltaPercent(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
old, new_ float64
|
||||
want float64
|
||||
}{
|
||||
{"increase", 100, 150, 50},
|
||||
{"decrease", 100, 75, -25},
|
||||
{"zero_old", 0, 10, 0},
|
||||
{"no_change", 50, 50, 0},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := DeltaPercent(tt.old, tt.new_)
|
||||
if math.Abs(got-tt.want) > 1e-9 {
|
||||
t.Errorf("DeltaPercent(%v, %v) = %v, want %v", tt.old, tt.new_, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
57
signal.go
Normal file
57
signal.go
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
package poindexter
|
||||
|
||||
import (
|
||||
"math"
|
||||
"math/rand"
|
||||
)
|
||||
|
||||
// RampUp returns a linear ramp from 0 to 1 over the given duration.
|
||||
// The result is clamped to [0, 1].
|
||||
func RampUp(elapsed, duration float64) float64 {
|
||||
if duration <= 0 {
|
||||
return 1
|
||||
}
|
||||
return Clamp(elapsed/duration, 0, 1)
|
||||
}
|
||||
|
||||
// SineWave returns a sine value with the given period and amplitude.
|
||||
// Output range is [-amplitude, amplitude].
|
||||
func SineWave(t, period, amplitude float64) float64 {
|
||||
if period == 0 {
|
||||
return 0
|
||||
}
|
||||
return math.Sin(t/period*2*math.Pi) * amplitude
|
||||
}
|
||||
|
||||
// Oscillate modulates a base value with a sine wave.
|
||||
// Returns base * (1 + sin(t/period*2π) * amplitude).
|
||||
func Oscillate(base, t, period, amplitude float64) float64 {
|
||||
if period == 0 {
|
||||
return base
|
||||
}
|
||||
return base * (1 + math.Sin(t/period*2*math.Pi)*amplitude)
|
||||
}
|
||||
|
||||
// Noise generates seeded pseudo-random values.
|
||||
type Noise struct {
|
||||
rng *rand.Rand
|
||||
}
|
||||
|
||||
// NewNoise creates a seeded noise generator.
|
||||
func NewNoise(seed int64) *Noise {
|
||||
return &Noise{rng: rand.New(rand.NewSource(seed))}
|
||||
}
|
||||
|
||||
// Float64 returns a random value in [-variance, variance].
|
||||
func (n *Noise) Float64(variance float64) float64 {
|
||||
return (n.rng.Float64() - 0.5) * 2 * variance
|
||||
}
|
||||
|
||||
// Int returns a random integer in [0, max).
|
||||
// Returns 0 if max <= 0.
|
||||
func (n *Noise) Int(max int) int {
|
||||
if max <= 0 {
|
||||
return 0
|
||||
}
|
||||
return n.rng.Intn(max)
|
||||
}
|
||||
103
signal_test.go
Normal file
103
signal_test.go
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
package poindexter
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRampUp(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
elapsed, duration float64
|
||||
want float64
|
||||
}{
|
||||
{"start", 0, 30, 0},
|
||||
{"mid", 15, 30, 0.5},
|
||||
{"end", 30, 30, 1},
|
||||
{"over", 60, 30, 1},
|
||||
{"negative", -5, 30, 0},
|
||||
{"zero_duration", 10, 0, 1},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := RampUp(tt.elapsed, tt.duration)
|
||||
if math.Abs(got-tt.want) > 1e-9 {
|
||||
t.Errorf("RampUp(%v, %v) = %v, want %v", tt.elapsed, tt.duration, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSineWave(t *testing.T) {
|
||||
// At t=0, sin(0) = 0
|
||||
if got := SineWave(0, 10, 5); math.Abs(got) > 1e-9 {
|
||||
t.Errorf("SineWave(0, 10, 5) = %v, want 0", got)
|
||||
}
|
||||
// At t=period/4, sin(π/2) = 1, so result = amplitude
|
||||
got := SineWave(2.5, 10, 5)
|
||||
if math.Abs(got-5) > 1e-9 {
|
||||
t.Errorf("SineWave(2.5, 10, 5) = %v, want 5", got)
|
||||
}
|
||||
// Zero period returns 0
|
||||
if got := SineWave(5, 0, 5); got != 0 {
|
||||
t.Errorf("SineWave(5, 0, 5) = %v, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOscillate(t *testing.T) {
|
||||
// At t=0, sin(0)=0, so result = base * (1 + 0) = base
|
||||
got := Oscillate(100, 0, 10, 0.05)
|
||||
if math.Abs(got-100) > 1e-9 {
|
||||
t.Errorf("Oscillate(100, 0, 10, 0.05) = %v, want 100", got)
|
||||
}
|
||||
// At t=period/4, sin=1, so result = base * (1 + amplitude)
|
||||
got = Oscillate(100, 2.5, 10, 0.05)
|
||||
if math.Abs(got-105) > 1e-9 {
|
||||
t.Errorf("Oscillate(100, 2.5, 10, 0.05) = %v, want 105", got)
|
||||
}
|
||||
// Zero period returns base
|
||||
if got := Oscillate(100, 5, 0, 0.05); got != 100 {
|
||||
t.Errorf("Oscillate(100, 5, 0, 0.05) = %v, want 100", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoise(t *testing.T) {
|
||||
n := NewNoise(42)
|
||||
|
||||
// Float64 should be within [-variance, variance]
|
||||
for i := 0; i < 1000; i++ {
|
||||
v := n.Float64(0.1)
|
||||
if v < -0.1 || v > 0.1 {
|
||||
t.Fatalf("Float64(0.1) = %v, outside [-0.1, 0.1]", v)
|
||||
}
|
||||
}
|
||||
|
||||
// Int should be within [0, max)
|
||||
n2 := NewNoise(42)
|
||||
for i := 0; i < 1000; i++ {
|
||||
v := n2.Int(10)
|
||||
if v < 0 || v >= 10 {
|
||||
t.Fatalf("Int(10) = %v, outside [0, 10)", v)
|
||||
}
|
||||
}
|
||||
|
||||
// Int with zero max returns 0
|
||||
if got := n.Int(0); got != 0 {
|
||||
t.Errorf("Int(0) = %v, want 0", got)
|
||||
}
|
||||
if got := n.Int(-1); got != 0 {
|
||||
t.Errorf("Int(-1) = %v, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoiseDeterministic(t *testing.T) {
|
||||
n1 := NewNoise(123)
|
||||
n2 := NewNoise(123)
|
||||
for i := 0; i < 100; i++ {
|
||||
a := n1.Float64(1.0)
|
||||
b := n2.Float64(1.0)
|
||||
if a != b {
|
||||
t.Fatalf("iteration %d: different values for same seed: %v != %v", i, a, b)
|
||||
}
|
||||
}
|
||||
}
|
||||
63
stats.go
Normal file
63
stats.go
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
package poindexter
|
||||
|
||||
import "math"
|
||||
|
||||
// Sum returns the sum of all values. Returns 0 for empty slices.
|
||||
func Sum(data []float64) float64 {
|
||||
var s float64
|
||||
for _, v := range data {
|
||||
s += v
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Mean returns the arithmetic mean. Returns 0 for empty slices.
|
||||
func Mean(data []float64) float64 {
|
||||
if len(data) == 0 {
|
||||
return 0
|
||||
}
|
||||
return Sum(data) / float64(len(data))
|
||||
}
|
||||
|
||||
// Variance returns the population variance. Returns 0 for empty slices.
|
||||
func Variance(data []float64) float64 {
|
||||
if len(data) == 0 {
|
||||
return 0
|
||||
}
|
||||
m := Mean(data)
|
||||
var ss float64
|
||||
for _, v := range data {
|
||||
d := v - m
|
||||
ss += d * d
|
||||
}
|
||||
return ss / float64(len(data))
|
||||
}
|
||||
|
||||
// StdDev returns the population standard deviation.
|
||||
func StdDev(data []float64) float64 {
|
||||
return math.Sqrt(Variance(data))
|
||||
}
|
||||
|
||||
// MinMax returns the minimum and maximum values.
|
||||
// Returns (0, 0) for empty slices.
|
||||
func MinMax(data []float64) (min, max float64) {
|
||||
if len(data) == 0 {
|
||||
return 0, 0
|
||||
}
|
||||
min, max = data[0], data[0]
|
||||
for _, v := range data[1:] {
|
||||
if v < min {
|
||||
min = v
|
||||
}
|
||||
if v > max {
|
||||
max = v
|
||||
}
|
||||
}
|
||||
return min, max
|
||||
}
|
||||
|
||||
// IsUnderrepresented returns true if val is below threshold fraction of avg.
|
||||
// For example, IsUnderrepresented(3, 10, 0.5) returns true because 3 < 10*0.5.
|
||||
func IsUnderrepresented(val, avg, threshold float64) bool {
|
||||
return val < avg*threshold
|
||||
}
|
||||
122
stats_test.go
Normal file
122
stats_test.go
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
package poindexter
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSum(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
data []float64
|
||||
want float64
|
||||
}{
|
||||
{"empty", nil, 0},
|
||||
{"single", []float64{5}, 5},
|
||||
{"multiple", []float64{1, 2, 3, 4, 5}, 15},
|
||||
{"negative", []float64{-1, -2, 3}, 0},
|
||||
{"floats", []float64{0.1, 0.2, 0.3}, 0.6},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := Sum(tt.data)
|
||||
if math.Abs(got-tt.want) > 1e-9 {
|
||||
t.Errorf("Sum(%v) = %v, want %v", tt.data, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMean(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
data []float64
|
||||
want float64
|
||||
}{
|
||||
{"empty", nil, 0},
|
||||
{"single", []float64{5}, 5},
|
||||
{"multiple", []float64{2, 4, 6}, 4},
|
||||
{"floats", []float64{1.5, 2.5}, 2},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := Mean(tt.data)
|
||||
if math.Abs(got-tt.want) > 1e-9 {
|
||||
t.Errorf("Mean(%v) = %v, want %v", tt.data, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestVariance(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
data []float64
|
||||
want float64
|
||||
}{
|
||||
{"empty", nil, 0},
|
||||
{"constant", []float64{5, 5, 5}, 0},
|
||||
{"simple", []float64{2, 4, 4, 4, 5, 5, 7, 9}, 4},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := Variance(tt.data)
|
||||
if math.Abs(got-tt.want) > 1e-9 {
|
||||
t.Errorf("Variance(%v) = %v, want %v", tt.data, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStdDev(t *testing.T) {
|
||||
got := StdDev([]float64{2, 4, 4, 4, 5, 5, 7, 9})
|
||||
if math.Abs(got-2) > 1e-9 {
|
||||
t.Errorf("StdDev = %v, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMinMax(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
data []float64
|
||||
wantMin float64
|
||||
wantMax float64
|
||||
}{
|
||||
{"empty", nil, 0, 0},
|
||||
{"single", []float64{3}, 3, 3},
|
||||
{"ordered", []float64{1, 2, 3}, 1, 3},
|
||||
{"reversed", []float64{3, 2, 1}, 1, 3},
|
||||
{"negative", []float64{-5, 0, 5}, -5, 5},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
gotMin, gotMax := MinMax(tt.data)
|
||||
if gotMin != tt.wantMin || gotMax != tt.wantMax {
|
||||
t.Errorf("MinMax(%v) = (%v, %v), want (%v, %v)", tt.data, gotMin, gotMax, tt.wantMin, tt.wantMax)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsUnderrepresented(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
val float64
|
||||
avg float64
|
||||
threshold float64
|
||||
want bool
|
||||
}{
|
||||
{"below", 3, 10, 0.5, true},
|
||||
{"at", 5, 10, 0.5, false},
|
||||
{"above", 7, 10, 0.5, false},
|
||||
{"zero_avg", 0, 0, 0.5, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := IsUnderrepresented(tt.val, tt.avg, tt.threshold)
|
||||
if got != tt.want {
|
||||
t.Errorf("IsUnderrepresented(%v, %v, %v) = %v, want %v", tt.val, tt.avg, tt.threshold, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue