Files
fathom/internal/insights/context_waste.go
zzg ec62a31983 Add the insights tab: cost patterns from observed traces
An isolated background subsystem under its own tab. It reads the store
through the same public API the UI uses, holds findings in memory, and
writes nothing to the data directory except the reader's own dismissals.
Nothing else depends on it; setting [insights] enabled = false removes a
tab and a goroutine and changes nothing about ingest, storage, or queries.

Three themes:

- Worth crystallizing, the theme you asked for. Two signals: frequent
  contiguous runs of tool calls, which finds repeated mechanics, and
  near-duplicate prompts, which finds repeated requests. Recommendations
  are framed around temporary skills, because these patterns belong to
  whatever is being worked on this fortnight rather than forever.
- Context you are paying for twice: files re-read within one run, oversized
  tool results, and runs whose context grew several times over.
- Spend with nothing to show: command shapes that fail repeatedly.

No model is called and no embedding is computed, after researching whether
one was needed. The questions being asked do not require semantics: which
mechanical sequences repeat is categorical over a tiny alphabet of tool
names, whether two commands are the same is structural argv shape, and
whether a request has been made before is near-duplicate detection, which
MinHash has solved for twenty years. The one thing semantics would add is
naming a cluster in prose, which is exactly what an evidence-first
presentation does not need.

Every finding leads with a dollar figure and ends with the traces it came
from, so a claim can be checked rather than taken on faith. Addressable
spend is counted per trace rather than per insight, or a trace flagged by
three detectors would claim three times the savings that were ever spent.

Two ranking bugs found while running this against generated traffic:

- Costs are not comparable across detectors. A repeated-ask finding claims
  the whole cost of each trace, since the entire run was repeated, while a
  sequence finding claims only its slice. Sorting by cost buried every
  sequence finding beneath the prompt clusters, so the per-theme cap now
  takes turns between kinds of finding.
- Ranking candidate sequences longest-first meant a five-step variant seen
  three times outranked the four-step core seen nine times, and the core
  was then suppressed as an overlap of its own variant. Ranking by
  recurrence fixes it; variants are collapsed by shared-run length and by
  trace-set containment.

gen now emits recurring workflows with varying arguments, because synthetic
data with no habits in it made the sequence detector look inert.
2026-09-15 22:36:33 -07:00

357 lines
9.7 KiB
Go

package insights
import (
"fmt"
"sort"
"strings"
)
// ContextWeight finds material that was paid for more than once.
//
// This is usually the largest real line item and the least visible one, because
// nothing in a normal trace view shows that the same file went into the context
// four times.
type ContextWeight struct {
// RereadThreshold is how many times one file must be read within a single
// trace before it counts. Twice is ordinary — look, edit, look again.
RereadThreshold int
// LargeOutputBytes flags a single tool result big enough to matter on its
// own once it enters the context.
LargeOutputBytes int64
// GrowthFactor is how much the last model call's input must exceed the
// first before the growth is worth remarking on.
GrowthFactor float64
}
func NewContextWeight() *ContextWeight {
return &ContextWeight{
RereadThreshold: 3,
LargeOutputBytes: 50_000,
GrowthFactor: 4,
}
}
func (c *ContextWeight) Name() string { return "context" }
func (c *ContextWeight) Detect(traces []TraceFacts) []Insight {
var out []Insight
if in, ok := c.rereads(traces); ok {
out = append(out, in)
}
if in, ok := c.largeOutputs(traces); ok {
out = append(out, in)
}
if in, ok := c.growth(traces); ok {
out = append(out, in)
}
return out
}
// rereads finds files read repeatedly within a single run.
func (c *ContextWeight) rereads(traces []TraceFacts) (Insight, bool) {
var ev []Evidence
var cost float64
total := 0
first, last := int64(0), int64(0)
for _, t := range traces {
counts := map[string]int{}
for _, call := range t.Calls {
if !isRead(call.Name) || call.Target == "" {
continue
}
counts[call.Target]++
}
var worst string
worstN := 0
repeats := 0
for target, n := range counts {
if n >= c.RereadThreshold {
repeats += n - 1
if n > worstN {
worst, worstN = target, n
}
}
}
if worstN == 0 {
continue
}
total += repeats
cost += t.CostUSD
if first == 0 || t.StartTS < first {
first = t.StartTS
}
if t.StartTS > last {
last = t.StartTS
}
ev = append(ev, Evidence{
TraceID: t.TraceID, When: t.StartTS, Prompt: t.Prompt, CostUSD: t.CostUSD,
Detail: fmt.Sprintf("read %s %d times", trimMiddle(worst, 48), worstN),
})
}
if len(ev) < 2 {
return Insight{}, false
}
sort.SliceStable(ev, func(i, j int) bool { return ev[i].When > ev[j].When })
if len(ev) > 8 {
ev = ev[:8]
}
in := Insight{
ID: "context/rereads",
Theme: ThemeContext,
Severity: severityFor(cost, total),
Title: fmt.Sprintf("The same files were re-read %d times within single runs", total),
Occurrences: total,
CostUSD: cost,
FirstSeen: first,
LastSeen: last,
Evidence: ev,
}
in.Body = "A file read three or more times inside one run is usually the agent losing track of something it " +
"already had. Each read goes back into the context and is paid for on every subsequent turn of that run, " +
"so the cost compounds rather than repeating."
in.Suggestion = "Where a file is central to a task, putting the relevant part somewhere durable — a note in the " +
"repo, or a project instruction file — costs one read instead of several."
return in, true
}
// largeOutputs finds single tool results big enough to be worth truncating.
func (c *ContextWeight) largeOutputs(traces []TraceFacts) (Insight, bool) {
type hit struct {
t TraceFacts
call ToolCall
}
var hits []hit
var bytes int64
for _, t := range traces {
for _, call := range t.Calls {
if call.OutputSize >= c.LargeOutputBytes {
hits = append(hits, hit{t, call})
bytes += call.OutputSize
}
}
}
if len(hits) < 2 {
return Insight{}, false
}
sort.SliceStable(hits, func(i, j int) bool { return hits[i].call.OutputSize > hits[j].call.OutputSize })
var cost float64
seen := map[string]bool{}
var ev []Evidence
first, last := int64(0), int64(0)
for _, h := range hits {
if !seen[h.t.TraceID] {
seen[h.t.TraceID] = true
cost += h.t.CostUSD
}
if first == 0 || h.t.StartTS < first {
first = h.t.StartTS
}
if h.t.StartTS > last {
last = h.t.StartTS
}
if len(ev) < 8 {
ev = append(ev, Evidence{
TraceID: h.t.TraceID, When: h.t.StartTS, Prompt: h.t.Prompt, CostUSD: h.t.CostUSD,
Detail: fmt.Sprintf("%s returned %s", h.call.Name, humanBytes(h.call.OutputSize)),
})
}
}
in := Insight{
ID: "context/large-outputs",
Theme: ThemeContext,
Severity: severityFor(cost, len(hits)),
Title: fmt.Sprintf("%d tool results were large enough to dominate the context", len(hits)),
Occurrences: len(hits),
CostUSD: cost,
FirstSeen: first,
LastSeen: last,
Evidence: ev,
}
in.Body = fmt.Sprintf(
"These results total %s. A large tool output is not paid for once — it stays in the context and is re-sent "+
"with every following turn in that run.", humanBytes(bytes))
in.Suggestion = "Narrowing these calls so they return the part that matters — a filter, a head, a smaller glob — " +
"usually costs nothing in usefulness."
return in, true
}
// growth finds runs whose context ballooned without obvious cause.
func (c *ContextWeight) growth(traces []TraceFacts) (Insight, bool) {
var ev []Evidence
var cost float64
first, last := int64(0), int64(0)
for _, t := range traces {
if len(t.LLMCalls) < 3 {
continue
}
start := t.LLMCalls[0].InputTokens + t.LLMCalls[0].CacheRead
end := t.LLMCalls[len(t.LLMCalls)-1].InputTokens + t.LLMCalls[len(t.LLMCalls)-1].CacheRead
if start == 0 || float64(end) < float64(start)*c.GrowthFactor {
continue
}
cost += t.CostUSD
if first == 0 || t.StartTS < first {
first = t.StartTS
}
if t.StartTS > last {
last = t.StartTS
}
ev = append(ev, Evidence{
TraceID: t.TraceID, When: t.StartTS, Prompt: t.Prompt, CostUSD: t.CostUSD,
Detail: fmt.Sprintf("context grew %s → %s over %d model calls",
humanCount(start), humanCount(end), len(t.LLMCalls)),
})
}
if len(ev) < 2 {
return Insight{}, false
}
sort.SliceStable(ev, func(i, j int) bool { return ev[i].CostUSD > ev[j].CostUSD })
if len(ev) > 8 {
ev = ev[:8]
}
in := Insight{
ID: "context/growth",
Theme: ThemeContext,
Severity: severityFor(cost, len(ev)),
Title: fmt.Sprintf("%d runs ended with several times the context they started with", len(ev)),
Occurrences: len(ev),
CostUSD: cost,
FirstSeen: first,
LastSeen: last,
Evidence: ev,
}
in.Body = "Context grows as a run goes on, which is normal. Growing several times over usually means the run " +
"accumulated material it never needed again — early exploration that stayed in the transcript."
in.Suggestion = "Long exploratory runs are cheaper split in two: find the answer in one, then start a fresh run " +
"that begins from it."
return in, true
}
// Waste finds spend that produced nothing.
type Waste struct {
// MinFailures is how many failures of one command shape are needed before
// it reads as a pattern rather than an accident.
MinFailures int
}
func NewWaste() *Waste { return &Waste{MinFailures: 3} }
func (w *Waste) Name() string { return "waste" }
func (w *Waste) Detect(traces []TraceFacts) []Insight {
type fail struct {
count int
cost float64
traces map[string]Evidence
}
byShape := map[string]*fail{}
for _, t := range traces {
for _, call := range t.Calls {
if call.Succeeded {
continue
}
f := byShape[call.Signature]
if f == nil {
f = &fail{traces: map[string]Evidence{}}
byShape[call.Signature] = f
}
f.count++
if _, ok := f.traces[t.TraceID]; !ok {
f.cost += t.CostUSD
f.traces[t.TraceID] = Evidence{
TraceID: t.TraceID, When: t.StartTS, Prompt: t.Prompt, CostUSD: t.CostUSD,
Detail: fmt.Sprintf("%s failed", call.Name),
}
}
}
}
var out []Insight
for shape, f := range byShape {
if f.count < w.MinFailures {
continue
}
var ev []Evidence
first, last := int64(0), int64(0)
for _, e := range f.traces {
if first == 0 || e.When < first {
first = e.When
}
if e.When > last {
last = e.When
}
ev = append(ev, e)
}
sort.SliceStable(ev, func(i, j int) bool { return ev[i].When > ev[j].When })
if len(ev) > 8 {
ev = ev[:8]
}
in := Insight{
ID: "waste/failing/" + slug(shape),
Theme: ThemeWaste,
Severity: severityFor(f.cost, f.count),
Title: fmt.Sprintf("`%s` failed %d times", displayShape(shape), f.count),
Occurrences: f.count,
CostUSD: f.cost,
FirstSeen: first,
LastSeen: last,
Evidence: ev,
Steps: []string{shape},
}
in.Body = fmt.Sprintf(
"This call shape failed %d times across %d %s. Every failure is paid for twice: once for the call, "+
"and again for the model turn that reads the error and decides what to do about it.",
f.count, len(f.traces), plural(len(f.traces), "run", "runs"))
in.Suggestion = "A command that fails this reliably is usually wrong in a fixable way — a missing tool, a " +
"wrong path, a flag that does not exist on this machine. Fixing it once removes the whole series."
out = append(out, in)
}
return out
}
func isRead(name string) bool {
switch name {
case "Read", "read_file", "View", "cat":
return true
}
return false
}
func displayShape(s string) string {
if i := strings.Index(s, ":"); i >= 0 && i+1 < len(s) {
return s[i+1:]
}
return s
}
func humanBytes(n int64) string {
switch {
case n >= 1<<20:
return fmt.Sprintf("%.1fMB", float64(n)/(1<<20))
case n >= 1<<10:
return fmt.Sprintf("%.0fKB", float64(n)/(1<<10))
}
return fmt.Sprintf("%dB", n)
}
func humanCount(n int64) string {
switch {
case n >= 1_000_000:
return fmt.Sprintf("%.1fM", float64(n)/1e6)
case n >= 1000:
return fmt.Sprintf("%.0fk", float64(n)/1000)
}
return fmt.Sprintf("%d", n)
}