215 lines
8.3 KiB
Go
215 lines
8.3 KiB
Go
package outcomes
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func at(h int) time.Time {
|
|
return time.Date(2026, 10, 5, h, 0, 0, 0, time.UTC)
|
|
}
|
|
|
|
func tp(t time.Time) *time.Time { return &t }
|
|
|
|
const window = 48 * time.Hour
|
|
|
|
// ─── Too soon to judge ─────────────────────────────────────────────────────
|
|
|
|
func TestYoungDecisionIsLeftPending(t *testing.T) {
|
|
decided := at(10)
|
|
for _, dt := range []string{"stall_response", "assignment_failure", "something_new"} {
|
|
_, ok := judge(dt, bookingFacts{DecidedAt: decided}, decided.Add(5*time.Minute), window)
|
|
if ok {
|
|
t.Errorf("%s: judged a 5-minute-old decision; the booking is still in flight", dt)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPendingWhileInsideTheWindow(t *testing.T) {
|
|
decided := at(10)
|
|
// An hour in: no delivery yet, but the window has not closed. Recording
|
|
// failure here would blame the decision for a parcel still on its way.
|
|
if _, ok := judge("stall_response", bookingFacts{DecidedAt: decided}, decided.Add(time.Hour), window); ok {
|
|
t.Error("stall_response judged before its window closed")
|
|
}
|
|
if _, ok := judge("assignment_failure", bookingFacts{DecidedAt: decided}, decided.Add(time.Hour), window); ok {
|
|
t.Error("assignment_failure judged before its window closed")
|
|
}
|
|
}
|
|
|
|
// ─── stall_response ────────────────────────────────────────────────────────
|
|
|
|
func TestStallDeliveredOnTimeIsSuccess(t *testing.T) {
|
|
decided := at(10)
|
|
f := bookingFacts{
|
|
DecidedAt: decided,
|
|
DeliveredAt: tp(decided.Add(3 * time.Hour)),
|
|
SLADueAt: tp(decided.Add(12 * time.Hour)),
|
|
}
|
|
got, ok := judge("stall_response", f, decided.Add(4*time.Hour), window)
|
|
if !ok || got != OutcomeSuccess {
|
|
t.Errorf("got %q ok=%v, want success", got, ok)
|
|
}
|
|
}
|
|
|
|
// The case that matters most: counting any eventual delivery as success would
|
|
// teach the model that the action is always vindicated, which is exactly the
|
|
// wrong lesson.
|
|
func TestStallDeliveredLateIsFailure(t *testing.T) {
|
|
decided := at(10)
|
|
f := bookingFacts{
|
|
DecidedAt: decided,
|
|
DeliveredAt: tp(decided.Add(20 * time.Hour)),
|
|
SLADueAt: tp(decided.Add(12 * time.Hour)),
|
|
}
|
|
got, ok := judge("stall_response", f, decided.Add(21*time.Hour), window)
|
|
if !ok || got != OutcomeFailure {
|
|
t.Errorf("got %q ok=%v, want failure — delivered 8h past SLA", got, ok)
|
|
}
|
|
}
|
|
|
|
func TestStallDeliveredWithNoSLAIsSuccess(t *testing.T) {
|
|
decided := at(10)
|
|
// No sladueat recorded (an older row). Delivery is the best evidence
|
|
// available and there is nothing to call it late against.
|
|
f := bookingFacts{DecidedAt: decided, DeliveredAt: tp(decided.Add(5 * time.Hour))}
|
|
got, ok := judge("stall_response", f, decided.Add(6*time.Hour), window)
|
|
if !ok || got != OutcomeSuccess {
|
|
t.Errorf("got %q ok=%v, want success", got, ok)
|
|
}
|
|
}
|
|
|
|
func TestStallNeverDeliveredAfterWindowIsFailure(t *testing.T) {
|
|
decided := at(10)
|
|
got, ok := judge("stall_response", bookingFacts{DecidedAt: decided}, decided.Add(window+time.Hour), window)
|
|
if !ok || got != OutcomeFailure {
|
|
t.Errorf("got %q ok=%v, want failure", got, ok)
|
|
}
|
|
}
|
|
|
|
func TestStallCancelledIsFailureImmediately(t *testing.T) {
|
|
decided := at(10)
|
|
f := bookingFacts{DecidedAt: decided, Cancelled: true}
|
|
// Cancellation is conclusive — no need to wait out the window.
|
|
got, ok := judge("stall_response", f, decided.Add(time.Hour), window)
|
|
if !ok || got != OutcomeFailure {
|
|
t.Errorf("got %q ok=%v, want failure on cancellation", got, ok)
|
|
}
|
|
}
|
|
|
|
// ─── assignment_failure ────────────────────────────────────────────────────
|
|
|
|
func TestAssignmentLaterAssignedIsSuccess(t *testing.T) {
|
|
decided := at(10)
|
|
f := bookingFacts{
|
|
DecidedAt: decided,
|
|
Assigned: true,
|
|
AssignedAt: tp(decided.Add(2 * time.Hour)),
|
|
}
|
|
got, ok := judge("assignment_failure", f, decided.Add(3*time.Hour), window)
|
|
if !ok || got != OutcomeSuccess {
|
|
t.Errorf("got %q ok=%v, want success", got, ok)
|
|
}
|
|
}
|
|
|
|
// A rider assigned BEFORE the decision is not evidence the decision worked —
|
|
// it is the assignment that was already there. Without the ordering check,
|
|
// every reassignment decision would read as an instant success.
|
|
func TestAssignmentPredatingTheDecisionIsNotSuccess(t *testing.T) {
|
|
decided := at(10)
|
|
f := bookingFacts{
|
|
DecidedAt: decided,
|
|
Assigned: true,
|
|
AssignedAt: tp(decided.Add(-2 * time.Hour)),
|
|
}
|
|
got, ok := judge("assignment_failure", f, decided.Add(time.Hour), window)
|
|
if ok && got == OutcomeSuccess {
|
|
t.Error("counted a pre-existing assignment as the decision's success")
|
|
}
|
|
}
|
|
|
|
func TestAssignmentNeverAssignedAfterWindowIsFailure(t *testing.T) {
|
|
decided := at(10)
|
|
got, ok := judge("assignment_failure", bookingFacts{DecidedAt: decided}, decided.Add(window+time.Hour), window)
|
|
if !ok || got != OutcomeFailure {
|
|
t.Errorf("got %q ok=%v, want failure", got, ok)
|
|
}
|
|
}
|
|
|
|
func TestAssignmentCancelledIsFailure(t *testing.T) {
|
|
decided := at(10)
|
|
f := bookingFacts{DecidedAt: decided, Cancelled: true}
|
|
got, ok := judge("assignment_failure", f, decided.Add(time.Hour), window)
|
|
if !ok || got != OutcomeFailure {
|
|
t.Errorf("got %q ok=%v, want failure", got, ok)
|
|
}
|
|
}
|
|
|
|
// ─── Unknown types are never guessed ───────────────────────────────────────
|
|
|
|
func TestUnknownTypeIsNeverSuccessOrFailure(t *testing.T) {
|
|
decided := at(10)
|
|
// Inside the window: pending.
|
|
if _, ok := judge("a_new_decision_type", bookingFacts{DecidedAt: decided}, decided.Add(time.Hour), window); ok {
|
|
t.Error("judged an unknown decision type inside its window")
|
|
}
|
|
// Past it: unknown, so it stops being rescanned — but never a label that
|
|
// would teach the model something nobody defined.
|
|
got, ok := judge("a_new_decision_type", bookingFacts{DecidedAt: decided}, decided.Add(window+time.Hour), window)
|
|
if !ok || got != OutcomeUnknown {
|
|
t.Errorf("got %q ok=%v, want unknown", got, ok)
|
|
}
|
|
}
|
|
|
|
// A delivered unknown type must still not be called a success: the rule for
|
|
// what success means for that type does not exist yet.
|
|
func TestUnknownTypeWithDeliveryIsStillUnknown(t *testing.T) {
|
|
decided := at(10)
|
|
f := bookingFacts{DecidedAt: decided, DeliveredAt: tp(decided.Add(time.Hour))}
|
|
got, ok := judge("a_new_decision_type", f, decided.Add(window+time.Hour), window)
|
|
if !ok || got != OutcomeUnknown {
|
|
t.Errorf("got %q ok=%v, want unknown", got, ok)
|
|
}
|
|
}
|
|
|
|
// ─── Configuration ─────────────────────────────────────────────────────────
|
|
|
|
func TestOutcomeWindowDefaultAndOverride(t *testing.T) {
|
|
t.Setenv("AGENT_OUTCOME_WINDOW_HOURS", "")
|
|
if got := OutcomeWindow(); got != defaultOutcomeWindowHours*time.Hour {
|
|
t.Errorf("default window = %v, want %v", got, defaultOutcomeWindowHours*time.Hour)
|
|
}
|
|
t.Setenv("AGENT_OUTCOME_WINDOW_HOURS", "12")
|
|
if got := OutcomeWindow(); got != 12*time.Hour {
|
|
t.Errorf("window = %v, want 12h", got)
|
|
}
|
|
// Garbage falls back rather than producing a zero window, which would
|
|
// judge every decision the instant it passed minDecisionAge.
|
|
t.Setenv("AGENT_OUTCOME_WINDOW_HOURS", "not-a-number")
|
|
if got := OutcomeWindow(); got != defaultOutcomeWindowHours*time.Hour {
|
|
t.Errorf("window on garbage = %v, want the default", got)
|
|
}
|
|
t.Setenv("AGENT_OUTCOME_WINDOW_HOURS", "0")
|
|
if got := OutcomeWindow(); got != defaultOutcomeWindowHours*time.Hour {
|
|
t.Errorf("window on 0 = %v, want the default", got)
|
|
}
|
|
}
|
|
|
|
func TestRetentionDaysDefaultAndOverride(t *testing.T) {
|
|
t.Setenv("AGENT_DECISION_RETENTION_DAYS", "")
|
|
if got := RetentionDays(); got != 180 {
|
|
t.Errorf("default retention = %d, want 180", got)
|
|
}
|
|
t.Setenv("AGENT_DECISION_RETENTION_DAYS", "90")
|
|
if got := RetentionDays(); got != 90 {
|
|
t.Errorf("retention = %d, want 90", got)
|
|
}
|
|
// Retention must be longer than aiagentruns' 30 days, because old
|
|
// precedent is the point of this table. Not enforced in code — asserted
|
|
// here so a future change to the default has to confront it.
|
|
t.Setenv("AGENT_DECISION_RETENTION_DAYS", "")
|
|
if RetentionDays() <= 30 {
|
|
t.Error("decision retention is no longer than telemetry retention; precedent will be purged before it is useful")
|
|
}
|
|
}
|