fix: Restore all files lost during destructive rebase
A previous `git pull --rebase origin main` dropped 177 local commits,
losing 3400+ files across admin-v2, backend, studio-v2, website,
klausur-service, and many other services. The partial restore attempt
(660295e2) only recovered some files.
This commit restores all missing files from pre-rebase ref 98933f5e
while preserving post-rebase additions (night-scheduler, night-mode UI,
NightModeWidget dashboard integration).
Restored features include:
- AI Module Sidebar (FAB), OCR Labeling, OCR Compare
- GPU Dashboard, RAG Pipeline, Magic Help
- Klausur-Korrektur (8 files), Abitur-Archiv (5+ files)
- Companion, Zeugnisse-Crawler, Screen Flow
- Full backend, studio-v2, website, klausur-service
- All compliance SDKs, agent-core, voice-service
- CI/CD configs, documentation, scripts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
340
pca-platform/heuristic-service/internal/heuristics/scorer.go
Normal file
340
pca-platform/heuristic-service/internal/heuristics/scorer.go
Normal file
@@ -0,0 +1,340 @@
|
||||
package heuristics
|
||||
|
||||
import (
|
||||
"math"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/breakpilot/pca-platform/heuristic-service/internal/config"
|
||||
)
|
||||
|
||||
// SessionMetrics holds behavioral metrics for a session
|
||||
type SessionMetrics struct {
|
||||
SessionID string `json:"session_id"`
|
||||
StartTime time.Time `json:"start_time"`
|
||||
VisibleTime float64 `json:"visible_time"` // Seconds visible
|
||||
LastVisibleTS time.Time `json:"last_visible_ts"` // Last visibility timestamp
|
||||
MaxScrollPercent float64 `json:"max_scroll_percent"` // 0-1 scroll depth
|
||||
ClickCount int `json:"click_count"`
|
||||
MouseMoves int `json:"mouse_moves"`
|
||||
KeyStrokes int `json:"key_strokes"`
|
||||
TouchEvents int `json:"touch_events"`
|
||||
|
||||
// Advanced metrics
|
||||
MouseVelocities []float64 `json:"mouse_velocities,omitempty"` // For variance calculation
|
||||
ScrollVelocities []float64 `json:"scroll_velocities,omitempty"` // Scroll speed patterns
|
||||
ClickIntervals []float64 `json:"click_intervals,omitempty"` // Time between clicks
|
||||
|
||||
// Computed score
|
||||
LastScore float64 `json:"last_score"`
|
||||
LastScoreTime time.Time `json:"last_score_time"`
|
||||
}
|
||||
|
||||
// Scorer calculates human-likelihood scores based on behavioral heuristics
|
||||
type Scorer struct {
|
||||
config *config.Config
|
||||
mu sync.RWMutex
|
||||
sessions map[string]*SessionMetrics
|
||||
}
|
||||
|
||||
// NewScorer creates a new heuristic scorer
|
||||
func NewScorer(cfg *config.Config) *Scorer {
|
||||
return &Scorer{
|
||||
config: cfg,
|
||||
sessions: make(map[string]*SessionMetrics),
|
||||
}
|
||||
}
|
||||
|
||||
// GetOrCreateSession retrieves or creates a session
|
||||
func (s *Scorer) GetOrCreateSession(sessionID string) *SessionMetrics {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if session, exists := s.sessions[sessionID]; exists {
|
||||
return session
|
||||
}
|
||||
|
||||
session := &SessionMetrics{
|
||||
SessionID: sessionID,
|
||||
StartTime: time.Now(),
|
||||
LastVisibleTS: time.Now(),
|
||||
}
|
||||
s.sessions[sessionID] = session
|
||||
return session
|
||||
}
|
||||
|
||||
// UpdateMetrics updates session metrics from a tick
|
||||
func (s *Scorer) UpdateMetrics(sessionID string, metrics *SessionMetrics) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if existing, exists := s.sessions[sessionID]; exists {
|
||||
// Merge metrics
|
||||
existing.VisibleTime = metrics.VisibleTime
|
||||
existing.MaxScrollPercent = metrics.MaxScrollPercent
|
||||
existing.ClickCount = metrics.ClickCount
|
||||
existing.MouseMoves = metrics.MouseMoves
|
||||
existing.KeyStrokes = metrics.KeyStrokes
|
||||
existing.TouchEvents = metrics.TouchEvents
|
||||
|
||||
if len(metrics.MouseVelocities) > 0 {
|
||||
existing.MouseVelocities = append(existing.MouseVelocities, metrics.MouseVelocities...)
|
||||
}
|
||||
if len(metrics.ScrollVelocities) > 0 {
|
||||
existing.ScrollVelocities = append(existing.ScrollVelocities, metrics.ScrollVelocities...)
|
||||
}
|
||||
if len(metrics.ClickIntervals) > 0 {
|
||||
existing.ClickIntervals = append(existing.ClickIntervals, metrics.ClickIntervals...)
|
||||
}
|
||||
} else {
|
||||
s.sessions[sessionID] = metrics
|
||||
}
|
||||
}
|
||||
|
||||
// CalculateScore computes the human-likelihood score for a session
|
||||
func (s *Scorer) CalculateScore(sessionID string) float64 {
|
||||
s.mu.RLock()
|
||||
session, exists := s.sessions[sessionID]
|
||||
if !exists {
|
||||
s.mu.RUnlock()
|
||||
return 0.0
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
|
||||
weights := s.config.Weights
|
||||
|
||||
// Calculate individual heuristic scores (0-1)
|
||||
dwellScore := s.calculateDwellScore(session)
|
||||
scrollScore := s.calculateScrollScore(session)
|
||||
pointerScore := s.calculatePointerScore(session)
|
||||
clickScore := s.calculateClickScore(session)
|
||||
|
||||
// Weighted sum
|
||||
totalScore := dwellScore*weights.DwellRatio +
|
||||
scrollScore*weights.ScrollScore +
|
||||
pointerScore*weights.PointerVariance +
|
||||
clickScore*weights.ClickRate
|
||||
|
||||
// Clamp to [0, 1]
|
||||
if totalScore > 1.0 {
|
||||
totalScore = 1.0
|
||||
}
|
||||
if totalScore < 0.0 {
|
||||
totalScore = 0.0
|
||||
}
|
||||
|
||||
// Update session with score
|
||||
s.mu.Lock()
|
||||
session.LastScore = totalScore
|
||||
session.LastScoreTime = time.Now()
|
||||
s.mu.Unlock()
|
||||
|
||||
return totalScore
|
||||
}
|
||||
|
||||
// calculateDwellScore: visible time / total time ratio
|
||||
func (s *Scorer) calculateDwellScore(session *SessionMetrics) float64 {
|
||||
totalTime := time.Since(session.StartTime).Seconds()
|
||||
if totalTime <= 0 {
|
||||
return 0.0
|
||||
}
|
||||
|
||||
// Calculate visible time including current period if visible
|
||||
visibleTime := session.VisibleTime
|
||||
|
||||
ratio := visibleTime / totalTime
|
||||
if ratio > 1.0 {
|
||||
ratio = 1.0
|
||||
}
|
||||
|
||||
// Apply sigmoid to reward longer dwell times
|
||||
// A 30+ second dwell with high visibility is very human-like
|
||||
return sigmoid(ratio, 0.5, 10)
|
||||
}
|
||||
|
||||
// calculateScrollScore: scroll depth and natural patterns
|
||||
func (s *Scorer) calculateScrollScore(session *SessionMetrics) float64 {
|
||||
// Base score from scroll depth
|
||||
baseScore := session.MaxScrollPercent
|
||||
if baseScore > 1.0 {
|
||||
baseScore = 1.0
|
||||
}
|
||||
|
||||
// Bonus for natural scroll velocity patterns (humans have variable scroll speeds)
|
||||
if len(session.ScrollVelocities) > 2 {
|
||||
variance := calculateVariance(session.ScrollVelocities)
|
||||
// Too uniform = bot, some variance = human
|
||||
if variance > 0.01 && variance < 10.0 {
|
||||
baseScore *= 1.2 // Boost for natural variance
|
||||
}
|
||||
}
|
||||
|
||||
if baseScore > 1.0 {
|
||||
baseScore = 1.0
|
||||
}
|
||||
|
||||
return baseScore
|
||||
}
|
||||
|
||||
// calculatePointerScore: mouse movement patterns
|
||||
func (s *Scorer) calculatePointerScore(session *SessionMetrics) float64 {
|
||||
// Binary: has mouse activity at all
|
||||
if session.MouseMoves == 0 && session.TouchEvents == 0 {
|
||||
return 0.0
|
||||
}
|
||||
|
||||
baseScore := 0.5 // Some activity
|
||||
|
||||
// Humans have variable mouse velocities
|
||||
if len(session.MouseVelocities) > 5 {
|
||||
variance := calculateVariance(session.MouseVelocities)
|
||||
// Bots often have either very uniform or very erratic movement
|
||||
if variance > 0.1 && variance < 100.0 {
|
||||
baseScore = 0.9 // Natural variance pattern
|
||||
} else if variance <= 0.1 {
|
||||
baseScore = 0.3 // Too uniform - suspicious
|
||||
} else {
|
||||
baseScore = 0.4 // Too erratic - also suspicious
|
||||
}
|
||||
}
|
||||
|
||||
// Boost for touch events (mobile users)
|
||||
if session.TouchEvents > 0 {
|
||||
baseScore += 0.2
|
||||
}
|
||||
|
||||
if baseScore > 1.0 {
|
||||
baseScore = 1.0
|
||||
}
|
||||
|
||||
return baseScore
|
||||
}
|
||||
|
||||
// calculateClickScore: click patterns
|
||||
func (s *Scorer) calculateClickScore(session *SessionMetrics) float64 {
|
||||
if session.ClickCount == 0 {
|
||||
return 0.0
|
||||
}
|
||||
|
||||
totalTime := time.Since(session.StartTime).Seconds()
|
||||
if totalTime <= 0 {
|
||||
return 0.0
|
||||
}
|
||||
|
||||
// Clicks per second
|
||||
clickRate := float64(session.ClickCount) / totalTime
|
||||
|
||||
// Natural click rate is 0.1-2 clicks per second
|
||||
// Too fast = bot, none = no interaction
|
||||
var baseScore float64
|
||||
if clickRate > 0.05 && clickRate < 3.0 {
|
||||
baseScore = 0.8
|
||||
} else if clickRate >= 3.0 {
|
||||
baseScore = 0.2 // Suspiciously fast clicking
|
||||
} else {
|
||||
baseScore = 0.4
|
||||
}
|
||||
|
||||
// Check for natural intervals between clicks
|
||||
if len(session.ClickIntervals) > 2 {
|
||||
variance := calculateVariance(session.ClickIntervals)
|
||||
// Natural human timing has variance
|
||||
if variance > 0.01 {
|
||||
baseScore += 0.2
|
||||
}
|
||||
}
|
||||
|
||||
if baseScore > 1.0 {
|
||||
baseScore = 1.0
|
||||
}
|
||||
|
||||
return baseScore
|
||||
}
|
||||
|
||||
// EvaluateRequest determines action based on score
|
||||
func (s *Scorer) EvaluateRequest(sessionID string, path string, pathConfigs map[string]config.PathConfig) *EvaluationResult {
|
||||
score := s.CalculateScore(sessionID)
|
||||
|
||||
// Get path-specific config or use defaults
|
||||
minScore := s.config.Thresholds.ScoreChallenge
|
||||
var stepUpMethod *string
|
||||
|
||||
if cfg, exists := pathConfigs[path]; exists {
|
||||
minScore = cfg.MinScore
|
||||
stepUpMethod = cfg.StepUpMethod
|
||||
}
|
||||
|
||||
result := &EvaluationResult{
|
||||
SessionID: sessionID,
|
||||
Score: score,
|
||||
MinScore: minScore,
|
||||
Action: "allow",
|
||||
}
|
||||
|
||||
if score >= s.config.Thresholds.ScorePass {
|
||||
result.Action = "allow"
|
||||
} else if score >= minScore {
|
||||
result.Action = "allow" // In gray zone but above minimum
|
||||
} else {
|
||||
result.Action = "challenge"
|
||||
if stepUpMethod != nil {
|
||||
result.StepUpMethod = *stepUpMethod
|
||||
} else {
|
||||
result.StepUpMethod = s.config.StepUp.Primary
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// EvaluationResult contains the score evaluation outcome
|
||||
type EvaluationResult struct {
|
||||
SessionID string `json:"session_id"`
|
||||
Score float64 `json:"score"`
|
||||
MinScore float64 `json:"min_score"`
|
||||
Action string `json:"action"` // "allow", "challenge", "block"
|
||||
StepUpMethod string `json:"step_up_method,omitempty"`
|
||||
}
|
||||
|
||||
// CleanupOldSessions removes sessions older than maxAge
|
||||
func (s *Scorer) CleanupOldSessions(maxAge time.Duration) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
for id, session := range s.sessions {
|
||||
if now.Sub(session.StartTime) > maxAge {
|
||||
delete(s.sessions, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
func calculateVariance(values []float64) float64 {
|
||||
if len(values) < 2 {
|
||||
return 0.0
|
||||
}
|
||||
|
||||
// Calculate mean
|
||||
var sum float64
|
||||
for _, v := range values {
|
||||
sum += v
|
||||
}
|
||||
mean := sum / float64(len(values))
|
||||
|
||||
// Calculate variance
|
||||
var variance float64
|
||||
for _, v := range values {
|
||||
diff := v - mean
|
||||
variance += diff * diff
|
||||
}
|
||||
variance /= float64(len(values) - 1)
|
||||
|
||||
return variance
|
||||
}
|
||||
|
||||
// sigmoid applies a sigmoid transformation for smoother score curves
|
||||
func sigmoid(x, midpoint, steepness float64) float64 {
|
||||
return 1.0 / (1.0 + math.Exp(-steepness*(x-midpoint)))
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
package heuristics
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/breakpilot/pca-platform/heuristic-service/internal/config"
|
||||
)
|
||||
|
||||
func TestNewScorer(t *testing.T) {
|
||||
cfg := config.DefaultConfig()
|
||||
scorer := NewScorer(cfg)
|
||||
|
||||
if scorer == nil {
|
||||
t.Fatal("Expected non-nil scorer")
|
||||
}
|
||||
if scorer.config == nil {
|
||||
t.Error("Expected config to be set")
|
||||
}
|
||||
if scorer.sessions == nil {
|
||||
t.Error("Expected sessions map to be initialized")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetOrCreateSession(t *testing.T) {
|
||||
cfg := config.DefaultConfig()
|
||||
scorer := NewScorer(cfg)
|
||||
|
||||
// First call should create session
|
||||
session1 := scorer.GetOrCreateSession("test-session-1")
|
||||
if session1 == nil {
|
||||
t.Fatal("Expected non-nil session")
|
||||
}
|
||||
if session1.SessionID != "test-session-1" {
|
||||
t.Errorf("Expected session ID 'test-session-1', got '%s'", session1.SessionID)
|
||||
}
|
||||
|
||||
// Second call should return same session
|
||||
session2 := scorer.GetOrCreateSession("test-session-1")
|
||||
if session1 != session2 {
|
||||
t.Error("Expected same session instance on second call")
|
||||
}
|
||||
|
||||
// Different ID should create new session
|
||||
session3 := scorer.GetOrCreateSession("test-session-2")
|
||||
if session1 == session3 {
|
||||
t.Error("Expected different session for different ID")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateScore_NewSession(t *testing.T) {
|
||||
cfg := config.DefaultConfig()
|
||||
scorer := NewScorer(cfg)
|
||||
|
||||
// New session with no activity should have low score
|
||||
scorer.GetOrCreateSession("test-new")
|
||||
score := scorer.CalculateScore("test-new")
|
||||
|
||||
if score < 0 || score > 1 {
|
||||
t.Errorf("Expected score between 0 and 1, got %f", score)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateScore_HighActivity(t *testing.T) {
|
||||
cfg := config.DefaultConfig()
|
||||
scorer := NewScorer(cfg)
|
||||
|
||||
session := scorer.GetOrCreateSession("test-active")
|
||||
session.StartTime = time.Now().Add(-30 * time.Second)
|
||||
session.VisibleTime = 28.0 // High visibility
|
||||
session.MaxScrollPercent = 0.8
|
||||
session.ClickCount = 10
|
||||
session.MouseMoves = 100
|
||||
session.MouseVelocities = []float64{100, 150, 80, 200, 120, 90}
|
||||
session.ClickIntervals = []float64{1.5, 2.0, 1.2, 0.8}
|
||||
|
||||
score := scorer.CalculateScore("test-active")
|
||||
|
||||
// Active session should have higher score
|
||||
if score < 0.5 {
|
||||
t.Errorf("Expected score > 0.5 for active session, got %f", score)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateScore_BotLikeActivity(t *testing.T) {
|
||||
cfg := config.DefaultConfig()
|
||||
scorer := NewScorer(cfg)
|
||||
|
||||
session := scorer.GetOrCreateSession("test-bot")
|
||||
session.StartTime = time.Now().Add(-5 * time.Second)
|
||||
session.VisibleTime = 1.0 // Very short
|
||||
session.MaxScrollPercent = 0.0
|
||||
session.ClickCount = 0
|
||||
session.MouseMoves = 0
|
||||
|
||||
score := scorer.CalculateScore("test-bot")
|
||||
|
||||
// Bot-like session should have very low score
|
||||
if score > 0.3 {
|
||||
t.Errorf("Expected score < 0.3 for bot-like session, got %f", score)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateScore_UniformMouseMovement(t *testing.T) {
|
||||
cfg := config.DefaultConfig()
|
||||
scorer := NewScorer(cfg)
|
||||
|
||||
session := scorer.GetOrCreateSession("test-uniform")
|
||||
session.StartTime = time.Now().Add(-20 * time.Second)
|
||||
session.VisibleTime = 18.0
|
||||
session.MouseMoves = 50
|
||||
// Very uniform velocities (suspicious)
|
||||
session.MouseVelocities = []float64{100, 100, 100, 100, 100, 100, 100, 100}
|
||||
|
||||
score := scorer.CalculateScore("test-uniform")
|
||||
|
||||
// Uniform movement should result in lower pointer score
|
||||
if score > 0.7 {
|
||||
t.Errorf("Expected score < 0.7 for uniform mouse movement, got %f", score)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateRequest(t *testing.T) {
|
||||
cfg := config.DefaultConfig()
|
||||
scorer := NewScorer(cfg)
|
||||
|
||||
// High score session
|
||||
session := scorer.GetOrCreateSession("test-evaluate")
|
||||
session.StartTime = time.Now().Add(-60 * time.Second)
|
||||
session.VisibleTime = 55.0
|
||||
session.MaxScrollPercent = 0.9
|
||||
session.ClickCount = 15
|
||||
session.MouseMoves = 200
|
||||
session.MouseVelocities = []float64{100, 150, 80, 200, 120, 90, 110}
|
||||
|
||||
result := scorer.EvaluateRequest("test-evaluate", "/default", nil)
|
||||
|
||||
if result.SessionID != "test-evaluate" {
|
||||
t.Errorf("Expected session ID 'test-evaluate', got '%s'", result.SessionID)
|
||||
}
|
||||
if result.Action != "allow" && result.Score >= cfg.Thresholds.ScorePass {
|
||||
t.Errorf("Expected 'allow' action for high score, got '%s'", result.Action)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateRequest_Challenge(t *testing.T) {
|
||||
cfg := config.DefaultConfig()
|
||||
scorer := NewScorer(cfg)
|
||||
|
||||
// Low score session
|
||||
scorer.GetOrCreateSession("test-challenge")
|
||||
|
||||
result := scorer.EvaluateRequest("test-challenge", "/api", nil)
|
||||
|
||||
if result.Action != "challenge" {
|
||||
t.Errorf("Expected 'challenge' action for new session, got '%s'", result.Action)
|
||||
}
|
||||
if result.StepUpMethod == "" {
|
||||
t.Error("Expected step-up method to be set for challenge")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanupOldSessions(t *testing.T) {
|
||||
cfg := config.DefaultConfig()
|
||||
scorer := NewScorer(cfg)
|
||||
|
||||
// Create some sessions
|
||||
scorer.GetOrCreateSession("session-new")
|
||||
|
||||
oldSession := scorer.GetOrCreateSession("session-old")
|
||||
oldSession.StartTime = time.Now().Add(-2 * time.Hour)
|
||||
|
||||
// Verify both exist
|
||||
if len(scorer.sessions) != 2 {
|
||||
t.Errorf("Expected 2 sessions, got %d", len(scorer.sessions))
|
||||
}
|
||||
|
||||
// Cleanup with 1 hour max age
|
||||
scorer.CleanupOldSessions(1 * time.Hour)
|
||||
|
||||
// Old session should be removed
|
||||
if len(scorer.sessions) != 1 {
|
||||
t.Errorf("Expected 1 session after cleanup, got %d", len(scorer.sessions))
|
||||
}
|
||||
|
||||
if _, exists := scorer.sessions["session-old"]; exists {
|
||||
t.Error("Expected old session to be cleaned up")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateVariance(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
values []float64
|
||||
expected float64
|
||||
}{
|
||||
{
|
||||
name: "empty",
|
||||
values: []float64{},
|
||||
expected: 0.0,
|
||||
},
|
||||
{
|
||||
name: "single value",
|
||||
values: []float64{5.0},
|
||||
expected: 0.0,
|
||||
},
|
||||
{
|
||||
name: "uniform values",
|
||||
values: []float64{5.0, 5.0, 5.0, 5.0},
|
||||
expected: 0.0,
|
||||
},
|
||||
{
|
||||
name: "varied values",
|
||||
values: []float64{1.0, 2.0, 3.0, 4.0, 5.0},
|
||||
expected: 2.5, // Variance of [1,2,3,4,5]
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := calculateVariance(tt.values)
|
||||
if tt.expected == 0.0 && result != 0.0 {
|
||||
t.Errorf("Expected 0 variance, got %f", result)
|
||||
}
|
||||
if tt.expected != 0.0 && (result < tt.expected-0.1 || result > tt.expected+0.1) {
|
||||
t.Errorf("Expected variance ~%f, got %f", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSigmoid(t *testing.T) {
|
||||
// Test sigmoid at midpoint
|
||||
result := sigmoid(0.5, 0.5, 10)
|
||||
if result < 0.49 || result > 0.51 {
|
||||
t.Errorf("Expected sigmoid(0.5, 0.5, 10) ~ 0.5, got %f", result)
|
||||
}
|
||||
|
||||
// Test sigmoid well above midpoint
|
||||
result = sigmoid(1.0, 0.5, 10)
|
||||
if result < 0.9 {
|
||||
t.Errorf("Expected sigmoid(1.0, 0.5, 10) > 0.9, got %f", result)
|
||||
}
|
||||
|
||||
// Test sigmoid well below midpoint
|
||||
result = sigmoid(0.0, 0.5, 10)
|
||||
if result > 0.1 {
|
||||
t.Errorf("Expected sigmoid(0.0, 0.5, 10) < 0.1, got %f", result)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user