mirror of
https://github.com/luxfi/aml.git
synced 2026-09-24 00:25:20 +00:00
Two defects in the same value. TIES. A decision score is 1 - prod(1-weight) over a fixed set of rule weights, rounded to four places, so the score alphabet is a handful of atoms and the modal atom is exactly zero. pava ran over individual samples, and a run of unproductive-then-productive at one score is already non-decreasing — so nothing merged, every sample stayed its own block, and the dedup that followed kept the MAX of two blocks sharing a centroid. Every plateau holding a positive was reported as certainty, at the top of the range where declines happen. Ties are now pooled into one block per distinct score before the algorithm starts, which is what makes the answer isotonic regression; a rounding collision between two centroids pools rather than picks. THE SHAPE GATE. P(score, shape) let the caller supply the value being compared, and every internal call site supplied m.Shape — so the training-serving skew control was trivially satisfied everywhere except the one place a live shape was threaded in. Reading is now impossible without Under(shape), which returns a Reader: the gate is at the bind, where the current shape is a fact the server observed, and every consumer downstream holds a value for which the question is already answered and has no shape to pass. Reliability draws no chart for a reader that cannot answer. Co-authored-by: Hanzo Dev <[email protected]>
575 lines
20 KiB
Go
575 lines
20 KiB
Go
// Package evaluate measures a decision rule against what actually happened, and
|
|
// replays a candidate one over recorded history to see what it would have done.
|
|
//
|
|
// WHY NOT ACCURACY. Fraud is rare. A stream at one productive event in a
|
|
// thousand is 99.9% accurate if the model says "fine" to everything, and that
|
|
// model is worth nothing. Accuracy on an imbalanced stream is not a weak metric,
|
|
// it is an actively misleading one, so this package does not compute it. What it
|
|
// computes instead:
|
|
//
|
|
// ROC-AUC the probability that a productive event outranks an unproductive
|
|
// one. Insensitive to the base rate, which makes it comparable
|
|
// across tenants — and, for the same reason, optimistic-looking on
|
|
// a rare-event stream, because the enormous negative class makes
|
|
// false positives cheap in its arithmetic.
|
|
// PR-AUC average precision: the area under precision-recall, computed as
|
|
// the sum of precision at each recall step rather than by
|
|
// trapezoid, because interpolating between operating points on a PR
|
|
// curve credits a model with points it cannot actually reach. This
|
|
// is the number that moves when a rare-event model gets better, and
|
|
// it is the one to argue about.
|
|
// Prevalence reported beside both, always. An AUC without the base rate is
|
|
// uninterpretable, and a PR-AUC is bounded below by the prevalence,
|
|
// so 0.1 is excellent at 1-in-1000 and worthless at 1-in-5.
|
|
// Cost the only metric with a unit anybody outside the team cares
|
|
// about. A miss and a false alarm have different prices; the
|
|
// cost-minimising threshold is where the marginal one of each
|
|
// balances, and it is almost never where a round number sits.
|
|
//
|
|
// EVERY RATE IS A POINTER. An unmeasured proportion reported as 0.0 reads as a
|
|
// perfect model, and on a plane where most events are unjudged that is the
|
|
// default state. Absent means absent.
|
|
//
|
|
// THE COST ARITHMETIC IS EXACT. Prices are int64 nano-units multiplied by
|
|
// counts. No float touches money here: a cost curve compared across thresholds
|
|
// in floating point can order two thresholds by rounding noise, and the answer
|
|
// is which one to put in production.
|
|
package evaluate
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"math"
|
|
"sort"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/luxfi/aml/pkg/calibrate"
|
|
"github.com/luxfi/aml/pkg/policy"
|
|
"github.com/luxfi/aml/pkg/replay"
|
|
)
|
|
|
|
// Observation is one recorded decision: what the model said, what the system
|
|
// did, and what turned out to be true.
|
|
//
|
|
// Disposition is replay.Disposition and not a second vocabulary. The engine's
|
|
// exhaustive search already refuses to name a winner without judged events, and
|
|
// a parallel set of label words would leave it permanently unable to read the
|
|
// labels this plane collects.
|
|
type Observation struct {
|
|
// ID identifies the decision, so a report can point at the rows behind a
|
|
// number instead of only stating it.
|
|
ID string
|
|
// At is when it was decided. The order of a slice of these is the order they
|
|
// happened, and every temporal split in this package relies on that.
|
|
At time.Time
|
|
// Score is the raw model score in [0,1] AS RECORDED AT THE TIME. It is not
|
|
// recomputed: the rings have moved on, and re-scoring history through a model
|
|
// that has since learned from that same history is how a backtest comes to
|
|
// know the future.
|
|
Score float64
|
|
// Action is what the live system actually did.
|
|
Action string
|
|
// Disposition is what a human or a processor concluded, when anyone has.
|
|
Disposition replay.Disposition
|
|
// Weight down-weights a low-confidence label. Zero means one.
|
|
Weight float64
|
|
}
|
|
|
|
func (o Observation) weight() float64 {
|
|
if o.Weight <= 0 {
|
|
return 1
|
|
}
|
|
return o.Weight
|
|
}
|
|
|
|
func (o Observation) judged() bool {
|
|
return o.Disposition == replay.Productive || o.Disposition == replay.Unproductive
|
|
}
|
|
|
|
func (o Observation) productive() bool { return o.Disposition == replay.Productive }
|
|
|
|
// Confusion is the four counts at one operating point, weighted.
|
|
type Confusion struct {
|
|
// TP is productive and flagged; FN is productive and let through — the
|
|
// misses, which is what Cost.Miss prices.
|
|
TP float64 `json:"tp"`
|
|
FN float64 `json:"fn"`
|
|
// FP is unproductive and flagged — the false alarms, which Cost.Alarm
|
|
// prices; TN is unproductive and let through.
|
|
FP float64 `json:"fp"`
|
|
TN float64 `json:"tn"`
|
|
}
|
|
|
|
// Metrics is the report. Every proportion is absent rather than zero when
|
|
// nothing supports it.
|
|
type Metrics struct {
|
|
// Rows is every observation looked at; Judged is how many carry a
|
|
// disposition. The gap between them is the honest limit on everything below,
|
|
// and it is reported first for that reason.
|
|
Rows int `json:"rows"`
|
|
Judged int `json:"judged"`
|
|
// Productive and Unproductive are the two judged classes, so the imbalance is
|
|
// visible before anyone reads a rate computed over it.
|
|
Productive int `json:"productive"`
|
|
Unproductive int `json:"unproductive"`
|
|
// Prevalence is the productive share of the judged set.
|
|
Prevalence *float64 `json:"prevalence,omitempty"`
|
|
// ROC is the area under the receiver-operating curve, with midranks for
|
|
// ties. Absent without both classes.
|
|
ROC *float64 `json:"roc,omitempty"`
|
|
// PR is average precision — the area under precision-recall by the step
|
|
// definition. This is the imbalanced-data number.
|
|
PR *float64 `json:"pr,omitempty"`
|
|
// Threshold is the operating point everything below is measured at.
|
|
Threshold *float64 `json:"threshold,omitempty"`
|
|
// Precision, Recall and F1 at that point.
|
|
Precision *float64 `json:"precision,omitempty"`
|
|
Recall *float64 `json:"recall,omitempty"`
|
|
F1 *float64 `json:"f1,omitempty"`
|
|
// Alarm is the share of the judged stream flagged at that point — the
|
|
// realised review rate, which is the number an operations team is actually
|
|
// resourced against.
|
|
Alarm *float64 `json:"alarm,omitempty"`
|
|
// Lift is precision divided by prevalence: how much better than chance the
|
|
// flagged set is. One means the model is choosing at random.
|
|
Lift *float64 `json:"lift,omitempty"`
|
|
// Confusion is the four counts at the operating point.
|
|
Confusion *Confusion `json:"confusion,omitempty"`
|
|
// CostNano is what being wrong cost at that point, in nano-units, and
|
|
// BestNano is the lowest it reaches anywhere, at BestThreshold. Absent when
|
|
// the tenant has not stated a price, because a cost computed from unstated
|
|
// prices is a number with no meaning and a decimal point on it.
|
|
CostNano *int64 `json:"cost_nano,omitempty"`
|
|
BestNano *int64 `json:"best_nano,omitempty"`
|
|
BestThreshold *float64 `json:"best_threshold,omitempty"`
|
|
// Brier is the calibration error of the supplied map on this data. A
|
|
// well-ranked, badly-calibrated model makes every rung of the policy mean
|
|
// something other than what it says, and ROC and PR cannot see that at all —
|
|
// both are rank statistics, and a monotone calibration cannot change either.
|
|
Brier *float64 `json:"brier,omitempty"`
|
|
// Refusal names why a report is thin when it is. Silence and a clean result
|
|
// are the same bytes otherwise.
|
|
Refusal string `json:"refusal,omitempty"`
|
|
}
|
|
|
|
// Point is one operating point of a curve.
|
|
type Point struct {
|
|
// Threshold is the score at or above which an observation is flagged.
|
|
Threshold float64 `json:"threshold"`
|
|
// TruePositive and FalsePositive are the ROC axes (recall and fall-out).
|
|
TruePositive float64 `json:"tpr"`
|
|
FalsePositive float64 `json:"fpr"`
|
|
// Precision is the PR axis against Recall, which equals TruePositive.
|
|
Precision float64 `json:"precision"`
|
|
// CostNano is what this point costs under the stated prices. Zero when no
|
|
// price is stated; Metrics is where absence is expressed, and a curve is a
|
|
// dense array a console plots.
|
|
CostNano int64 `json:"cost_nano"`
|
|
}
|
|
|
|
// Measure computes the report at one operating point.
|
|
//
|
|
// threshold is on the SCORE, not the probability, because the score is what was
|
|
// recorded and a calibration can be refitted afterwards. A caller holding a
|
|
// policy over probabilities converts once, at the boundary, rather than making
|
|
// every metric here depend on a map that may not exist.
|
|
//
|
|
// cal may be the zero Reader — no calibration, or one whose shape has moved.
|
|
// Brier is then absent and everything else is unaffected, since every other
|
|
// metric here is a rank statistic. It is a READER and not a Map because the
|
|
// question "does this map apply to these coordinates" is answered once, at the
|
|
// boundary that knows the live shape, and never re-asked here.
|
|
func Measure(obs []Observation, threshold float64, cost policy.Cost, cal calibrate.Reader) Metrics {
|
|
m := Metrics{Rows: len(obs)}
|
|
judged := make([]Observation, 0, len(obs))
|
|
for _, o := range obs {
|
|
if !o.judged() || math.IsNaN(o.Score) || math.IsInf(o.Score, 0) {
|
|
continue
|
|
}
|
|
judged = append(judged, o)
|
|
if o.productive() {
|
|
m.Productive++
|
|
} else {
|
|
m.Unproductive++
|
|
}
|
|
}
|
|
m.Judged = len(judged)
|
|
if m.Judged == 0 {
|
|
m.Refusal = "nothing in this window has been judged, so no rate can be measured"
|
|
return m
|
|
}
|
|
prev := float64(m.Productive) / float64(m.Judged)
|
|
m.Prevalence = ptr(round6(prev))
|
|
|
|
if m.Productive == 0 || m.Unproductive == 0 {
|
|
m.Refusal = "the judged observations are all one class, so separation cannot be measured"
|
|
} else {
|
|
m.ROC = ptr(round6(roc(judged)))
|
|
m.PR = ptr(round6(averagePrecision(judged)))
|
|
}
|
|
|
|
c := confusionAt(judged, threshold)
|
|
m.Threshold = ptr(round6(threshold))
|
|
m.Confusion = &c
|
|
if flagged := c.TP + c.FP; flagged > 0 {
|
|
p := c.TP / flagged
|
|
m.Precision = ptr(round6(p))
|
|
if prev > 0 {
|
|
m.Lift = ptr(round6(p / prev))
|
|
}
|
|
}
|
|
if actual := c.TP + c.FN; actual > 0 {
|
|
m.Recall = ptr(round6(c.TP / actual))
|
|
}
|
|
if total := c.TP + c.FP + c.TN + c.FN; total > 0 {
|
|
m.Alarm = ptr(round6((c.TP + c.FP) / total))
|
|
}
|
|
if m.Precision != nil && m.Recall != nil && (*m.Precision+*m.Recall) > 0 {
|
|
m.F1 = ptr(round6(2 * *m.Precision * *m.Recall / (*m.Precision + *m.Recall)))
|
|
}
|
|
if cost.Stated() {
|
|
v, ok := nano(c, cost)
|
|
best, at, okBest := minimum(judged, cost)
|
|
if ok && okBest {
|
|
m.CostNano = ptrInt(v)
|
|
m.BestNano, m.BestThreshold = ptrInt(best), ptr(round6(at))
|
|
} else if m.Refusal == "" {
|
|
m.Refusal = "the stated prices cannot be multiplied by this many observations without overflowing, so no cost is reported"
|
|
}
|
|
}
|
|
if b, ok := brier(judged, cal); ok {
|
|
m.Brier = ptr(round6(b))
|
|
}
|
|
return m
|
|
}
|
|
|
|
// Curve is the whole sweep, one point per distinct score, descending. It is what
|
|
// a console renders as ROC, as precision-recall and as a cost curve — three
|
|
// charts from one pass, because they are three projections of the same sweep.
|
|
//
|
|
// One point per DISTINCT score, not per observation: two observations at the
|
|
// same score cannot be separated by any threshold, so a curve with a point
|
|
// between them describes an operating point that does not exist.
|
|
func Curve(obs []Observation, cost policy.Cost) []Point {
|
|
judged := make([]Observation, 0, len(obs))
|
|
var pos, neg float64
|
|
for _, o := range obs {
|
|
if !o.judged() || math.IsNaN(o.Score) || math.IsInf(o.Score, 0) {
|
|
continue
|
|
}
|
|
judged = append(judged, o)
|
|
if o.productive() {
|
|
pos += o.weight()
|
|
} else {
|
|
neg += o.weight()
|
|
}
|
|
}
|
|
if len(judged) == 0 || pos == 0 || neg == 0 {
|
|
return nil
|
|
}
|
|
sort.SliceStable(judged, func(i, j int) bool { return judged[i].Score > judged[j].Score })
|
|
priced := cost.Stated()
|
|
|
|
out := make([]Point, 0, len(judged))
|
|
var tp, fp float64
|
|
for i := 0; i < len(judged); {
|
|
j := i
|
|
for j < len(judged) && judged[j].Score == judged[i].Score {
|
|
if judged[j].productive() {
|
|
tp += judged[j].weight()
|
|
} else {
|
|
fp += judged[j].weight()
|
|
}
|
|
j++
|
|
}
|
|
p := Point{
|
|
Threshold: round6(judged[i].Score),
|
|
TruePositive: round6(tp / pos),
|
|
FalsePositive: round6(fp / neg),
|
|
Precision: round6(tp / (tp + fp)),
|
|
}
|
|
if priced {
|
|
v, ok := nano(Confusion{TP: tp, FP: fp, FN: pos - tp, TN: neg - fp}, cost)
|
|
if !ok {
|
|
// One unrepresentable point makes the whole cost dimension
|
|
// unreadable: a curve with a hole in it plots as a cliff. Drop it
|
|
// for the sweep and let Metrics carry the refusal.
|
|
priced = false
|
|
for k := range out {
|
|
out[k].CostNano = 0
|
|
}
|
|
}
|
|
p.CostNano = v
|
|
}
|
|
out = append(out, p)
|
|
i = j
|
|
}
|
|
return sample(out)
|
|
}
|
|
|
|
// sample bounds what a curve RETURNS without moving where its points sit.
|
|
//
|
|
// The sweep above is over every distinct score and stays that way: Metrics and
|
|
// the cost minimum are exact whatever this does. A curve is a CHART, and the
|
|
// argument that bounds Moved applies unchanged — a person reading one does not
|
|
// need the ten-thousandth point, and a response carrying fifty thousand of them
|
|
// is a megabyte nobody renders.
|
|
//
|
|
// It takes an evenly spaced subsequence and always keeps both ends, so the
|
|
// shape, the extremes and the monotonicity survive. Truncating instead would cut
|
|
// the curve off at one end and describe a model that stops.
|
|
func sample(points []Point) []Point {
|
|
if len(points) <= Plotted {
|
|
return points
|
|
}
|
|
out := make([]Point, 0, Plotted)
|
|
last := len(points) - 1
|
|
for i := range Plotted - 1 {
|
|
out = append(out, points[i*last/(Plotted-1)])
|
|
}
|
|
return append(out, points[last])
|
|
}
|
|
|
|
// confusionAt counts the four cells at one threshold. Flagged is score >= t, so
|
|
// a threshold of zero flags everything and a threshold above one flags nothing —
|
|
// both ends reachable, which is what makes the sweep total.
|
|
func confusionAt(judged []Observation, t float64) Confusion {
|
|
var c Confusion
|
|
for _, o := range judged {
|
|
w := o.weight()
|
|
switch {
|
|
case o.Score >= t && o.productive():
|
|
c.TP += w
|
|
case o.Score >= t:
|
|
c.FP += w
|
|
case o.productive():
|
|
c.FN += w
|
|
default:
|
|
c.TN += w
|
|
}
|
|
}
|
|
return c
|
|
}
|
|
|
|
// nano prices a confusion, and says whether the price is representable.
|
|
//
|
|
// Counts are weighted and therefore float; the MULTIPLICATION is done once, per
|
|
// cell, and rounded to the nearest nano before summing, so the result is an
|
|
// exact integer number of nano-units and two calls on the same counts cannot
|
|
// differ.
|
|
//
|
|
// It returns false rather than a wrapped number. policy.MaxPrice already keeps a
|
|
// sealed ladder's prices inside the range where this cannot happen, but a
|
|
// wrapped cost is the one arithmetic failure here that does not look like a
|
|
// failure — it looks like a recommendation — so the arithmetic refuses on its
|
|
// own account and does not rely on having been called correctly. A policy
|
|
// recorded before that bound existed reads back through this same path.
|
|
func nano(c Confusion, cost policy.Cost) (int64, bool) {
|
|
miss, ok := mul(int64(math.Round(c.FN)), cost.Miss)
|
|
if !ok {
|
|
return 0, false
|
|
}
|
|
alarm, ok := mul(int64(math.Round(c.FP)), cost.Alarm)
|
|
if !ok {
|
|
return 0, false
|
|
}
|
|
sum := miss + alarm
|
|
if sum < miss {
|
|
return 0, false
|
|
}
|
|
return sum, true
|
|
}
|
|
|
|
// mul multiplies a count by a price, reporting whether the product fits. Both
|
|
// are non-negative here, so the check is the single division that inverts the
|
|
// multiplication exactly.
|
|
func mul(count, price int64) (int64, bool) {
|
|
if count <= 0 || price <= 0 {
|
|
return 0, true
|
|
}
|
|
if count > math.MaxInt64/price {
|
|
return 0, false
|
|
}
|
|
return count * price, true
|
|
}
|
|
|
|
// Plotted bounds how many points a curve returns. A thousand is denser than any
|
|
// screen, and the metrics behind it are computed over every distinct score
|
|
// regardless — this bounds the rendering, never the arithmetic.
|
|
const Plotted = 1000
|
|
|
|
// minimum sweeps every reachable threshold and returns the lowest cost and where
|
|
// it is reached.
|
|
//
|
|
// Ties go to the HIGHER threshold — the one that flags less. Two thresholds that
|
|
// cost the same are not equivalent to the business: the one that stops fewer
|
|
// customers is the one to choose, and leaving the tie to iteration order would
|
|
// make the recommendation move between runs on identical data.
|
|
func minimum(judged []Observation, cost policy.Cost) (int64, float64, bool) {
|
|
sorted := append([]Observation(nil), judged...)
|
|
sort.SliceStable(sorted, func(i, j int) bool { return sorted[i].Score > sorted[j].Score })
|
|
|
|
var pos, neg float64
|
|
for _, o := range sorted {
|
|
if o.productive() {
|
|
pos += o.weight()
|
|
} else {
|
|
neg += o.weight()
|
|
}
|
|
}
|
|
// The empty flagged set is a real operating point — flag nothing, pay for
|
|
// every miss — and it is the right answer when alarms cost more than misses.
|
|
best, ok := nano(Confusion{FN: pos, TN: neg}, cost)
|
|
if !ok {
|
|
return 0, 0, false
|
|
}
|
|
at := math.Nextafter(1, 2)
|
|
var tp, fp float64
|
|
for i := 0; i < len(sorted); {
|
|
j := i
|
|
for j < len(sorted) && sorted[j].Score == sorted[i].Score {
|
|
if sorted[j].productive() {
|
|
tp += sorted[j].weight()
|
|
} else {
|
|
fp += sorted[j].weight()
|
|
}
|
|
j++
|
|
}
|
|
v, ok := nano(Confusion{TP: tp, FP: fp, FN: pos - tp, TN: neg - fp}, cost)
|
|
if !ok {
|
|
return 0, 0, false
|
|
}
|
|
if v < best {
|
|
best, at = v, sorted[i].Score
|
|
}
|
|
i = j
|
|
}
|
|
return best, at, true
|
|
}
|
|
|
|
// roc is the area under the receiver-operating curve, by the Mann-Whitney
|
|
// identity: the probability that a random productive observation outranks a
|
|
// random unproductive one.
|
|
//
|
|
// Computed from midranks, so ties count as half a win rather than a whole one.
|
|
// Without that a model that gives every observation the same score — which is
|
|
// exactly what a warming detector does — scores 1.0 instead of 0.5.
|
|
func roc(judged []Observation) float64 {
|
|
sorted := append([]Observation(nil), judged...)
|
|
sort.SliceStable(sorted, func(i, j int) bool { return sorted[i].Score < sorted[j].Score })
|
|
|
|
var sumRanks, pos, neg float64
|
|
for i := 0; i < len(sorted); {
|
|
j := i
|
|
for j < len(sorted) && sorted[j].Score == sorted[i].Score {
|
|
j++
|
|
}
|
|
// Ranks are one-based; the midrank of the block [i,j) is the mean of the
|
|
// ranks it spans.
|
|
mid := (float64(i+1) + float64(j)) / 2
|
|
for k := i; k < j; k++ {
|
|
w := sorted[k].weight()
|
|
if sorted[k].productive() {
|
|
sumRanks += mid * w
|
|
pos += w
|
|
} else {
|
|
neg += w
|
|
}
|
|
}
|
|
i = j
|
|
}
|
|
if pos == 0 || neg == 0 {
|
|
return 0
|
|
}
|
|
return (sumRanks - pos*(pos+1)/2) / (pos * neg)
|
|
}
|
|
|
|
// averagePrecision is the area under precision-recall by the STEP definition:
|
|
// the sum over operating points of (recall gained) x (precision there).
|
|
//
|
|
// Not the trapezoid. Interpolating between two PR operating points draws a line
|
|
// through points the classifier cannot actually reach, and it flatters exactly
|
|
// the rare-event case this metric exists for. Ties are handled as one block, so
|
|
// two observations at one score contribute one step and the answer does not
|
|
// depend on which of them the sort happened to put first.
|
|
func averagePrecision(judged []Observation) float64 {
|
|
sorted := append([]Observation(nil), judged...)
|
|
sort.SliceStable(sorted, func(i, j int) bool { return sorted[i].Score > sorted[j].Score })
|
|
|
|
var pos float64
|
|
for _, o := range sorted {
|
|
if o.productive() {
|
|
pos += o.weight()
|
|
}
|
|
}
|
|
if pos == 0 {
|
|
return 0
|
|
}
|
|
var tp, seen, ap, prevRecall float64
|
|
for i := 0; i < len(sorted); {
|
|
j := i
|
|
for j < len(sorted) && sorted[j].Score == sorted[i].Score {
|
|
w := sorted[j].weight()
|
|
seen += w
|
|
if sorted[j].productive() {
|
|
tp += w
|
|
}
|
|
j++
|
|
}
|
|
recall := tp / pos
|
|
precision := tp / seen
|
|
ap += (recall - prevRecall) * precision
|
|
prevRecall = recall
|
|
i = j
|
|
}
|
|
return ap
|
|
}
|
|
|
|
// brier is the mean squared error of the calibration on these observations. It
|
|
// is the out-of-sample number when the caller passes data the map was not fitted
|
|
// on, which is the only version of it worth reporting.
|
|
func brier(judged []Observation, cal calibrate.Reader) (float64, bool) {
|
|
if !cal.Fitted() {
|
|
return 0, false
|
|
}
|
|
var sum, w float64
|
|
for _, o := range judged {
|
|
p := cal.P(o.Score)
|
|
y := 0.0
|
|
if o.productive() {
|
|
y = 1
|
|
}
|
|
sum += o.weight() * (p - y) * (p - y)
|
|
w += o.weight()
|
|
}
|
|
if w == 0 {
|
|
return 0, false
|
|
}
|
|
return sum / w, true
|
|
}
|
|
|
|
func ptr(v float64) *float64 { return &v }
|
|
func ptrInt(v int64) *int64 { return &v }
|
|
|
|
func round6(v float64) float64 {
|
|
if math.IsNaN(v) || math.IsInf(v, 0) {
|
|
return 0
|
|
}
|
|
return math.Round(v*1e6) / 1e6
|
|
}
|
|
|
|
// canonical renders a float for a digest: fixed precision, no exponent, so two
|
|
// runs that agree to six places agree byte for byte.
|
|
func canonical(v float64) string { return strconv.FormatFloat(round6(v), 'f', 6, 64) }
|
|
|
|
// fingerprint folds a sequence of strings into one identity.
|
|
func fingerprint(parts ...string) string {
|
|
h := sha256.New()
|
|
for _, p := range parts {
|
|
fmt.Fprintf(h, "%d:%s|", len(p), p)
|
|
}
|
|
return hex.EncodeToString(h.Sum(nil))
|
|
}
|