package fitness import ( "sort" "time" "github.com/tegwick/fluid-core/internal/contract" ) // Standard metric names the measurer derives from telemetry. // // These are the metrics the Blueprint's worked example turns on, and having // them named in one place stops a hypothesis and an evaluation from measuring // subtly different things under the same word. const ( MetricRequestsPerTask = "requests_per_completed_task" MetricP95LatencyMS = "p95_latency_ms" MetricErrorRate = "error_rate" MetricSuccessRate = "success_rate" ) // Measurer derives metric observations from raw telemetry. type Measurer struct { // ChainGap bounds one completed task when the consumer supplies no chain // id, matching the topology analyzer's grouping. ChainGap time.Duration } // NewMeasurer returns a measurer with the default grouping window. func NewMeasurer() *Measurer { return &Measurer{ChainGap: 30 * time.Second} } // Measure computes the standard metrics per revision over a window. // // Only events inside the window count. Blueprint section 18 requires the // measurement window to be retained, and quietly including events outside it // would make a comparison irreproducible. func (m *Measurer) Measure(events []contract.FluidTelemetry, window Window) []Observation { type acc struct { requests int errors int tasks map[string]int latency []float64 } byRevision := map[contract.RevisionID]*acc{} for _, ev := range events { if !inWindow(ev.OccurredAt, window) { continue } if ev.Revision == nil { continue } rev := *ev.Revision a, ok := byRevision[rev] if !ok { a = &acc{tasks: map[string]int{}} byRevision[rev] = a } a.requests++ if ev.Error != nil { a.errors++ } if ev.Request != nil && ev.Request.LatencyMS != nil { a.latency = append(a.latency, *ev.Request.LatencyMS) } a.tasks[taskKey(ev, m.ChainGap)]++ } revisions := make([]contract.RevisionID, 0, len(byRevision)) for r := range byRevision { revisions = append(revisions, r) } sort.Slice(revisions, func(i, j int) bool { return revisions[i] < revisions[j] }) var out []Observation for _, rev := range revisions { a := byRevision[rev] if a.requests == 0 { continue } // Requests per completed task is the metric that catches an interface // making consumers assemble what it could have handed them. tasks := len(a.tasks) if tasks > 0 { out = append(out, Observation{ Metric: MetricRequestsPerTask, Revision: rev, Value: round4(float64(a.requests) / float64(tasks)), Samples: tasks, }) } out = append(out, Observation{ Metric: MetricErrorRate, Revision: rev, Value: round4(float64(a.errors) / float64(a.requests)), Samples: a.requests, }) out = append(out, Observation{ Metric: MetricSuccessRate, Revision: rev, Value: round4(float64(a.requests-a.errors) / float64(a.requests)), Samples: a.requests, }) if len(a.latency) > 0 { out = append(out, Observation{ Metric: MetricP95LatencyMS, Revision: rev, Value: round4(percentile(a.latency, 0.95)), Samples: len(a.latency), }) } } return out } // taskKey groups events into completed tasks. func taskKey(ev contract.FluidTelemetry, gap time.Duration) string { consumer := ev.ConsumerRef if consumer == "" { consumer = ev.CorrelationID } if ev.Sequence != nil && ev.Sequence.ChainID != "" { return consumer + "/" + ev.Sequence.ChainID } // Without a chain id, bucket by consumer and elapsed gap. This is a // heuristic, which is the reason to prefer chain ids from agentic // consumers where they can supply them. bucket := ev.OccurredAt.Truncate(gap).UTC().Format(time.RFC3339) return consumer + "/" + bucket } func inWindow(t time.Time, w Window) bool { if !w.Start.IsZero() && t.Before(w.Start) { return false } if w.End != nil && t.After(*w.End) { return false } return true } // percentile returns the nearest-rank percentile of values. func percentile(values []float64, p float64) float64 { sorted := make([]float64, len(values)) copy(sorted, values) sort.Float64s(sorted) if len(sorted) == 1 { return sorted[0] } rank := int(p*float64(len(sorted)-1) + 0.5) if rank < 0 { rank = 0 } if rank >= len(sorted) { rank = len(sorted) - 1 } return sorted[rank] }