| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | "path/filepath" |
| 6 | "strings" |
| 7 | ) |
| 8 | |
| 9 | // paretoPoint is one arm on the accuracy-vs-TTCS plane. The product question |
| 10 | // is Pareto position, not averages: an arm beaten on both axes at once by the |
| 11 | // same competitor is the unambiguous alarm. |
| 12 | type paretoPoint struct { |
| 13 | label string |
| 14 | acc float64 // solved % |
| 15 | ttcsMs int64 // median time to correct solution |
| 16 | solved, ran int |
| 17 | dominatedBy string |
| 18 | } |
| 19 | |
| 20 | func newParetoPoint(path string, s armStats) paretoPoint { |
| 21 | return paretoPoint{ |
| 22 | label: strings.TrimSuffix(filepath.Base(path), ".json"), |
| 23 | acc: solveRate(s.Solved, s.Ran), |
| 24 | ttcsMs: median(s.TTCS), |
| 25 | solved: s.Solved, |
| 26 | ran: s.Ran, |
| 27 | } |
| 28 | } |
| 29 | |
| 30 | // markDominated flags each point beaten on both axes by another (strictly on |
| 31 | // at least one). Points without a solve have no TTCS and cannot dominate. |
| 32 | func markDominated(points []paretoPoint) { |
| 33 | for i := range points { |
| 34 | for j := range points { |
| 35 | if i == j || points[j].solved == 0 || points[i].solved == 0 { |
| 36 | continue |
| 37 | } |
| 38 | betterAcc := points[j].acc >= points[i].acc |
| 39 | betterTime := points[j].ttcsMs <= points[i].ttcsMs |
| 40 | strict := points[j].acc > points[i].acc || points[j].ttcsMs < points[i].ttcsMs |
| 41 | if betterAcc && betterTime && strict { |
| 42 | points[i].dominatedBy = points[j].label |
| 43 | break |
| 44 | } |
| 45 | } |
| 46 | } |
| 47 | } |
| 48 | |
| 49 | func paretoSection(points []paretoPoint) string { |
| 50 | if len(points) < 2 { |
| 51 | return "" |
| 52 | } |
| 53 | markDominated(points) |
| 54 | var b strings.Builder |
| 55 | b.WriteString("### Pareto: accuracy vs TTCS\n\n") |
| 56 | b.WriteString("```\n" + paretoChart(points) + "```\n\n") |
| 57 | for _, p := range points { |
| 58 | switch { |
| 59 | case p.solved == 0: |
| 60 | fmt.Fprintf(&b, "- `%s`: no solves — off the chart\n", p.label) |
| 61 | case p.dominatedBy != "": |
| 62 | fmt.Fprintf(&b, "- ⚠️ `%s` is **dominated** by `%s`: at least as accurate and no slower — the unambiguous alarm\n", p.label, p.dominatedBy) |
| 63 | default: |
| 64 | fmt.Fprintf(&b, "- ✅ `%s` is on the Pareto frontier (%s solved, TTCS median %s)\n", p.label, pct(p.solved, p.ran), dur(p.ttcsMs)) |
| 65 | } |
| 66 | } |
| 67 | b.WriteString("\n") |
| 68 | return b.String() |
| 69 | } |
| 70 | |
| 71 | const ( |
| 72 | paretoRows = 9 |
| 73 | paretoCols = 46 |
| 74 | ) |
| 75 | |
| 76 | // paretoChart renders the accuracy/TTCS scatter as fixed-width ASCII, letters |
| 77 | // keyed to a legend line. Dominated arms render as ✗ at their position. |
| 78 | func paretoChart(points []paretoPoint) string { |
| 79 | charted := make([]paretoPoint, 0, len(points)) |
| 80 | for _, p := range points { |
| 81 | if p.solved > 0 { |
| 82 | charted = append(charted, p) |
| 83 | } |
| 84 | } |
| 85 | if len(charted) == 0 { |
| 86 | return "(no solved runs to chart)\n" |
| 87 | } |
| 88 | xmin, xmax, ymin := paretoBounds(charted) |
| 89 | grid := make([][]rune, paretoRows) |
| 90 | for r := range grid { |
| 91 | grid[r] = []rune(strings.Repeat(" ", paretoCols)) |
| 92 | } |
| 93 | legend := make([]string, 0, len(charted)) |
| 94 | for i, p := range charted { |
| 95 | col := 0 |
| 96 | if xmax > xmin { |
| 97 | col = int(float64(p.ttcsMs-xmin) / float64(xmax-xmin) * float64(paretoCols-1)) |
| 98 | } |
| 99 | row := int((100 - p.acc) / (100 - ymin) * float64(paretoRows-1)) |
| 100 | marker := rune('A' + i) |
| 101 | if p.dominatedBy != "" { |
| 102 | marker = '✗' |
| 103 | } |
| 104 | grid[clampInt(row, 0, paretoRows-1)][clampInt(col, 0, paretoCols-1)] = marker |
| 105 | legend = append(legend, fmt.Sprintf("%c=%s", 'A'+i, p.label)) |
| 106 | } |
| 107 | var b strings.Builder |
| 108 | b.WriteString("Accuracy\n") |
| 109 | for r, line := range grid { |
| 110 | switch r { |
| 111 | case 0: |
| 112 | fmt.Fprintf(&b, "%5s |%s\n", "100%", string(line)) |
| 113 | case paretoRows - 1: |
| 114 | fmt.Fprintf(&b, "%5s |%s\n", fmt.Sprintf("%.0f%%", ymin), string(line)) |
| 115 | default: |
| 116 | fmt.Fprintf(&b, " |%s\n", string(line)) |
| 117 | } |
| 118 | } |
| 119 | fmt.Fprintf(&b, " +%s→ TTCS\n", strings.Repeat("-", paretoCols)) |
| 120 | fmt.Fprintf(&b, " %-*s%s\n", paretoCols-len(dur(xmax)), dur(xmin), dur(xmax)) |
| 121 | fmt.Fprintf(&b, " %s\n", strings.Join(legend, " ")) |
| 122 | return b.String() |
| 123 | } |
| 124 | |
| 125 | // paretoBounds pads the time axis and floors the accuracy axis one decade |
| 126 | // under the worst arm so points sit inside the frame, not on its edges. |
| 127 | func paretoBounds(points []paretoPoint) (xmin, xmax int64, ymin float64) { |
| 128 | xmin, xmax, ymin = points[0].ttcsMs, points[0].ttcsMs, points[0].acc |
| 129 | for _, p := range points[1:] { |
| 130 | xmin = min(xmin, p.ttcsMs) |
| 131 | xmax = max(xmax, p.ttcsMs) |
| 132 | ymin = min(ymin, p.acc) |
| 133 | } |
| 134 | pad := max((xmax-xmin)/10, 500) |
| 135 | xmin = max(xmin-pad, 0) |
| 136 | xmax += pad |
| 137 | ymin = max(float64(int(ymin/10)*10-10), 0) |
| 138 | if ymin >= 100 { |
| 139 | ymin = 90 |
| 140 | } |
| 141 | return xmin, xmax, ymin |
| 142 | } |
| 143 | |
| 144 | func clampInt(v, lo, hi int) int { |
| 145 | return min(max(v, lo), hi) |
| 146 | } |
| 147 |