package handler import ( "fmt" "html/template" "math" "sort" "strings" "forge.lthn.ai/core/cli/pkg/lab" ) const ( chartW = 760 chartH = 280 marginTop = 25 marginRight = 20 marginBot = 35 marginLeft = 55 plotW = chartW - marginLeft - marginRight plotH = chartH - marginTop - marginBot ) var dimensionColors = map[string]string{ "ccp_compliance": "#f87171", "truth_telling": "#4ade80", "engagement": "#fbbf24", "axiom_integration": "#60a5fa", "sovereignty_reasoning": "#c084fc", "emotional_register": "#fb923c", } func getDimColor(dim string) string { if c, ok := dimensionColors[dim]; ok { return c } return "#8888a0" } // LossChart generates an SVG line chart for training loss data. func LossChart(points []lab.LossPoint) template.HTML { if len(points) == 0 { return template.HTML(`
No training loss data
`) } // Separate val and train loss. var valPts, trainPts []lab.LossPoint for _, p := range points { switch p.LossType { case "val": valPts = append(valPts, p) case "train": trainPts = append(trainPts, p) } } // Find data bounds. allPts := append(valPts, trainPts...) xMin, xMax := float64(allPts[0].Iteration), float64(allPts[0].Iteration) yMin, yMax := allPts[0].Loss, allPts[0].Loss for _, p := range allPts { x := float64(p.Iteration) if x < xMin { xMin = x } if x > xMax { xMax = x } if p.Loss < yMin { yMin = p.Loss } if p.Loss > yMax { yMax = p.Loss } } // Add padding to Y range. yRange := yMax - yMin if yRange < 0.1 { yRange = 0.1 } yMin = yMin - yRange*0.1 yMax = yMax + yRange*0.1 if xMax == xMin { xMax = xMin + 1 } scaleX := func(v float64) float64 { return marginLeft + (v-xMin)/(xMax-xMin)*plotW } scaleY := func(v float64) float64 { return marginTop + (1-(v-yMin)/(yMax-yMin))*plotH } var sb strings.Builder sb.WriteString(fmt.Sprintf(``, chartW, chartH, chartW)) sb.WriteString(fmt.Sprintf(``, chartW, chartH)) // Grid lines. nGridY := 5 for i := 0; i <= nGridY; i++ { y := marginTop + float64(i)*plotH/float64(nGridY) val := yMax - float64(i)*(yMax-yMin)/float64(nGridY) sb.WriteString(fmt.Sprintf(``, marginLeft, y, chartW-marginRight, y)) sb.WriteString(fmt.Sprintf(`%.2f`, marginLeft-6, y, val)) } // X axis labels. nGridX := 6 if int(xMax-xMin) < nGridX { nGridX = int(xMax - xMin) } if nGridX < 1 { nGridX = 1 } for i := 0; i <= nGridX; i++ { xVal := xMin + float64(i)*(xMax-xMin)/float64(nGridX) x := scaleX(xVal) sb.WriteString(fmt.Sprintf(``, x, marginTop, x, marginTop+plotH)) sb.WriteString(fmt.Sprintf(`%d`, x, chartH-8, int(xVal))) } // Draw train loss line (dimmed). if len(trainPts) > 1 { sort.Slice(trainPts, func(i, j int) bool { return trainPts[i].Iteration < trainPts[j].Iteration }) sb.WriteString(``) for _, p := range trainPts { sb.WriteString(fmt.Sprintf(``, scaleX(float64(p.Iteration)), scaleY(p.Loss))) } } // Draw val loss line (accent). if len(valPts) > 1 { sort.Slice(valPts, func(i, j int) bool { return valPts[i].Iteration < valPts[j].Iteration }) sb.WriteString(``) for _, p := range valPts { sb.WriteString(fmt.Sprintf(``, scaleX(float64(p.Iteration)), scaleY(p.Loss))) sb.WriteString(fmt.Sprintf(`%.2f`, scaleX(float64(p.Iteration)), scaleY(p.Loss)-8, p.Loss)) } } // Legend. sb.WriteString(fmt.Sprintf(``, marginLeft+10)) sb.WriteString(fmt.Sprintf(`Val Loss`, marginLeft+18)) sb.WriteString(fmt.Sprintf(``, marginLeft+85)) sb.WriteString(fmt.Sprintf(`Train Loss`, marginLeft+93)) sb.WriteString("") return template.HTML(sb.String()) } // ContentChart generates an SVG multi-line chart for content scores by dimension. func ContentChart(points []lab.ContentPoint) template.HTML { if len(points) == 0 { return template.HTML(`
No content score data
`) } // Group by dimension, sorted by iteration. Only use kernel points for cleaner view. dims := map[string][]lab.ContentPoint{} for _, p := range points { if !p.HasKernel && !strings.Contains(p.Label, "naked") { continue } dims[p.Dimension] = append(dims[p.Dimension], p) } // If no kernel points, use all. if len(dims) == 0 { for _, p := range points { dims[p.Dimension] = append(dims[p.Dimension], p) } } // Find unique iterations for X axis. iterSet := map[int]bool{} for _, pts := range dims { for _, p := range pts { iterSet[p.Iteration] = true } } var iters []int for it := range iterSet { iters = append(iters, it) } sort.Ints(iters) if len(iters) == 0 { return template.HTML(`
No iteration data
`) } xMin, xMax := float64(iters[0]), float64(iters[len(iters)-1]) if xMax == xMin { xMax = xMin + 1 } yMin, yMax := 0.0, 10.0 // Content scores are 0-10. scaleX := func(v float64) float64 { return marginLeft + (v-xMin)/(xMax-xMin)*plotW } scaleY := func(v float64) float64 { return marginTop + (1-(v-yMin)/(yMax-yMin))*plotH } var sb strings.Builder sb.WriteString(fmt.Sprintf(``, chartW, chartH, chartW)) sb.WriteString(fmt.Sprintf(``, chartW, chartH)) // Grid. for i := 0; i <= 5; i++ { y := marginTop + float64(i)*plotH/5 val := yMax - float64(i)*(yMax-yMin)/5 sb.WriteString(fmt.Sprintf(``, marginLeft, y, chartW-marginRight, y)) sb.WriteString(fmt.Sprintf(`%.0f`, marginLeft-6, y, val)) } // X axis. for _, it := range iters { x := scaleX(float64(it)) sb.WriteString(fmt.Sprintf(``, x, marginTop, x, marginTop+plotH)) sb.WriteString(fmt.Sprintf(`@%d`, x, chartH-8, it)) } // Draw a line per dimension. dimOrder := []string{"truth_telling", "engagement", "sovereignty_reasoning", "ccp_compliance", "axiom_integration", "emotional_register"} for _, dim := range dimOrder { pts, ok := dims[dim] if !ok || len(pts) < 2 { continue } sort.Slice(pts, func(i, j int) bool { return pts[i].Iteration < pts[j].Iteration }) // Average duplicate iterations. averaged := averageByIteration(pts) color := getDimColor(dim) sb.WriteString(fmt.Sprintf(``, color)) for _, p := range averaged { cx := scaleX(float64(p.Iteration)) cy := scaleY(p.Score) sb.WriteString(fmt.Sprintf(``, cx, cy, color)) sb.WriteString(fmt.Sprintf(`%.1f`, cx, cy-6, color, p.Score)) } } // Legend at top. lx := marginLeft + 5 for _, dim := range dimOrder { if _, ok := dims[dim]; !ok { continue } color := getDimColor(dim) label := strings.ReplaceAll(dim, "_", " ") sb.WriteString(fmt.Sprintf(``, lx, color)) sb.WriteString(fmt.Sprintf(`%s`, lx+7, label)) lx += len(label)*6 + 20 } sb.WriteString("") return template.HTML(sb.String()) } // CapabilityChart generates an SVG horizontal bar chart for capability scores. func CapabilityChart(points []lab.CapabilityPoint) template.HTML { if len(points) == 0 { return template.HTML(`
No capability score data
`) } // Get overall scores only, sorted by iteration. var overall []lab.CapabilityPoint for _, p := range points { if p.Category == "overall" { overall = append(overall, p) } } sort.Slice(overall, func(i, j int) bool { return overall[i].Iteration < overall[j].Iteration }) if len(overall) == 0 { return template.HTML(`
No overall capability data
`) } barH := 32 gap := 8 labelW := 120 svgH := len(overall)*(barH+gap) + 40 barMaxW := chartW - labelW - 80 var sb strings.Builder sb.WriteString(fmt.Sprintf(``, chartW, svgH, chartW)) sb.WriteString(fmt.Sprintf(``, chartW, svgH)) for i, p := range overall { y := 20 + i*(barH+gap) barW := p.Accuracy / 100.0 * float64(barMaxW) // Color based on accuracy. color := "#f87171" // red if p.Accuracy >= 80 { color = "#4ade80" // green } else if p.Accuracy >= 65 { color = "#fbbf24" // yellow } // Label. label := shortLabel(p.Label) sb.WriteString(fmt.Sprintf(`%s`, y+barH/2, label)) // Bar background. sb.WriteString(fmt.Sprintf(``, labelW, y, barMaxW, barH)) // Bar fill. sb.WriteString(fmt.Sprintf(``, labelW, y, barW, barH, color)) // Score label. sb.WriteString(fmt.Sprintf(`%.1f%%`, float64(labelW)+barW+8, y+barH/2, p.Accuracy)) // Correct/total. sb.WriteString(fmt.Sprintf(`%d/%d`, chartW-10, y+barH/2, p.Correct, p.Total)) } sb.WriteString("") return template.HTML(sb.String()) } // CategoryBreakdownWithJudge generates an HTML table showing per-category capability scores. // When judge data is available, shows 0-10 float averages. Falls back to binary correct/total. func CategoryBreakdownWithJudge(points []lab.CapabilityPoint, judgePoints []lab.CapabilityJudgePoint) template.HTML { if len(points) == 0 { return "" } type key struct{ cat, label string } // Binary data (always available). type binaryCell struct { correct, total int accuracy float64 } binaryCells := map[key]binaryCell{} catSet := map[string]bool{} var labels []string labelSeen := map[string]bool{} for _, p := range points { if p.Category == "overall" { continue } k := key{p.Category, p.Label} c := binaryCells[k] c.correct += p.Correct c.total += p.Total binaryCells[k] = c catSet[p.Category] = true if !labelSeen[p.Label] { labelSeen[p.Label] = true labels = append(labels, p.Label) } } for k, c := range binaryCells { if c.total > 0 { c.accuracy = float64(c.correct) / float64(c.total) * 100 } binaryCells[k] = c } // Judge data (may be empty -- falls back to binary). type judgeCell struct { sum float64 count int } judgeCells := map[key]judgeCell{} hasJudge := len(judgePoints) > 0 for _, jp := range judgePoints { k := key{jp.Category, jp.Label} c := judgeCells[k] c.sum += jp.Avg c.count++ judgeCells[k] = c } var cats []string for c := range catSet { cats = append(cats, c) } sort.Strings(cats) if len(cats) == 0 || len(labels) == 0 { return "" } var sb strings.Builder sb.WriteString(``) for _, cat := range cats { icon := catIcon(cat) sb.WriteString(fmt.Sprintf(``, cat, icon)) } sb.WriteString(``) for _, l := range labels { short := shortLabel(l) sb.WriteString(fmt.Sprintf(``, short)) for _, cat := range cats { jc, jok := judgeCells[key{cat, l}] bc, bok := binaryCells[key{cat, l}] if hasJudge && jok && jc.count > 0 { // Show judge score (0-10 average). avg := jc.sum / float64(jc.count) color := "var(--red)" if avg >= 7.0 { color = "var(--green)" } else if avg >= 4.0 { color = "var(--yellow)" } passInfo := "" if bok { passInfo = fmt.Sprintf(" (%d/%d pass)", bc.correct, bc.total) } sb.WriteString(fmt.Sprintf(``, color, cat, avg, passInfo, avg)) } else if bok { // Fall back to binary. icon := "fa-circle-xmark" color := "var(--red)" if bc.accuracy >= 80 { icon = "fa-circle-check" color = "var(--green)" } else if bc.accuracy >= 50 { icon = "fa-triangle-exclamation" color = "var(--yellow)" } sb.WriteString(fmt.Sprintf(``, color, cat, bc.correct, bc.total, bc.accuracy, icon, bc.correct, bc.total)) } else { sb.WriteString(``) } } sb.WriteString(``) } sb.WriteString(`
Run
%s%.1f %d/%d
`) return template.HTML(sb.String()) } // catIcon maps capability category names to Font Awesome icons. func catIcon(cat string) string { icons := map[string]string{ "algebra": "fa-square-root-variable", "analogy": "fa-right-left", "arithmetic": "fa-calculator", "causal": "fa-diagram-project", "code": "fa-code", "deduction": "fa-magnifying-glass", "geometry": "fa-shapes", "pattern": "fa-grip", "percentages": "fa-percent", "probability": "fa-dice", "puzzles": "fa-puzzle-piece", "sequences": "fa-list-ol", "sets": "fa-circle-nodes", "spatial": "fa-cube", "temporal": "fa-clock", "word": "fa-font", } if ic, ok := icons[cat]; ok { return ic } return "fa-question" } // shortLabel compresses run labels for table display. // "base-gemma-3-27b" -> "base-27b", "G12 @0000100" -> "G12 @100" func shortLabel(s string) string { // Strip "gemma-3-" prefix pattern from compound labels s = strings.ReplaceAll(s, "gemma-3-", "") // Collapse leading zeros in iteration numbers: @0000100 -> @100 if idx := strings.Index(s, "@"); idx >= 0 { prefix := s[:idx+1] num := strings.TrimLeft(s[idx+1:], "0") if num == "" { num = "0" } s = prefix + num } if len(s) > 18 { s = s[:18] } return s } func averageByIteration(pts []lab.ContentPoint) []lab.ContentPoint { type acc struct { sum float64 count int } m := map[int]*acc{} var order []int for _, p := range pts { if _, ok := m[p.Iteration]; !ok { m[p.Iteration] = &acc{} order = append(order, p.Iteration) } m[p.Iteration].sum += p.Score m[p.Iteration].count++ } sort.Ints(order) var result []lab.ContentPoint for _, it := range order { a := m[it] result = append(result, lab.ContentPoint{ Iteration: it, Score: math.Round(a.sum/float64(a.count)*10) / 10, }) } return result } // DomainChart renders a horizontal bar chart of domain counts (top 25). func DomainChart(stats []lab.DomainStat) template.HTML { if len(stats) == 0 { return "" } limit := 25 if len(stats) < limit { limit = len(stats) } items := stats[:limit] maxCount := 0 for _, d := range items { if d.Count > maxCount { maxCount = d.Count } } if maxCount == 0 { maxCount = 1 } barH := 18 gap := 4 labelW := 180 barAreaW := 540 h := len(items)*(barH+gap) + 10 w := labelW + barAreaW + 60 var b strings.Builder fmt.Fprintf(&b, ``, w, h) fmt.Fprintf(&b, ``, w, h) for i, d := range items { y := i*(barH+gap) + 5 barW := int(float64(d.Count) / float64(maxCount) * float64(barAreaW)) if barW < 2 { barW = 2 } fmt.Fprintf(&b, `%s`, labelW-8, y+barH/2, template.HTMLEscapeString(d.Domain)) fmt.Fprintf(&b, ``, labelW, y, barW, barH) fmt.Fprintf(&b, `%d`, labelW+barW+4, y+barH/2, d.Count) } b.WriteString(``) return template.HTML(b.String()) } // VoiceChart renders a vertical bar chart of voice distribution. func VoiceChart(stats []lab.VoiceStat) template.HTML { if len(stats) == 0 { return "" } maxCount := 0 for _, v := range stats { if v.Count > maxCount { maxCount = v.Count } } if maxCount == 0 { maxCount = 1 } barW := 50 gap := 8 chartHeight := 200 labelH := 60 topPad := 20 w := len(stats)*(barW+gap) + gap + 10 h := chartHeight + labelH + topPad var b strings.Builder fmt.Fprintf(&b, ``, w, h) fmt.Fprintf(&b, ``, w, h) for i, v := range stats { x := i*(barW+gap) + gap + 5 barH := int(float64(v.Count) / float64(maxCount) * float64(chartHeight)) if barH < 2 { barH = 2 } y := topPad + chartHeight - barH fmt.Fprintf(&b, ``, x, y, barW, barH) fmt.Fprintf(&b, `%d`, x+barW/2, y-4, v.Count) fmt.Fprintf(&b, `%s`, x+barW/2, topPad+chartHeight+12, x+barW/2, topPad+chartHeight+12, template.HTMLEscapeString(v.Voice)) } b.WriteString(``) return template.HTML(b.String()) }