Files
neural-hunt/internal/settings/settings.go
jbergner 1a56dec079
All checks were successful
release-tag / release-image (push) Successful in 3m57s
RC-16
2026-08-14 14:19:16 +02:00

399 lines
17 KiB
Go

package settings
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"math"
"os"
"strconv"
"strings"
"sync"
"time"
)
type Runtime struct {
GuessMinIntervalSec int `json:"guess_min_interval_sec"`
ClientSubmitIntervalSec int `json:"client_submit_interval_sec"`
GuessLotteryWindowSec int `json:"guess_lottery_window_sec"`
GuessLotteryMaxAccepted int `json:"guess_lottery_max_accepted"`
BeaconHuntEnabled int `json:"beacon_hunt_enabled"`
BeaconBonusWeight int `json:"beacon_bonus_weight"`
SybilProofOfWorkBits int `json:"sybil_pow_bits"`
SybilWarmupSec int `json:"sybil_warmup_sec"`
OpenAIMaxCalls1H int `json:"openai_max_calls_1h"`
OpenAIMaxCalls24H int `json:"openai_max_calls_24h"`
OpenAIMaxCost24HUSD float64 `json:"openai_max_cost_24h_usd"`
OpenAIBudgetReserveUSD float64 `json:"openai_budget_reserve_usd"`
TaskRangeBits int `json:"task_range_bits"`
ActiveTaskCount int `json:"active_task_count"`
PresenceTTLSec int `json:"presence_ttl_sec"`
DefaultMaxNodes int `json:"default_max_nodes"`
PublicScorePrecision int `json:"public_score_precision"`
PlaceEnabled int `json:"place_enabled"`
PlaceWidth int `json:"place_width"`
PlaceHeight int `json:"place_height"`
PlacePointsPerScore int `json:"place_points_per_score"`
PlaceDrawPoints int `json:"place_draw_points"`
PlaceDrawBeaconMultiplier int `json:"place_draw_beacon_multiplier"`
PlaceTimePoints int `json:"place_time_points"`
PlaceTimeIntervalSec int `json:"place_time_interval_sec"`
PlaceTimeMaxGapSec int `json:"place_time_max_gap_sec"`
PlacePixelCost int `json:"place_pixel_cost"`
ArtifactPreset string `json:"artifact_preset"`
ArtifactProvider string `json:"artifact_provider"`
ArtifactModel string `json:"artifact_model"`
ArtifactPrompt string `json:"artifact_prompt"`
ArtifactNegativePrompt string `json:"artifact_negative_prompt"`
ArtifactWidth int `json:"artifact_width"`
ArtifactHeight int `json:"artifact_height"`
ArtifactSteps int `json:"artifact_steps"`
ArtifactQuality string `json:"artifact_quality"`
ArtifactRarityCommonPct float64 `json:"artifact_rarity_common_pct"`
ArtifactRarityUncommonPct float64 `json:"artifact_rarity_uncommon_pct"`
ArtifactRarityRarePct float64 `json:"artifact_rarity_rare_pct"`
ArtifactRarityUltraRarePct float64 `json:"artifact_rarity_ultra_rare_pct"`
ArtifactRaritySpecialIllustrationPct float64 `json:"artifact_rarity_special_illustration_pct"`
}
type Manager struct {
db *sql.DB
mu sync.RWMutex
v Runtime
}
func envInt(k string, def int) int {
if s := os.Getenv(k); s != "" {
if n, err := strconv.Atoi(s); err == nil {
return n
}
}
return def
}
func envString(k, def string) string {
if s := strings.TrimSpace(os.Getenv(k)); s != "" {
return s
}
return def
}
func envFloat(k string, def float64) float64 {
if s := strings.TrimSpace(os.Getenv(k)); s != "" {
if n, err := strconv.ParseFloat(s, 64); err == nil {
return n
}
}
return def
}
func Defaults() Runtime {
return Runtime{
GuessMinIntervalSec: envInt("DEFAULT_GUESS_MIN_INTERVAL_SEC", 10),
ClientSubmitIntervalSec: envInt("DEFAULT_CLIENT_SUBMIT_INTERVAL_SEC", 11),
GuessLotteryWindowSec: envInt("DEFAULT_GUESS_LOTTERY_WINDOW_SEC", 60),
GuessLotteryMaxAccepted: envInt("DEFAULT_GUESS_LOTTERY_MAX_ACCEPTED", 0),
BeaconHuntEnabled: envInt("DEFAULT_BEACON_HUNT_ENABLED", 0),
BeaconBonusWeight: envInt("DEFAULT_BEACON_BONUS_WEIGHT", 2),
SybilProofOfWorkBits: envInt("DEFAULT_SYBIL_POW_BITS", 15),
SybilWarmupSec: envInt("DEFAULT_SYBIL_WARMUP_SEC", 15),
OpenAIMaxCalls1H: envInt("DEFAULT_OPENAI_MAX_CALLS_1H", 20),
OpenAIMaxCalls24H: envInt("DEFAULT_OPENAI_MAX_CALLS_24H", 100),
OpenAIMaxCost24HUSD: envFloat("DEFAULT_OPENAI_MAX_COST_24H_USD", 5.00),
OpenAIBudgetReserveUSD: envFloat("DEFAULT_OPENAI_BUDGET_RESERVE_USD", 0.20),
TaskRangeBits: envInt("DEFAULT_TASK_RANGE_BITS", 28),
ActiveTaskCount: envInt("DEFAULT_ACTIVE_TASK_COUNT", 1),
PresenceTTLSec: envInt("DEFAULT_PRESENCE_TTL_SEC", 35),
DefaultMaxNodes: envInt("DEFAULT_MAX_NODES", 2000),
PublicScorePrecision: envInt("DEFAULT_PUBLIC_SCORE_PRECISION", 2),
PlaceEnabled: envInt("DEFAULT_PLACE_ENABLED", 1),
PlaceWidth: envInt("DEFAULT_PLACE_WIDTH", 256),
PlaceHeight: envInt("DEFAULT_PLACE_HEIGHT", 256),
PlacePointsPerScore: envInt("DEFAULT_PLACE_POINTS_PER_SCORE", 100),
PlaceDrawPoints: envInt("DEFAULT_PLACE_DRAW_POINTS", 25),
PlaceDrawBeaconMultiplier: envInt("DEFAULT_PLACE_DRAW_BEACON_MULTIPLIER", 1),
PlaceTimePoints: envInt("DEFAULT_PLACE_TIME_POINTS", 20),
PlaceTimeIntervalSec: envInt("DEFAULT_PLACE_TIME_INTERVAL_SEC", 60),
PlaceTimeMaxGapSec: envInt("DEFAULT_PLACE_TIME_MAX_GAP_SEC", 120),
PlacePixelCost: envInt("DEFAULT_PLACE_PIXEL_COST", 100),
ArtifactPreset: strings.ToLower(envString("ARTIFACT_PRESET", "raccoon_full_art_v1")),
ArtifactProvider: strings.ToLower(envString("ARTIFACT_PROVIDER", "openai")),
ArtifactModel: envString("ARTIFACT_MODEL", "gpt-image-2"),
ArtifactPrompt: envString("ARTIFACT_PROMPT", "Optional global creative override for legacy artifact providers. The raccoon_full_art_v1 preset uses its built-in structured master prompt and task-specific NFT instructions."),
ArtifactNegativePrompt: envString("ARTIFACT_NEGATIVE_PROMPT", "text, watermark, logo, signature, low quality, blurry, malformed"),
ArtifactWidth: envInt("ARTIFACT_WIDTH", 1024),
ArtifactHeight: envInt("ARTIFACT_HEIGHT", 1536),
ArtifactSteps: envInt("ARTIFACT_STEPS", 28),
ArtifactQuality: strings.ToLower(envString("ARTIFACT_QUALITY", "medium")),
ArtifactRarityCommonPct: envFloat("DEFAULT_ARTIFACT_RARITY_COMMON_PCT", 58.00),
ArtifactRarityUncommonPct: envFloat("DEFAULT_ARTIFACT_RARITY_UNCOMMON_PCT", 29.50),
ArtifactRarityRarePct: envFloat("DEFAULT_ARTIFACT_RARITY_RARE_PCT", 9.90),
ArtifactRarityUltraRarePct: envFloat("DEFAULT_ARTIFACT_RARITY_ULTRA_RARE_PCT", 2.25),
ArtifactRaritySpecialIllustrationPct: envFloat("DEFAULT_ARTIFACT_RARITY_SPECIAL_ILLUSTRATION_PCT", 0.35),
}
}
func New(ctx context.Context, db *sql.DB) (*Manager, error) {
m := &Manager{db: db, v: Defaults()}
b, _ := json.Marshal(m.v)
now := time.Now().UTC().UnixMilli()
_, err := db.ExecContext(ctx, `INSERT INTO settings(key,value,updated_at) VALUES('runtime',?,?) ON CONFLICT(key) DO NOTHING`, string(b), now)
if err != nil {
return nil, err
}
if err := m.Refresh(ctx); err != nil {
return nil, err
}
if err := m.migrateRIFTMediumDefault(ctx); err != nil {
return nil, err
}
return m, nil
}
// migrateRIFTMediumDefault upgrades deployments created by the first RIFT
// release, where high quality was hard-coded as the collection default. The
// migration runs once only; after that an operator can still deliberately set
// high (or low) without it being overwritten on restart.
func (m *Manager) migrateRIFTMediumDefault(ctx context.Context) error {
const marker = "migration_rift_medium_quality_v1"
var n int
if err := m.db.QueryRowContext(ctx, `SELECT count(*) FROM settings WHERE key=?`, marker).Scan(&n); err != nil {
return err
}
if n > 0 {
return nil
}
// A deployment that explicitly configured ARTIFACT_QUALITY keeps that
// operator choice. The migration only changes the former implicit RIFT
// default.
if strings.TrimSpace(os.Getenv("ARTIFACT_QUALITY")) != "" {
_, err := m.db.ExecContext(ctx, `INSERT INTO settings(key,value,updated_at) VALUES(?,?,?) ON CONFLICT(key) DO NOTHING`, marker, "explicit-env-preserved", time.Now().UTC().UnixMilli())
return err
}
v := m.Get()
if strings.EqualFold(strings.TrimSpace(v.ArtifactPreset), "raccoon_full_art_v1") && strings.EqualFold(strings.TrimSpace(v.ArtifactQuality), "high") {
v.ArtifactQuality = "medium"
b, _ := json.Marshal(v)
now := time.Now().UTC().UnixMilli()
if _, err := m.db.ExecContext(ctx, `UPDATE settings SET value=?,updated_at=? WHERE key='runtime'`, string(b), now); err != nil {
return err
}
m.mu.Lock()
m.v = v
m.mu.Unlock()
}
_, err := m.db.ExecContext(ctx, `INSERT INTO settings(key,value,updated_at) VALUES(?,?,?) ON CONFLICT(key) DO NOTHING`, marker, "done", time.Now().UTC().UnixMilli())
return err
}
func (m *Manager) Get() Runtime {
m.mu.RLock()
defer m.mu.RUnlock()
return m.v
}
func (m *Manager) Refresh(ctx context.Context) error {
var raw string
if err := m.db.QueryRowContext(ctx, `SELECT value FROM settings WHERE key='runtime'`).Scan(&raw); err != nil {
return err
}
// Start from current defaults so databases created by older Neural Hunt
// versions automatically receive newly introduced settings.
var stored map[string]json.RawMessage
_ = json.Unmarshal([]byte(raw), &stored)
_, hadArtifactPreset := stored["artifact_preset"]
v := Defaults()
if err := json.Unmarshal([]byte(raw), &v); err != nil {
return err
}
// One-time upgrade for the bundled pre-collection database: older builds
// shipped the offline square SVG preset. The new collection is designed to
// work after only OPENAI_API_KEY + model are configured, so that exact legacy
// default is migrated to OpenAI portrait generation automatically. Explicit
// non-local provider choices are preserved.
if !hadArtifactPreset {
if strings.EqualFold(v.ArtifactProvider, "local") && v.ArtifactWidth == 1024 && v.ArtifactHeight == 1024 {
v.ArtifactPreset = "raccoon_full_art_v1"
v.ArtifactProvider = "openai"
v.ArtifactWidth = 1024
v.ArtifactHeight = 1536
v.ArtifactQuality = "medium"
} else {
// Existing deployments that deliberately chose another provider keep
// their old behavior until they explicitly opt into the collection preset.
v.ArtifactPreset = "legacy"
}
if b, err := json.Marshal(v); err == nil {
_, _ = m.db.ExecContext(ctx, `UPDATE settings SET value=?,updated_at=? WHERE key='runtime'`, string(b), time.Now().UTC().UnixMilli())
}
}
if err := Validate(v); err != nil {
return err
}
m.mu.Lock()
m.v = v
m.mu.Unlock()
return nil
}
func (m *Manager) Update(ctx context.Context, v Runtime) error {
if err := Validate(v); err != nil {
return err
}
b, _ := json.Marshal(v)
if _, err := m.db.ExecContext(ctx, `UPDATE settings SET value=?,updated_at=? WHERE key='runtime'`, string(b), time.Now().UTC().UnixMilli()); err != nil {
return err
}
m.mu.Lock()
m.v = v
m.mu.Unlock()
return nil
}
func Validate(v Runtime) error {
if v.GuessMinIntervalSec < 1 || v.GuessMinIntervalSec > 3600 {
return fmt.Errorf("guess_min_interval_sec must be 1..3600")
}
if v.ClientSubmitIntervalSec <= v.GuessMinIntervalSec || v.ClientSubmitIntervalSec > 7200 {
return fmt.Errorf("client_submit_interval_sec must be > guess_min_interval_sec and <= 7200")
}
if v.GuessLotteryWindowSec < 1 || v.GuessLotteryWindowSec > 3600 {
return fmt.Errorf("guess_lottery_window_sec must be 1..3600")
}
if v.GuessLotteryMaxAccepted < 0 || v.GuessLotteryMaxAccepted > 100000 {
return fmt.Errorf("guess_lottery_max_accepted must be 0..100000 (0 disables lottery)")
}
if v.BeaconHuntEnabled < 0 || v.BeaconHuntEnabled > 1 {
return fmt.Errorf("beacon_hunt_enabled must be 0 or 1")
}
if v.BeaconBonusWeight < 1 || v.BeaconBonusWeight > 10 {
return fmt.Errorf("beacon_bonus_weight must be 1..10")
}
if v.SybilProofOfWorkBits < 0 || v.SybilProofOfWorkBits > 22 {
return fmt.Errorf("sybil_pow_bits must be 0..22")
}
if v.SybilWarmupSec < 0 || v.SybilWarmupSec > 86400 {
return fmt.Errorf("sybil_warmup_sec must be 0..86400")
}
if v.OpenAIMaxCalls1H < 0 || v.OpenAIMaxCalls1H > 100000 {
return fmt.Errorf("openai_max_calls_1h must be 0..100000")
}
if v.OpenAIMaxCalls24H < 0 || v.OpenAIMaxCalls24H > 1000000 {
return fmt.Errorf("openai_max_calls_24h must be 0..1000000")
}
if v.OpenAIMaxCost24HUSD < 0 || v.OpenAIMaxCost24HUSD > 100000 {
return fmt.Errorf("openai_max_cost_24h_usd must be 0..100000")
}
if v.OpenAIBudgetReserveUSD < 0 || v.OpenAIBudgetReserveUSD > 10000 {
return fmt.Errorf("openai_budget_reserve_usd must be 0..10000")
}
if v.TaskRangeBits < 8 || v.TaskRangeBits > 128 {
return fmt.Errorf("task_range_bits must be 8..128")
}
if v.ActiveTaskCount < 1 || v.ActiveTaskCount > 64 {
return fmt.Errorf("active_task_count must be 1..64")
}
if v.PresenceTTLSec < 15 || v.PresenceTTLSec > 600 {
return fmt.Errorf("presence_ttl_sec must be 15..600")
}
if v.DefaultMaxNodes < 50 || v.DefaultMaxNodes > 100000 {
return fmt.Errorf("default_max_nodes must be 50..100000")
}
if v.PublicScorePrecision < 0 || v.PublicScorePrecision > 6 {
return fmt.Errorf("public_score_precision must be 0..6")
}
if v.PlaceEnabled < 0 || v.PlaceEnabled > 1 {
return fmt.Errorf("place_enabled must be 0 or 1")
}
if v.PlaceWidth < 32 || v.PlaceWidth > 1024 || v.PlaceHeight < 32 || v.PlaceHeight > 1024 {
return fmt.Errorf("place dimensions must be 32..1024")
}
if v.PlacePointsPerScore < 1 || v.PlacePointsPerScore > 1000000 {
return fmt.Errorf("place_points_per_score must be 1..1000000")
}
if v.PlaceDrawPoints < 0 || v.PlaceDrawPoints > 100000000 {
return fmt.Errorf("place_draw_points must be 0..100000000")
}
if v.PlaceDrawBeaconMultiplier < 0 || v.PlaceDrawBeaconMultiplier > 1 {
return fmt.Errorf("place_draw_beacon_multiplier must be 0 or 1")
}
if v.PlaceTimePoints < 0 || v.PlaceTimePoints > 100000000 {
return fmt.Errorf("place_time_points must be 0..100000000")
}
if v.PlaceTimeIntervalSec < 5 || v.PlaceTimeIntervalSec > 86400 {
return fmt.Errorf("place_time_interval_sec must be 5..86400")
}
if v.PlaceTimeMaxGapSec < 5 || v.PlaceTimeMaxGapSec > 86400 {
return fmt.Errorf("place_time_max_gap_sec must be 5..86400")
}
if v.PlacePixelCost < 1 || v.PlacePixelCost > 100000000 {
return fmt.Errorf("place_pixel_cost must be 1..100000000")
}
switch strings.ToLower(strings.TrimSpace(v.ArtifactPreset)) {
case "legacy", "raccoon_full_art_v1":
default:
return fmt.Errorf("artifact_preset must be legacy or raccoon_full_art_v1")
}
switch strings.ToLower(v.ArtifactProvider) {
case "local", "openai", "comfyui", "a1111", "auto":
default:
return fmt.Errorf("artifact_provider must be local, openai, comfyui, a1111 or auto")
}
if strings.TrimSpace(v.ArtifactModel) == "" {
return fmt.Errorf("artifact_model must not be empty")
}
if len(v.ArtifactPrompt) < 10 || len(v.ArtifactPrompt) > 12000 {
return fmt.Errorf("artifact_prompt length must be 10..12000")
}
if len(v.ArtifactNegativePrompt) > 8000 {
return fmt.Errorf("artifact_negative_prompt too long")
}
if v.ArtifactWidth < 256 || v.ArtifactWidth > 4096 || v.ArtifactHeight < 256 || v.ArtifactHeight > 4096 {
return fmt.Errorf("artifact dimensions must be 256..4096")
}
if v.ArtifactSteps < 1 || v.ArtifactSteps > 200 {
return fmt.Errorf("artifact_steps must be 1..200")
}
switch strings.ToLower(v.ArtifactQuality) {
case "low", "medium", "high", "auto":
default:
return fmt.Errorf("artifact_quality must be low, medium, high or auto")
}
rarityValues := []struct {
name string
v float64
}{
{"artifact_rarity_common_pct", v.ArtifactRarityCommonPct},
{"artifact_rarity_uncommon_pct", v.ArtifactRarityUncommonPct},
{"artifact_rarity_rare_pct", v.ArtifactRarityRarePct},
{"artifact_rarity_ultra_rare_pct", v.ArtifactRarityUltraRarePct},
{"artifact_rarity_special_illustration_pct", v.ArtifactRaritySpecialIllustrationPct},
}
total := 0.0
for _, x := range rarityValues {
if x.v < 0 || x.v > 100 {
return fmt.Errorf("%s must be 0..100", x.name)
}
total += x.v
}
if math.Abs(total-100.0) > 0.001 {
return fmt.Errorf("artifact rarity percentages must sum to exactly 100 (currently %.4f)", total)
}
return nil
}
func (m *Manager) Run(ctx context.Context) {
t := time.NewTicker(5 * time.Second)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
_ = m.Refresh(ctx)
}
}
}