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
528 lines
19 KiB
Go
528 lines
19 KiB
Go
// Package reach precomputes per-depth incoming-reachability sets on
|
||
// every impact-seed node so blast-radius queries (AnalyzeImpact,
|
||
// explain_change_impact, simulate_chain step-impact, prompt
|
||
// SafeToChange / PreCommit, diff_context) answer in O(seeds × reach)
|
||
// map lookups instead of a live BFS.
|
||
//
|
||
// The package depends only on internal/graph; it is imported by the
|
||
// indexer (build site) and the analysis package (consumer) so the
|
||
// import graph stays acyclic — analysis already imports indexer in
|
||
// its bench tests.
|
||
package reach
|
||
|
||
import (
|
||
"context"
|
||
"sort"
|
||
"sync/atomic"
|
||
|
||
"github.com/zzet/gortex/internal/graph"
|
||
"github.com/zzet/gortex/internal/progress"
|
||
)
|
||
|
||
// Reachability index keys. Each value is a []string of node IDs that
|
||
// can reach the carrier node via incoming edges within the named
|
||
// number of hops. Tiers are per-depth (not cumulative) so they map
|
||
// 1:1 onto AnalyzeImpact's ByDepth tiers.
|
||
//
|
||
// Parallel `*_conf` and `*_label` keys carry the representative
|
||
// in-edge's Confidence and ConfidenceLabel for each ID, indexed by
|
||
// position. They turn the fast path into a pure lookup — no
|
||
// GetInEdges calls at query time — so a precomputed AnalyzeImpact
|
||
// stays sub-ms even on graphs with high fan-in.
|
||
//
|
||
// Stored on Node.Meta — gob-serialized into the daemon snapshot so
|
||
// warm starts keep O(1) impact lookups without paying the build cost.
|
||
const (
|
||
MetaReachD1 = "reach_d1"
|
||
MetaReachD2 = "reach_d2"
|
||
MetaReachD3 = "reach_d3"
|
||
MetaReachD1Conf = "reach_d1_conf"
|
||
MetaReachD2Conf = "reach_d2_conf"
|
||
MetaReachD3Conf = "reach_d3_conf"
|
||
MetaReachD1Label = "reach_d1_label"
|
||
MetaReachD2Label = "reach_d2_label"
|
||
MetaReachD3Label = "reach_d3_label"
|
||
|
||
// MetaReachBuild is a monotonic build-generation counter stamped
|
||
// on every node the indexer touched in the most recent reach pass.
|
||
// Consumers compare it against the graph-level counter on
|
||
// AnalyzeImpact entry to decide whether to trust the precomputed
|
||
// sets or fall back to a live walk. The Meta value is a uint64.
|
||
MetaReachBuild = "reach_build"
|
||
)
|
||
|
||
// ReachableEdge returns true when an edge participates in the impact
|
||
// graph. Mirrors AnalyzeImpact's filter exactly so the precomputed
|
||
// sets and the live walk agree on membership. Exported so the
|
||
// AnalyzeImpact live-walk path can share the same filter and tests
|
||
// can assert filter parity across the two code paths.
|
||
func ReachableEdge(k graph.EdgeKind) bool {
|
||
return k != graph.EdgeDefines && k != graph.EdgeMemberOf
|
||
}
|
||
|
||
// ImpactSeedKind returns true for node kinds that are sensible impact
|
||
// seeds — the symbols a developer actually changes. Files, imports,
|
||
// parameters, and similar wiring kinds carry no useful blast radius,
|
||
// so we skip them to keep the index lean.
|
||
func ImpactSeedKind(k graph.NodeKind) bool {
|
||
switch k {
|
||
case graph.KindFunction, graph.KindMethod,
|
||
graph.KindType, graph.KindInterface,
|
||
graph.KindField, graph.KindEnumMember,
|
||
graph.KindConstant, graph.KindVariable:
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
// Stats reports the work BuildIndex did.
|
||
type Stats struct {
|
||
NodesIndexed int // nodes that received reach_d* entries
|
||
EntriesD1 int // total reach_d1 IDs across all indexed nodes
|
||
EntriesD2 int // total reach_d2 IDs
|
||
EntriesD3 int // total reach_d3 IDs
|
||
Build uint64 // generation tag stamped on every indexed node
|
||
}
|
||
|
||
// buildCounter is a process-wide monotonic generation counter used to
|
||
// invalidate cached reach sets across snapshot reloads and
|
||
// incremental rebuilds. Bumped on every BuildIndex / ClearIndex call.
|
||
var buildCounter uint64
|
||
|
||
// BuildIndex precomputes per-depth incoming reachability sets for
|
||
// every impact-seed node in g and stores them under Node.Meta as
|
||
// []string slices keyed reach_d1 / reach_d2 / reach_d3. Tiers are
|
||
// per-depth (a node appears in at most one tier per seed). The build
|
||
// generation is stamped under MetaReachBuild so consumers can detect
|
||
// stale entries after partial rebuilds.
|
||
//
|
||
// Cost: O(N · E_avg) where E_avg is the average reach-3 fan-in
|
||
// (typically <200 nodes per seed on real call graphs). Empirically
|
||
// completes in well under a second on 50k-node graphs. Run after all
|
||
// graph-shaping passes settle (resolver, semantic enrichment, cross-
|
||
// repo edges, gRPC stub resolution).
|
||
//
|
||
// Safe to call repeatedly: existing reach_d* entries are overwritten
|
||
// and the build counter advances each time so any consumer that read
|
||
// an entry from a prior generation will fall back to a live walk.
|
||
func BuildIndex(g graph.Store) *Stats {
|
||
return BuildIndexCtx(context.Background(), g)
|
||
}
|
||
|
||
// BuildIndexCtx is BuildIndex with intra-stage progress reporting.
|
||
// Pulls a progress.Reporter from ctx (no-op when none is attached) and
|
||
// emits per-seed progress every reachProgressEvery seeds — the pass
|
||
// otherwise looks hung from the outside, since "reach" is one of the
|
||
// longest stages on monorepo-scale graphs (~200 s on k8s with 150 k
|
||
// impact seeds). Pure operator-visibility instrumentation: the per-
|
||
// report call is cheap (no I/O when the reporter is the default no-op).
|
||
func BuildIndexCtx(ctx context.Context, g graph.Store) *Stats {
|
||
if g == nil {
|
||
return &Stats{}
|
||
}
|
||
reporter := progress.FromContext(ctx)
|
||
mu := g.ResolveMutex()
|
||
mu.Lock()
|
||
defer mu.Unlock()
|
||
build := atomic.AddUint64(&buildCounter, 1)
|
||
stats := &Stats{Build: build}
|
||
|
||
nodes := g.AllNodes()
|
||
// Sort by ID so the deterministic iteration order produces stable
|
||
// reach slices — important for snapshot determinism and for tests
|
||
// that compare reach payloads across runs.
|
||
sort.Slice(nodes, func(i, j int) bool { return nodes[i].ID < nodes[j].ID })
|
||
|
||
// Pre-count impact seeds so the progress denominator is real, not
|
||
// the total node count (the loop skips ~80% of nodes — files,
|
||
// imports, params, vars, …).
|
||
var seedTotal int
|
||
for _, n := range nodes {
|
||
if n != nil && ImpactSeedKind(n.Kind) {
|
||
seedTotal++
|
||
}
|
||
}
|
||
reporter.Report("reachability index", 0, seedTotal)
|
||
|
||
const reachProgressEvery = 1000
|
||
seedsDone := 0
|
||
// Collect the seed nodes we stamp so we can persist the Meta back
|
||
// through the store in one batch at the end. On the in-memory
|
||
// backend the in-place stamp already persists (n is canonical); on
|
||
// disk backends n is a GetNode reconstruction, so without
|
||
// the write-back the whole reach index would be computed and then
|
||
// thrown away. Mirrors the per-seed AddNode in Lookup's slow path.
|
||
stamped := make([]*graph.Node, 0, seedTotal)
|
||
for _, n := range nodes {
|
||
if n == nil || !ImpactSeedKind(n.Kind) {
|
||
continue
|
||
}
|
||
tiers := compute(g, n.ID)
|
||
if n.Meta == nil {
|
||
n.Meta = make(map[string]any, 10)
|
||
}
|
||
// Always stamp the build counter so a node with no reach
|
||
// (sink with zero callers) still proves "we tried" — without
|
||
// this, lookups for sink nodes would fall back to a live walk
|
||
// on every call.
|
||
n.Meta[MetaReachBuild] = build
|
||
setOrDeleteStrings(n.Meta, MetaReachD1, tiers[0].IDs)
|
||
setOrDeleteStrings(n.Meta, MetaReachD2, tiers[1].IDs)
|
||
setOrDeleteStrings(n.Meta, MetaReachD3, tiers[2].IDs)
|
||
setOrDeleteFloats(n.Meta, MetaReachD1Conf, tiers[0].Conf)
|
||
setOrDeleteFloats(n.Meta, MetaReachD2Conf, tiers[1].Conf)
|
||
setOrDeleteFloats(n.Meta, MetaReachD3Conf, tiers[2].Conf)
|
||
setOrDeleteStrings(n.Meta, MetaReachD1Label, tiers[0].Labels)
|
||
setOrDeleteStrings(n.Meta, MetaReachD2Label, tiers[1].Labels)
|
||
setOrDeleteStrings(n.Meta, MetaReachD3Label, tiers[2].Labels)
|
||
|
||
stamped = append(stamped, n)
|
||
stats.NodesIndexed++
|
||
stats.EntriesD1 += len(tiers[0].IDs)
|
||
stats.EntriesD2 += len(tiers[1].IDs)
|
||
stats.EntriesD3 += len(tiers[2].IDs)
|
||
|
||
seedsDone++
|
||
if seedsDone%reachProgressEvery == 0 {
|
||
reporter.Report("reachability index", seedsDone, seedTotal)
|
||
}
|
||
}
|
||
// Persist every stamped node's Meta back through the store in one
|
||
// batch (no-op-ish on the in-memory backend, the durable write on
|
||
// disk backends). AddBatch with no edges only upserts the nodes.
|
||
if len(stamped) > 0 {
|
||
g.AddBatch(stamped, nil)
|
||
}
|
||
reporter.Report("reachability index", seedsDone, seedTotal)
|
||
return stats
|
||
}
|
||
|
||
// tier holds the per-depth precomputed payload: a parallel triple of
|
||
// (ID, edge-confidence, edge-confidence-label) so the fast path can
|
||
// hydrate an ImpactEntry without a single GetInEdges call at query
|
||
// time. Sorted by ID for stable snapshot output and test parity.
|
||
type tier struct {
|
||
IDs []string
|
||
Conf []float64
|
||
Labels []string
|
||
}
|
||
|
||
// setOrDeleteStrings keeps Meta lean — empty tiers are removed rather
|
||
// than stored as []string{} so cold-start gob payloads stay small and
|
||
// downstream code can rely on "key absent" == "no callers at this tier".
|
||
func setOrDeleteStrings(m map[string]any, key string, value []string) {
|
||
if len(value) == 0 {
|
||
delete(m, key)
|
||
return
|
||
}
|
||
m[key] = value
|
||
}
|
||
|
||
// setOrDeleteFloats mirrors setOrDeleteStrings for the parallel
|
||
// confidence arrays.
|
||
func setOrDeleteFloats(m map[string]any, key string, value []float64) {
|
||
if len(value) == 0 {
|
||
delete(m, key)
|
||
return
|
||
}
|
||
m[key] = value
|
||
}
|
||
|
||
// compute walks incoming edges from seed up to depth 3 and returns
|
||
// per-depth tiers carrying every ID encountered plus the
|
||
// representative in-edge's confidence + label. Each ID appears in at
|
||
// most one tier (BFS visited set is shared across depths). Edges are
|
||
// filtered with ReachableEdge so the result matches AnalyzeImpact;
|
||
// file / import nodes are walked through for fan-out but excluded
|
||
// from the tier slices.
|
||
func compute(g graph.Store, seedID string) [3]tier {
|
||
var result [3]tier
|
||
visited := map[string]struct{}{seedID: {}}
|
||
current := []string{seedID}
|
||
for depth := 1; depth <= 3 && len(current) > 0; depth++ {
|
||
// Batch the whole BFS level's incoming-edge fetch into one
|
||
// backend round-trip. The per-node g.GetInEdges(id) form issued
|
||
// one query per node on disk backends — an
|
||
// O(reachable-nodes) query storm that turned a single
|
||
// AnalyzeImpact live walk into a multi-minute (timeout) call on
|
||
// a disk backend. GetInEdgesByNodeIDs collapses it to one query per depth.
|
||
inEdges := g.GetInEdgesByNodeIDs(current)
|
||
|
||
// First pass: discover this level's new From-nodes in
|
||
// deterministic (current-order, edge-order) order, recording the
|
||
// representative in-edge for each.
|
||
type cand struct {
|
||
from string
|
||
conf float64
|
||
kind graph.EdgeKind
|
||
}
|
||
var next []string
|
||
var cands []cand
|
||
for _, id := range current {
|
||
for _, e := range inEdges[id] {
|
||
if !ReachableEdge(e.Kind) {
|
||
continue
|
||
}
|
||
if _, seen := visited[e.From]; seen {
|
||
continue
|
||
}
|
||
visited[e.From] = struct{}{}
|
||
next = append(next, e.From)
|
||
cands = append(cands, cand{from: e.From, conf: e.Confidence, kind: e.Kind})
|
||
}
|
||
}
|
||
|
||
// Batch the node-kind lookups too — the original called
|
||
// g.GetNode(e.From) once per discovered node (a second per-node
|
||
// query storm on disk backends). File / import nodes are still
|
||
// walked through for fan-out (they stay in `next`) but excluded
|
||
// from the result tiers, exactly as before.
|
||
ids := make([]string, len(cands))
|
||
for i := range cands {
|
||
ids[i] = cands[i].from
|
||
}
|
||
nodes := g.GetNodesByIDs(ids)
|
||
slot := depth - 1
|
||
for _, c := range cands {
|
||
n := nodes[c.from]
|
||
if n == nil || n.Kind == graph.KindFile || n.Kind == graph.KindImport {
|
||
continue
|
||
}
|
||
result[slot].IDs = append(result[slot].IDs, c.from)
|
||
result[slot].Conf = append(result[slot].Conf, c.conf)
|
||
result[slot].Labels = append(result[slot].Labels,
|
||
graph.ConfidenceLabelFor(c.kind, c.conf))
|
||
}
|
||
current = next
|
||
}
|
||
for i := range result {
|
||
sortTierByID(&result[i])
|
||
}
|
||
return result
|
||
}
|
||
|
||
// sortTierByID sorts a tier's parallel arrays in lock-step by ID so
|
||
// repeated builds produce identical snapshots and consumers can
|
||
// binary-search for membership.
|
||
func sortTierByID(t *tier) {
|
||
n := len(t.IDs)
|
||
if n <= 1 {
|
||
return
|
||
}
|
||
idx := make([]int, n)
|
||
for i := range idx {
|
||
idx[i] = i
|
||
}
|
||
sort.Slice(idx, func(a, b int) bool { return t.IDs[idx[a]] < t.IDs[idx[b]] })
|
||
ids := make([]string, n)
|
||
conf := make([]float64, n)
|
||
labels := make([]string, n)
|
||
for newPos, oldPos := range idx {
|
||
ids[newPos] = t.IDs[oldPos]
|
||
conf[newPos] = t.Conf[oldPos]
|
||
labels[newPos] = t.Labels[oldPos]
|
||
}
|
||
t.IDs = ids
|
||
t.Conf = conf
|
||
t.Labels = labels
|
||
}
|
||
|
||
// ClearIndex removes reach_d* and reach_build entries from every node
|
||
// and bumps the build counter so any cached lookups dated to a prior
|
||
// generation are invalidated. Use when the graph topology has shifted
|
||
// so far that a full rebuild is cheaper than incremental invalidation.
|
||
func ClearIndex(g graph.Store) {
|
||
if g == nil {
|
||
return
|
||
}
|
||
mu := g.ResolveMutex()
|
||
mu.Lock()
|
||
defer mu.Unlock()
|
||
atomic.AddUint64(&buildCounter, 1)
|
||
for _, n := range g.AllNodes() {
|
||
if n == nil || n.Meta == nil {
|
||
continue
|
||
}
|
||
for _, k := range []string{
|
||
MetaReachD1, MetaReachD2, MetaReachD3,
|
||
MetaReachD1Conf, MetaReachD2Conf, MetaReachD3Conf,
|
||
MetaReachD1Label, MetaReachD2Label, MetaReachD3Label,
|
||
MetaReachBuild,
|
||
} {
|
||
delete(n.Meta, k)
|
||
}
|
||
}
|
||
}
|
||
|
||
// Entry is one precomputed reach record: a node ID and the
|
||
// representative in-edge's confidence + confidence-label so the
|
||
// AnalyzeImpact fast path can hydrate an ImpactEntry with zero
|
||
// GetInEdges calls.
|
||
type Entry struct {
|
||
ID string
|
||
Conf float64
|
||
Label string
|
||
}
|
||
|
||
// Lookup returns the per-depth reach for seedID. On a fresh cache hit
|
||
// (build counter matches current generation) it returns the cached
|
||
// tiers in sub-millisecond. On a miss — first call for this seed, or
|
||
// the global build counter has advanced past the stamped value
|
||
// because the graph mutated — it runs the BFS on demand under
|
||
// g.ResolveMutex(), caches the result onto n.Meta, and returns the
|
||
// fresh tiers. Returns hit=false only when seedID names no node or
|
||
// names a node whose kind is not an impact seed (KindFunction,
|
||
// KindMethod, KindType, KindInterface).
|
||
//
|
||
// This is the "lazy reach index" — the eager BuildIndex pass that
|
||
// used to walk every impact seed during cold-index has been removed
|
||
// from the IndexCtx hot path because the breakeven was untenable on
|
||
// monorepo graphs: ~2000 s of cold-index work on k8s to save ~10 ms
|
||
// per query, requiring ~200 k queries to break even. The lazy form
|
||
// pays the 10 ms only on the first AnalyzeImpact call that names a
|
||
// given seed, then caches forever. BuildIndex remains available for
|
||
// `gortex enrich reach` (explicit prebuild) and for callers that
|
||
// want to pay the cost up front under controlled conditions.
|
||
func Lookup(g graph.Store, seedID string) (d1, d2, d3 []Entry, hit bool) {
|
||
if g == nil {
|
||
return nil, nil, nil, false
|
||
}
|
||
n := g.GetNode(seedID)
|
||
if n == nil {
|
||
return nil, nil, nil, false
|
||
}
|
||
if !ImpactSeedKind(n.Kind) {
|
||
return nil, nil, nil, false
|
||
}
|
||
|
||
currentBuild := atomic.LoadUint64(&buildCounter)
|
||
// Fast path: existing stamp matches the current build generation.
|
||
if d1, d2, d3, ok := readCached(n, currentBuild); ok {
|
||
return d1, d2, d3, true
|
||
}
|
||
|
||
// Slow path: compute the tiers and cache them. Acquire the resolve
|
||
// mutex so the Meta writes don't race other graph-wide passes that
|
||
// already serialise on it (markTestSymbolsAndEmitEdges, clone
|
||
// detection, ResolveTemporalCalls).
|
||
mu := g.ResolveMutex()
|
||
mu.Lock()
|
||
defer mu.Unlock()
|
||
|
||
// Re-check after acquiring the lock: another goroutine may have
|
||
// computed and cached this seed while we were waiting.
|
||
if d1, d2, d3, ok := readCached(n, currentBuild); ok {
|
||
return d1, d2, d3, true
|
||
}
|
||
|
||
tiers := compute(g, seedID)
|
||
if n.Meta == nil {
|
||
n.Meta = make(map[string]any, 10)
|
||
}
|
||
n.Meta[MetaReachBuild] = currentBuild
|
||
setOrDeleteStrings(n.Meta, MetaReachD1, tiers[0].IDs)
|
||
setOrDeleteStrings(n.Meta, MetaReachD2, tiers[1].IDs)
|
||
setOrDeleteStrings(n.Meta, MetaReachD3, tiers[2].IDs)
|
||
setOrDeleteFloats(n.Meta, MetaReachD1Conf, tiers[0].Conf)
|
||
setOrDeleteFloats(n.Meta, MetaReachD2Conf, tiers[1].Conf)
|
||
setOrDeleteFloats(n.Meta, MetaReachD3Conf, tiers[2].Conf)
|
||
setOrDeleteStrings(n.Meta, MetaReachD1Label, tiers[0].Labels)
|
||
setOrDeleteStrings(n.Meta, MetaReachD2Label, tiers[1].Labels)
|
||
setOrDeleteStrings(n.Meta, MetaReachD3Label, tiers[2].Labels)
|
||
|
||
// Persist the freshly-stamped Meta through the store. On the
|
||
// in-memory backend n is the canonical node, so the mutations above
|
||
// already stuck — AddNode re-inserts the same pointer idempotently.
|
||
// On disk backends n is a per-call reconstruction returned
|
||
// by GetNode, so the in-place stamp would otherwise be discarded the
|
||
// moment this function returns: the lazy reach cache would never
|
||
// survive a single query, forcing a full recompute on every
|
||
// AnalyzeImpact / explain_change_impact / get_callers call. AddNode
|
||
// upserts the Meta column so the cache actually sticks.
|
||
g.AddNode(n)
|
||
|
||
d1 = readTier(n.Meta, MetaReachD1, MetaReachD1Conf, MetaReachD1Label)
|
||
d2 = readTier(n.Meta, MetaReachD2, MetaReachD2Conf, MetaReachD2Label)
|
||
d3 = readTier(n.Meta, MetaReachD3, MetaReachD3Conf, MetaReachD3Label)
|
||
return d1, d2, d3, true
|
||
}
|
||
|
||
// readCached reads the stamped reach tiers off n.Meta when the stamp
|
||
// matches currentBuild. Returns ok=false when the stamp is missing
|
||
// (never built), stale (graph has changed since), or has the wrong
|
||
// Go type (snapshot from an older format).
|
||
func readCached(n *graph.Node, currentBuild uint64) (d1, d2, d3 []Entry, ok bool) {
|
||
if n.Meta == nil {
|
||
return nil, nil, nil, false
|
||
}
|
||
raw, present := n.Meta[MetaReachBuild]
|
||
if !present {
|
||
return nil, nil, nil, false
|
||
}
|
||
var stamped uint64
|
||
switch v := raw.(type) {
|
||
case uint64:
|
||
stamped = v
|
||
case uint32:
|
||
stamped = uint64(v)
|
||
case int:
|
||
stamped = uint64(v)
|
||
case int64:
|
||
stamped = uint64(v)
|
||
default:
|
||
return nil, nil, nil, false
|
||
}
|
||
if stamped != currentBuild {
|
||
return nil, nil, nil, false
|
||
}
|
||
d1 = readTier(n.Meta, MetaReachD1, MetaReachD1Conf, MetaReachD1Label)
|
||
d2 = readTier(n.Meta, MetaReachD2, MetaReachD2Conf, MetaReachD2Label)
|
||
d3 = readTier(n.Meta, MetaReachD3, MetaReachD3Conf, MetaReachD3Label)
|
||
return d1, d2, d3, true
|
||
}
|
||
|
||
// InvalidateIndex advances the global build counter so every future
|
||
// Lookup recomputes against the new graph state. Call this whenever
|
||
// the graph mutates in a way that could change reach sets — at the
|
||
// end of every IndexCtx / IncrementalReindex / global-pass run.
|
||
//
|
||
// The cached Meta entries on nodes that survived the mutation are
|
||
// not deleted; they're simply tagged with a stale build counter, so
|
||
// the next Lookup on each falls through to a fresh compute. This is
|
||
// strictly cheaper than walking all nodes to clear Meta — the
|
||
// invalidation is O(1) and only the seeds actually queried pay the
|
||
// recompute cost.
|
||
func InvalidateIndex() {
|
||
atomic.AddUint64(&buildCounter, 1)
|
||
}
|
||
|
||
// readTier reconstructs an []Entry from the parallel arrays. Missing
|
||
// confidence / label keys (or shorter slices) zero-fill so older
|
||
// snapshots that lack the parallel data degrade gracefully — the
|
||
// caller still sees the ID set, just with zero confidence.
|
||
func readTier(meta map[string]any, idsKey, confKey, labelKey string) []Entry {
|
||
ids, _ := meta[idsKey].([]string)
|
||
if len(ids) == 0 {
|
||
return nil
|
||
}
|
||
conf, _ := meta[confKey].([]float64)
|
||
labels, _ := meta[labelKey].([]string)
|
||
out := make([]Entry, len(ids))
|
||
for i, id := range ids {
|
||
out[i].ID = id
|
||
if i < len(conf) {
|
||
out[i].Conf = conf[i]
|
||
}
|
||
if i < len(labels) {
|
||
out[i].Label = labels[i]
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// BuildCounter returns the current generation tag. Tests use it to
|
||
// assert that a rebuild actually bumped the counter.
|
||
func BuildCounter() uint64 {
|
||
return atomic.LoadUint64(&buildCounter)
|
||
}
|