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 }