Files
zzet--gortex/internal/mcp/tools_analyze_hotspot_modes.go
wehub-resource-sync a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

156 lines
4.7 KiB
Go

package mcp
import (
"sort"
"time"
"github.com/zzet/gortex/internal/analysis"
"github.com/zzet/gortex/internal/graph"
)
// rerankHotspots applies K17's two alternate ranking modes on top of
// the analyzer's complexity score. The legacy mode "complexity" is
// pass-through; "novelty" and "directional" multiply the complexity
// score by a recency weight derived from the blame / releases
// substrate.
//
// Substrate dependency (silent when missing):
//
// novelty reads meta.last_authored.timestamp. Symbols touched
// within window_days get weight >0 (rising toward 1
// as the touch approaches "now"); untouched symbols
// get weight 0 and sort to the bottom.
//
// directional reads meta.added_in.timestamp (the releases-pipeline
// stamp from `gortex enrich releases`). With
// direction="adds" the weight favours recently-added
// symbols; "stable" inverts to favour old additions.
// Symbols missing the metadata silently score 0.
//
// We don't fail when the meta is absent — the analyzer treats this
// as a soft ranker, not a strict filter, so callers get *some*
// ranking even on un-enriched graphs (the unweighted baseline).
func rerankHotspots(entries []analysis.HotspotEntry, g graph.Store, mode, direction string, windowDays int) []analysis.HotspotEntry {
if windowDays <= 0 {
windowDays = 30
}
now := time.Now().UTC()
window := time.Duration(windowDays) * 24 * time.Hour
blame := blameRowsByID(g)
weighted := make([]analysis.HotspotEntry, 0, len(entries))
for _, e := range entries {
n := g.GetNode(e.ID)
if n == nil {
continue
}
var weight float64
switch mode {
case "novelty":
weight = noveltyWeight(blame, n, now, window)
case "directional":
weight = directionalWeight(n, now, window, direction)
default:
weight = 1.0
}
// Scale the complexity score so the existing fields stay
// meaningful — the agent still sees fan_in / fan_out /
// crossings; only the rank order shifts.
e.ComplexityScore = e.ComplexityScore * weight
weighted = append(weighted, e)
}
sort.Slice(weighted, func(i, j int) bool {
if weighted[i].ComplexityScore != weighted[j].ComplexityScore {
return weighted[i].ComplexityScore > weighted[j].ComplexityScore
}
return weighted[i].ID < weighted[j].ID
})
return weighted
}
// noveltyWeight returns 1.0 - days_since_last_authored / windowDays,
// clamped to [0, 1]. Symbols missing the meta return 0 — they sort
// to the bottom rather than getting a free "fully novel" pass.
func noveltyWeight(blame map[string]graph.BlameEnrichment, n *graph.Node, now time.Time, window time.Duration) float64 {
ts := nodeLastAuthoredTime(blame, n)
if ts.IsZero() {
return 0
}
elapsed := now.Sub(ts)
if elapsed < 0 {
return 1.0
}
if elapsed >= window {
return 0
}
return 1.0 - float64(elapsed)/float64(window)
}
// directionalWeight reads meta.added_in.timestamp. direction="adds"
// rewards recently-added symbols; "stable" rewards old additions.
// Empty / unknown direction defaults to "adds".
func directionalWeight(n *graph.Node, now time.Time, window time.Duration, direction string) float64 {
ts := nodeAddedInTime(n)
if ts.IsZero() {
return 0
}
elapsed := now.Sub(ts)
if elapsed < 0 {
elapsed = 0
}
frac := float64(elapsed) / float64(window)
if frac > 1 {
frac = 1
}
switch direction {
case "stable":
return frac // older addition = higher
default: // "adds"
return 1.0 - frac // newer addition = higher
}
}
// nodeLastAuthoredTime returns the meta.last_authored.timestamp as a
// time.Time, or zero when the field isn't populated. Blame writes
// the timestamp as a Unix int64; releases enrichment may write an
// RFC3339 string — we tolerate both.
func nodeLastAuthoredTime(blame map[string]graph.BlameEnrichment, n *graph.Node) time.Time {
e, ok := lastAuthoredFrom(blame, n)
if !ok || e.Timestamp == 0 {
return time.Time{}
}
return time.Unix(e.Timestamp, 0)
}
// nodeAddedInTime returns meta.added_in.timestamp as a time.Time,
// or zero when absent. Same dual encoding as last_authored.
func nodeAddedInTime(n *graph.Node) time.Time {
if n.Meta == nil {
return time.Time{}
}
ai, ok := n.Meta["added_in"].(map[string]any)
if !ok {
return time.Time{}
}
return decodeMetaTimestamp(ai["timestamp"])
}
// decodeMetaTimestamp handles the int-seconds / float-seconds /
// RFC3339-string forms blame and releases enrichment produce. Empty
// or unknown shapes return zero.
func decodeMetaTimestamp(v any) time.Time {
switch t := v.(type) {
case int64:
return time.Unix(t, 0).UTC()
case int:
return time.Unix(int64(t), 0).UTC()
case float64:
return time.Unix(int64(t), 0).UTC()
case string:
if ts, err := time.Parse(time.RFC3339, t); err == nil {
return ts.UTC()
}
}
return time.Time{}
}