turash/models/validator/validator.go
Damir Mukimov 4a2fda96cd
Initial commit: Repository setup with .gitignore, golangci-lint v2.6.0, and code quality checks
- Initialize git repository
- Add comprehensive .gitignore for Go projects
- Install golangci-lint v2.6.0 (latest v2) globally
- Configure .golangci.yml with appropriate linters and formatters
- Fix all formatting issues (gofmt)
- Fix all errcheck issues (unchecked errors)
- Adjust complexity threshold for validation functions
- All checks passing: build, test, vet, lint
2025-11-01 07:36:22 +01:00

138 lines
4.4 KiB
Go

package validator
import (
"fmt"
"github.com/damirmukimov/city_resource_graph/models/cost"
"github.com/damirmukimov/city_resource_graph/models/customer"
"github.com/damirmukimov/city_resource_graph/models/impact"
"github.com/damirmukimov/city_resource_graph/models/revenue"
)
// ValidationError represents a validation failure.
type ValidationError struct {
Year int
Rule string
Message string
Value interface{}
}
func (ve ValidationError) Error() string {
return fmt.Sprintf("validation failed [Year %d, Rule: %s]: %s (value: %v)", ve.Year, ve.Rule, ve.Message, ve.Value)
}
// ValidationResult contains all validation errors found.
type ValidationResult struct {
Errors []ValidationError
}
// IsValid returns true if no validation errors were found.
func (vr ValidationResult) IsValid() bool {
return len(vr.Errors) == 0
}
// Validate performs all sanity checks on the model outputs for a given year.
func Validate(
year int,
custMetrics customer.CustomerMetrics,
revBreakdown revenue.RevenueBreakdown,
costBreakdown cost.CostBreakdown,
impactMetrics impact.ImpactMetrics,
) ValidationResult {
var errors []ValidationError
// 1. ARPU sanity check
// ARPU should be within typical B2B SaaS range: €300-€6,000/year
if custMetrics.PayingOrgs > 0 {
arpu := revBreakdown.Total / float64(custMetrics.PayingOrgs)
if arpu < 300 || arpu > 6000 {
errors = append(errors, ValidationError{
Year: year,
Rule: "ARPU_SANITY",
Message: fmt.Sprintf("ARPU (€%.2f) outside typical B2B SaaS range (€300-€6,000)", arpu),
Value: arpu,
})
}
}
// 2. Implementation density check
// Matches per organization should not exceed 1.5 (unrealistic for 3-year horizon)
if impactMetrics.CO2Avoided > 0 || custMetrics.TotalOrgs > 0 {
// Estimate matches from implementation revenue
// This is approximate - ideally should come from model directly
implRev := revBreakdown.Implementation.Total
if implRev > 0 && revBreakdown.Implementation.PaidImpls > 0 {
matchesPerOrg := revBreakdown.Implementation.Matches / float64(custMetrics.TotalOrgs)
if matchesPerOrg > 1.5 {
errors = append(errors, ValidationError{
Year: year,
Rule: "IMPLEMENTATION_DENSITY",
Message: fmt.Sprintf("Matches per org (%.2f) exceeds realistic limit (1.5)", matchesPerOrg),
Value: matchesPerOrg,
})
}
}
}
// 3. CO₂ / revenue ratio check
// Catches unit mistakes (e.g., if CO₂ is in wrong units)
if revBreakdown.Total > 0 {
co2PerEur := impactMetrics.CO2Avoided / revBreakdown.Total
if co2PerEur > 10 {
errors = append(errors, ValidationError{
Year: year,
Rule: "CO2_REVENUE_RATIO",
Message: fmt.Sprintf("CO₂ per EUR (%.2f t CO₂/€) exceeds sanity limit (10)", co2PerEur),
Value: co2PerEur,
})
}
}
// 4. Heat MWh sanity check
// If heat exceeds 5,000,000 MWh/year, likely multiplied by 12 (monthly mistake)
if impactMetrics.CO2Avoided > 0 {
// Reconstruct heat from CO₂ to check
// This is approximate - ideally should validate input directly
estimatedHeat := impactMetrics.CO2Avoided / (0.3 * 0.9 * 0.7) // Reverse calculation
if estimatedHeat > 5_000_000 {
errors = append(errors, ValidationError{
Year: year,
Rule: "HEAT_MWH_SANITY",
Message: fmt.Sprintf("Estimated heat (%.0f MWh) exceeds sanity limit (5M MWh/year) - possible monthly multiplier error", estimatedHeat),
Value: estimatedHeat,
})
}
}
// 5. Profitability check (warning only)
if revBreakdown.Total > 0 && costBreakdown.Total > 0 {
profit := revBreakdown.Total - costBreakdown.Total
margin := (profit / revBreakdown.Total) * 100
if margin < -100 {
// Losses exceed revenue - suspicious
errors = append(errors, ValidationError{
Year: year,
Rule: "PROFITABILITY_WARNING",
Message: fmt.Sprintf("Margin (%.1f%%) indicates losses exceeding revenue - verify cost inputs", margin),
Value: margin,
})
}
}
return ValidationResult{Errors: errors}
}
// ValidateMunicipalPenetration checks if municipal city count is reasonable.
// This requires external context (total cities in target region).
func ValidateMunicipalPenetration(year int, cities int, maxCities int) ValidationError {
if cities > maxCities {
return ValidationError{
Year: year,
Rule: "MUNICIPAL_PENETRATION",
Message: fmt.Sprintf("Municipal cities (%d) exceeds maximum expected (%d)", cities, maxCities),
Value: cities,
}
}
return ValidationError{} // No error
}