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(`")
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(`
`)
}
// 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(`")
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(`
Run
`)
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(`
%s
`, 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(`
%.1f
`,
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(`