test(explore): #1500 regression fixtures for budget allocation (CG-6)
Two permanent fixtures pinning the failure mode from issue #1500 — explore spending its byte envelope on files that merely name-collide with the query. BOTH FAIL TODAY, by design: they document the bug and become the pass gate for CG-10 (scoring) + CG-12 (proportional allocation). __tests__/fixtures/payroll-go/ — a synthetic Go service mirroring the reporter's shape: generated FKIT CRUD beside a hand-written payroll use-case, entered from an HTTP route. Half the generated tree carries ORDINARY names detectable only by their `// Code generated ... DO NOT EDIT.` header (the #1500 case, and end-to-end cover for CG-5); `payrollpb/*.pb.go` covers the path-detectable channel. BuildPayslip, Upsert and Store each exist twice, generated and hand-written. cycle.go sits above the whole-file window so it clips; the generated files sit below it so they ship whole. Asking "how does payroll cycle create and calculate payslips?" — naming none of the answering symbols — the generated CRUD delivers 57.4% of the envelope against the hand-written layer's 25.6%, all of the latter domain types. cycle.go is allocated the single largest slice (30.6%) and delivers ZERO: the hard ceiling drops its whole section. runPayrollCycleAll, the hand-written BuildPayslip and the real Upsert never reach the agent. The second fixture is this repo, "how does explore allocate its output budget across files", where scripts/agent-eval/*.mjs take 71.8% against tools.ts's 18.5% despite scoring 4.6x lower. It reads the live index, so its assertions are relative rather than fixed percentages. - scripts/agent-eval/probe-allocation.mjs — per-file budget-share probe, driving the CG-4 diagnostic through a JSONL sidecar so it measures the shipping allocator. Fixture entries are hermetic (copy + re-index per run, verified byte-identical across runs); exits 1 while any assertion fails. - scripts/agent-eval/allocation-fixtures.json — both fixtures declared, with the 2026-08-03 baselines. - __tests__/explore-allocation-1500.test.ts — fixture-shape assertions green today; the allocation assertions held as `it.fails` so the suite stays green while the bug is open and goes RED the moment it is fixed. Also documented and deliberately left unfixed: runPayrollCycleAll's `s.store.Upsert` edge resolves to the GENERATED Store.Upsert, not the hand-written one — same-name method resolution across two packages picks the wrong receiver. It is upstream of the allocation bug, so it belongs with CG-10's scoring work. Refs #1500 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
16e17495f4
commit
bd86ad2061
@@ -0,0 +1,227 @@
|
||||
package payroll
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/example/payroll-svc/internal/domain/payroll"
|
||||
"github.com/example/payroll-svc/internal/platform/clock"
|
||||
"github.com/example/payroll-svc/internal/store/payslipstore"
|
||||
)
|
||||
|
||||
// ErrCycleClosed is returned when a cycle has already been finalized.
|
||||
var ErrCycleClosed = errors.New("payroll cycle is closed")
|
||||
|
||||
// ErrNoEmployees is returned when a cycle resolves to an empty roster.
|
||||
var ErrNoEmployees = errors.New("payroll cycle has no active employees")
|
||||
|
||||
// RunOptions tunes a single run of a payroll cycle.
|
||||
type RunOptions struct {
|
||||
// DryRun computes every payslip but persists nothing.
|
||||
DryRun bool
|
||||
// Reason is recorded on the audit trail for re-runs.
|
||||
Reason string
|
||||
// Only, when non-empty, restricts the run to these employee ids.
|
||||
Only []string
|
||||
}
|
||||
|
||||
// RunResult is the outcome of one payroll cycle run.
|
||||
type RunResult struct {
|
||||
CycleID string
|
||||
Payslips []payroll.Payslip
|
||||
TotalGrossCents int64
|
||||
TotalNetCents int64
|
||||
Skipped []string
|
||||
FinishedAt time.Time
|
||||
}
|
||||
|
||||
// Service is the hand-written payroll use-case layer. It owns the order of
|
||||
// operations for a cycle: resolve the roster, build a payslip per employee,
|
||||
// then persist. The generated CRUD layer under internal/gen has no opinion
|
||||
// about any of that — it can only read and write single rows.
|
||||
type Service struct {
|
||||
store *payslipstore.Store
|
||||
clock clock.Clock
|
||||
}
|
||||
|
||||
func NewService(store *payslipstore.Store, c clock.Clock) *Service {
|
||||
return &Service{store: store, clock: c}
|
||||
}
|
||||
|
||||
// RunCycle is the public entry point used by the HTTP handler. It loads the
|
||||
// cycle, guards its state, and delegates the actual work to runPayrollCycleAll.
|
||||
func (s *Service) RunCycle(ctx context.Context, cycleID string, opts RunOptions) (RunResult, error) {
|
||||
cycle, err := s.loadCycle(ctx, cycleID)
|
||||
if err != nil {
|
||||
return RunResult{}, err
|
||||
}
|
||||
if cycle.Status == payroll.CycleClosed {
|
||||
return RunResult{}, ErrCycleClosed
|
||||
}
|
||||
|
||||
roster, err := s.rosterFor(ctx, cycle, opts)
|
||||
if err != nil {
|
||||
return RunResult{}, err
|
||||
}
|
||||
if len(roster) == 0 {
|
||||
return RunResult{}, ErrNoEmployees
|
||||
}
|
||||
|
||||
return s.runPayrollCycleAll(ctx, cycle, roster, opts)
|
||||
}
|
||||
|
||||
// runPayrollCycleAll is the heart of the cycle: for every employee on the
|
||||
// roster it builds a payslip from that employee's contract and timesheet,
|
||||
// then upserts the result. Ordering matters — a payslip is only persisted
|
||||
// after every earning, deduction and tax line has been resolved, so a
|
||||
// partially-computed slip can never reach the store.
|
||||
func (s *Service) runPayrollCycleAll(
|
||||
ctx context.Context,
|
||||
cycle payroll.Cycle,
|
||||
roster []payroll.Employee,
|
||||
opts RunOptions,
|
||||
) (RunResult, error) {
|
||||
result := RunResult{CycleID: cycle.ID}
|
||||
now := s.clock.Now()
|
||||
|
||||
for _, employee := range roster {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
timesheet, err := s.timesheetFor(ctx, cycle, employee)
|
||||
if err != nil {
|
||||
result.Skipped = append(result.Skipped, employee.ID)
|
||||
continue
|
||||
}
|
||||
|
||||
slip, err := s.BuildPayslip(ctx, cycle, employee, timesheet)
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("build payslip for %s: %w", employee.ID, err)
|
||||
}
|
||||
|
||||
slip.RunAt = now
|
||||
slip.RunReason = opts.Reason
|
||||
|
||||
if !opts.DryRun {
|
||||
if err := s.store.Upsert(ctx, slip); err != nil {
|
||||
return result, fmt.Errorf("persist payslip for %s: %w", employee.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
result.Payslips = append(result.Payslips, slip)
|
||||
result.TotalGrossCents += slip.GrossCents
|
||||
result.TotalNetCents += slip.NetCents
|
||||
}
|
||||
|
||||
if !opts.DryRun {
|
||||
if err := s.closeCycle(ctx, cycle, now); err != nil {
|
||||
return result, err
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(result.Payslips, func(i, j int) bool {
|
||||
return result.Payslips[i].EmployeeID < result.Payslips[j].EmployeeID
|
||||
})
|
||||
result.FinishedAt = now
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// rosterFor resolves which employees this cycle pays. An employee joins the
|
||||
// roster when their contract overlaps the cycle window and they are not on
|
||||
// unpaid leave for the whole period.
|
||||
func (s *Service) rosterFor(ctx context.Context, cycle payroll.Cycle, opts RunOptions) ([]payroll.Employee, error) {
|
||||
all, err := s.store.EmployeesForCycle(ctx, cycle.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
only := map[string]bool{}
|
||||
for _, id := range opts.Only {
|
||||
only[id] = true
|
||||
}
|
||||
|
||||
roster := make([]payroll.Employee, 0, len(all))
|
||||
for _, e := range all {
|
||||
if len(only) > 0 && !only[e.ID] {
|
||||
continue
|
||||
}
|
||||
if !e.Contract.OverlapsWindow(cycle.Start, cycle.End) {
|
||||
continue
|
||||
}
|
||||
if e.UnpaidLeaveCoversWindow(cycle.Start, cycle.End) {
|
||||
continue
|
||||
}
|
||||
roster = append(roster, e)
|
||||
}
|
||||
|
||||
sort.Slice(roster, func(i, j int) bool { return roster[i].ID < roster[j].ID })
|
||||
return roster, nil
|
||||
}
|
||||
|
||||
func (s *Service) timesheetFor(ctx context.Context, cycle payroll.Cycle, e payroll.Employee) (payroll.Timesheet, error) {
|
||||
ts, err := s.store.Timesheet(ctx, cycle.ID, e.ID)
|
||||
if err != nil {
|
||||
return payroll.Timesheet{}, err
|
||||
}
|
||||
if ts.Approved {
|
||||
return ts, nil
|
||||
}
|
||||
if e.Contract.Kind == payroll.ContractSalaried {
|
||||
// Salaried staff are paid the contractual period regardless of an
|
||||
// unapproved timesheet; hourly staff are skipped until approval.
|
||||
return payroll.Timesheet{
|
||||
CycleID: cycle.ID,
|
||||
EmployeeID: e.ID,
|
||||
Approved: true,
|
||||
Units: e.Contract.PeriodUnits(cycle.Start, cycle.End),
|
||||
}, nil
|
||||
}
|
||||
return payroll.Timesheet{}, fmt.Errorf("timesheet for %s not approved", e.ID)
|
||||
}
|
||||
|
||||
func (s *Service) loadCycle(ctx context.Context, cycleID string) (payroll.Cycle, error) {
|
||||
if cycleID == "" {
|
||||
return payroll.Cycle{}, errors.New("empty cycle id")
|
||||
}
|
||||
return s.store.Cycle(ctx, cycleID)
|
||||
}
|
||||
|
||||
func (s *Service) closeCycle(ctx context.Context, cycle payroll.Cycle, at time.Time) error {
|
||||
cycle.Status = payroll.CycleClosed
|
||||
cycle.ClosedAt = at
|
||||
return s.store.SaveCycle(ctx, cycle)
|
||||
}
|
||||
|
||||
// Cycle exposes a cycle for the read endpoints.
|
||||
func (s *Service) Cycle(ctx context.Context, cycleID string) (payroll.Cycle, error) {
|
||||
return s.loadCycle(ctx, cycleID)
|
||||
}
|
||||
|
||||
// PayslipsForCycle lists the payslips a completed cycle produced.
|
||||
func (s *Service) PayslipsForCycle(ctx context.Context, cycleID string) ([]payroll.Payslip, error) {
|
||||
slips, err := s.store.ListByCycle(ctx, cycleID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.Slice(slips, func(i, j int) bool { return slips[i].EmployeeID < slips[j].EmployeeID })
|
||||
return slips, nil
|
||||
}
|
||||
|
||||
// Reopen unwinds a closed cycle so it can be re-run after a correction.
|
||||
func (s *Service) Reopen(ctx context.Context, cycleID string, reason string) error {
|
||||
cycle, err := s.loadCycle(ctx, cycleID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cycle.Status != payroll.CycleClosed {
|
||||
return nil
|
||||
}
|
||||
cycle.Status = payroll.CycleOpen
|
||||
cycle.ReopenReason = reason
|
||||
cycle.ClosedAt = time.Time{}
|
||||
return s.store.SaveCycle(ctx, cycle)
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package payroll
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/example/payroll-svc/internal/domain/payroll"
|
||||
)
|
||||
|
||||
// BuildPayslip turns one employee's contract and timesheet into a complete
|
||||
// payslip for the cycle: base pay, overtime, allowances, then deductions and
|
||||
// tax, in that order. Every amount is in integer cents; nothing here rounds
|
||||
// until the final net, so a cent never disappears between two lines.
|
||||
//
|
||||
// This is the calculation the generated CRUD layer does NOT do — fkit's
|
||||
// BuildPayslip only copies fields between a DTO and a row.
|
||||
func (s *Service) BuildPayslip(
|
||||
ctx context.Context,
|
||||
cycle payroll.Cycle,
|
||||
employee payroll.Employee,
|
||||
timesheet payroll.Timesheet,
|
||||
) (payroll.Payslip, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return payroll.Payslip{}, err
|
||||
}
|
||||
if timesheet.EmployeeID != "" && timesheet.EmployeeID != employee.ID {
|
||||
return payroll.Payslip{}, fmt.Errorf("timesheet/employee mismatch: %s vs %s", timesheet.EmployeeID, employee.ID)
|
||||
}
|
||||
|
||||
slip := payroll.Payslip{
|
||||
CycleID: cycle.ID,
|
||||
EmployeeID: employee.ID,
|
||||
Currency: employee.Contract.Currency,
|
||||
PeriodFrom: cycle.Start,
|
||||
PeriodTo: cycle.End,
|
||||
}
|
||||
|
||||
base := s.basePayCents(employee, cycle, timesheet)
|
||||
slip.Lines = append(slip.Lines, payroll.Line{
|
||||
Code: "BASE", Kind: payroll.LineEarning, AmountCents: base,
|
||||
})
|
||||
|
||||
if overtime := s.overtimeCents(employee, timesheet); overtime > 0 {
|
||||
slip.Lines = append(slip.Lines, payroll.Line{
|
||||
Code: "OT", Kind: payroll.LineEarning, AmountCents: overtime,
|
||||
})
|
||||
}
|
||||
|
||||
for _, allowance := range employee.Contract.Allowances {
|
||||
amount := prorateAllowance(allowance, cycle, employee)
|
||||
if amount == 0 {
|
||||
continue
|
||||
}
|
||||
slip.Lines = append(slip.Lines, payroll.Line{
|
||||
Code: allowance.Code, Kind: payroll.LineEarning, AmountCents: amount,
|
||||
})
|
||||
}
|
||||
|
||||
slip.GrossCents = sumKind(slip.Lines, payroll.LineEarning)
|
||||
|
||||
for _, d := range employee.Deductions {
|
||||
amount := d.AmountFor(slip.GrossCents)
|
||||
if amount == 0 {
|
||||
continue
|
||||
}
|
||||
slip.Lines = append(slip.Lines, payroll.Line{
|
||||
Code: d.Code, Kind: payroll.LineDeduction, AmountCents: amount,
|
||||
})
|
||||
}
|
||||
|
||||
tax, err := s.taxCents(employee, slip.GrossCents)
|
||||
if err != nil {
|
||||
return payroll.Payslip{}, fmt.Errorf("tax for %s: %w", employee.ID, err)
|
||||
}
|
||||
slip.Lines = append(slip.Lines, payroll.Line{
|
||||
Code: "TAX", Kind: payroll.LineDeduction, AmountCents: tax,
|
||||
})
|
||||
|
||||
slip.DeductionCents = sumKind(slip.Lines, payroll.LineDeduction)
|
||||
slip.NetCents = slip.GrossCents - slip.DeductionCents
|
||||
if slip.NetCents < 0 {
|
||||
slip.NetCents = 0
|
||||
slip.Underwater = true
|
||||
}
|
||||
|
||||
return slip, nil
|
||||
}
|
||||
|
||||
// basePayCents is the contractual pay for the period: salaried staff get the
|
||||
// period rate prorated across their contract window, hourly staff get rate ×
|
||||
// approved units.
|
||||
func (s *Service) basePayCents(e payroll.Employee, cycle payroll.Cycle, ts payroll.Timesheet) int64 {
|
||||
switch e.Contract.Kind {
|
||||
case payroll.ContractSalaried:
|
||||
full := e.Contract.PeriodRateCents
|
||||
return prorateSalary(full, e.Contract, cycle)
|
||||
case payroll.ContractHourly:
|
||||
return e.Contract.RateCents * int64(ts.Units)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// overtimeCents pays approved units above the contractual threshold at the
|
||||
// contract's overtime multiplier.
|
||||
func (s *Service) overtimeCents(e payroll.Employee, ts payroll.Timesheet) int64 {
|
||||
if e.Contract.Kind != payroll.ContractHourly {
|
||||
return 0
|
||||
}
|
||||
threshold := e.Contract.OvertimeThresholdUnits
|
||||
if threshold <= 0 || ts.Units <= threshold {
|
||||
return 0
|
||||
}
|
||||
extra := int64(ts.Units - threshold)
|
||||
return int64(float64(e.Contract.RateCents) * e.Contract.OvertimeMultiplier * float64(extra))
|
||||
}
|
||||
|
||||
// taxCents applies the employee's tax band schedule to the gross.
|
||||
func (s *Service) taxCents(e payroll.Employee, gross int64) (int64, error) {
|
||||
if len(e.TaxBands) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
var tax int64
|
||||
remaining := gross
|
||||
for _, band := range e.TaxBands {
|
||||
if remaining <= 0 {
|
||||
break
|
||||
}
|
||||
if band.RateBasisPoints < 0 || band.RateBasisPoints > 10000 {
|
||||
return 0, fmt.Errorf("invalid band rate %d", band.RateBasisPoints)
|
||||
}
|
||||
slice := remaining
|
||||
if band.UpToCents > 0 && slice > band.UpToCents {
|
||||
slice = band.UpToCents
|
||||
}
|
||||
tax += slice * int64(band.RateBasisPoints) / 10000
|
||||
remaining -= slice
|
||||
}
|
||||
return tax, nil
|
||||
}
|
||||
|
||||
func sumKind(lines []payroll.Line, kind payroll.LineKind) int64 {
|
||||
var total int64
|
||||
for _, l := range lines {
|
||||
if l.Kind == kind {
|
||||
total += l.AmountCents
|
||||
}
|
||||
}
|
||||
return total
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package payroll
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/example/payroll-svc/internal/domain/payroll"
|
||||
)
|
||||
|
||||
// prorateSalary scales a full period rate down when the contract covers only
|
||||
// part of the cycle window (a mid-period joiner or leaver).
|
||||
func prorateSalary(fullCents int64, contract payroll.Contract, cycle payroll.Cycle) int64 {
|
||||
window := calendarDays(cycle.Start, cycle.End)
|
||||
if window <= 0 {
|
||||
return 0
|
||||
}
|
||||
covered := calendarDays(laterOf(cycle.Start, contract.StartsOn), earlierOf(cycle.End, contract.EndsOn))
|
||||
if covered >= window {
|
||||
return fullCents
|
||||
}
|
||||
if covered <= 0 {
|
||||
return 0
|
||||
}
|
||||
return fullCents * int64(covered) / int64(window)
|
||||
}
|
||||
|
||||
// prorateAllowance applies the same window rule to a recurring allowance.
|
||||
func prorateAllowance(a payroll.Allowance, cycle payroll.Cycle, e payroll.Employee) int64 {
|
||||
if !a.Prorated {
|
||||
return a.AmountCents
|
||||
}
|
||||
return prorateSalary(a.AmountCents, e.Contract, cycle)
|
||||
}
|
||||
|
||||
func calendarDays(from, to time.Time) int {
|
||||
if to.Before(from) {
|
||||
return 0
|
||||
}
|
||||
return int(to.Sub(from).Hours()/24) + 1
|
||||
}
|
||||
|
||||
func laterOf(a, b time.Time) time.Time {
|
||||
if b.IsZero() || a.After(b) {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func earlierOf(a, b time.Time) time.Time {
|
||||
if b.IsZero() || a.Before(b) {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
Reference in New Issue
Block a user