Source identity
| Field | Recorded value |
|---|---|
| Source type | GitHub source |
| Original record | Janady13/missionmatch / internal/modules/recruiting/analytics_math.go |
| Date | Not specified in source |
| Repository | Janady13/missionmatch |
| Revision | 720d47fae476b0d38477ae4401697d372b1ea2bc |
| Branch | feature/opportunity-to-outcome |
| Path or archive member | internal/modules/recruiting/analytics_math.go |
| Original SHA-256 | c49e24965eec7e15b091b462f7e686daa12dc158968ddd1a007cb0ea8185fa5f |
| Git blob SHA | 4f044fee7ca684bdda5a008abe463aeb70d67ba5 |
| Reading-copy SHA-256 | c49e24965eec7e15b091b462f7e686daa12dc158968ddd1a007cb0ea8185fa5f |
Every recorded occurrence
These 2 records contain identical original source bytes. Their paths and revisions remain part of the archive.
| Original record | Branch or archive | Revision / identifier | Original location |
|---|---|---|---|
| Janady13/missionmatch / internal/modules/recruiting/analytics_math.go | feature/opportunity-to-outcome | 720d47fae476b0d38477ae4401697d372b1ea2bc | Open original |
| Janady13/missionmatch / internal/modules/recruiting/analytics_math.go | feature/aioe-upgrade | 2178e7298d0e5d9af83afa12402fd587f0ba6ed8 | Open original |
Recovered source transcript
Download complete source text ↓254 lines · 9.3 KB
package recruiting
import (
"sort"
"github.com/shopspring/decimal"
)
// The mathematics of recruiting analytics, pure and free of a database.
//
// The defect this file exists to prevent is survivorship bias in time to fill. Averaging the days it
// took to fill only the requisitions that FILLED systematically understates the truth, because the slow
// ones are still open and therefore excluded. The bias grows with the number of open orders, so the
// worse a desk is doing the better the naive number looks, which is the exact opposite of what a metric
// should do.
//
// The correct treatment is to keep the still open orders in the calculation as right censored
// observations: each one is known to have taken AT LEAST as long as it has been open so far. That is
// what the Kaplan Meier estimator does, and it is what this file implements.
// Observation is one requisition in a cohort. Days is how long it took to fill, or, when Filled is
// false, how long it has been open so far. A censored observation is not missing data, it is a lower
// bound, and treating it as either a filled order or as nothing at all is what produces the bias.
type Observation struct {
Days decimal.Decimal
Filled bool
}
// SurvivalPoint is one step of the curve. Surviving is the share of the cohort still unfilled at Days.
type SurvivalPoint struct {
Days string `json:"days"`
AtRisk int `json:"at_risk"`
Filled int `json:"filled"`
Surviving string `json:"surviving"`
}
// TimeToFill reports the distribution honestly, including what it cannot know.
type TimeToFill struct {
// Cohort is every requisition opened in the window, filled or not.
Cohort int `json:"cohort"`
FilledN int `json:"filled"`
OpenN int `json:"still_open"`
// NaiveMedian is the median over filled orders only. Reported because it is the number every other
// tool shows, and shown NEXT TO the corrected one so the gap between them is visible rather than
// hidden. It is biased low whenever anything is still open.
NaiveMedian string `json:"naive_median_days,omitempty"`
// Median is the Kaplan Meier median, which keeps the still open orders in the calculation as lower
// bounds. Empty when the curve never reaches half, which is itself the answer: fewer than half the
// cohort has filled, so a median does not exist yet and inventing one would be a fabrication.
Median string `json:"median_days,omitempty"`
// MedianReached says whether a median exists, so a consumer never mistakes an absent value for zero.
MedianReached bool `json:"median_reached"`
// Curve is the full survival function, so the shape is inspectable rather than taken on trust.
Curve []SurvivalPoint `json:"curve"`
}
// kaplanMeier computes the survival function for a set of observations.
//
// S(t) = product over event times ti <= t of (1 - di / ni), where ni is the number still at risk
// immediately before ti and di is the number that filled at exactly ti.
//
// Censored observations contribute to ni up to their censoring time and then leave the risk set without
// causing a drop. That is precisely the information a still open requisition carries: it had not filled
// by the time we looked, and we do not know when it will.
//
// Ties are handled by grouping every observation at the same duration, and censored observations at the
// same time as an event are treated as leaving AFTER it, which is the standard convention and the
// conservative one.
func kaplanMeier(obs []Observation) []SurvivalPoint {
if len(obs) == 0 {
return []SurvivalPoint{}
}
sorted := make([]Observation, len(obs))
copy(sorted, obs)
// Ascending by time; at equal times, events before censorings so the censored one is still counted
// in the risk set for that event.
sort.SliceStable(sorted, func(i, j int) bool {
c := sorted[i].Days.Cmp(sorted[j].Days)
if c != 0 {
return c < 0
}
return sorted[i].Filled && !sorted[j].Filled
})
out := []SurvivalPoint{}
surviving := decimal.NewFromInt(1)
atRisk := len(sorted)
for i := 0; i < len(sorted); {
t := sorted[i].Days
events, leaving := 0, 0
j := i
for ; j < len(sorted) && sorted[j].Days.Equal(t); j++ {
leaving++
if sorted[j].Filled {
events++
}
}
if events > 0 {
// The drop. atRisk is the count immediately before this instant, which includes everything
// censored at exactly this time, by the convention above.
factor := decimal.NewFromInt(1).Sub(
decimal.NewFromInt(int64(events)).Div(decimal.NewFromInt(int64(atRisk))))
surviving = surviving.Mul(factor)
out = append(out, SurvivalPoint{
Days: t.StringFixed(2),
AtRisk: atRisk,
Filled: events,
Surviving: surviving.Round(6).String(),
})
}
atRisk -= leaving
i = j
}
return out
}
// kmMedian is the first time the survival function is at or below one half.
//
// Returns false when the curve never gets there. That happens when fewer than half the cohort has
// filled, and the honest answer is that the median is not yet reached. Returning the largest observed
// duration instead, which some tools do, would report a number that is guaranteed to be too small and
// would look like a real measurement.
func kmMedian(curve []SurvivalPoint) (decimal.Decimal, bool) {
half := decimal.RequireFromString("0.5")
for _, p := range curve {
s := decimal.RequireFromString(p.Surviving)
if s.LessThanOrEqual(half) {
return decimal.RequireFromString(p.Days), true
}
}
return decimal.Zero, false
}
// naiveMedian is the median over filled observations only, ignoring everything still open. It is
// computed solely so it can be displayed beside the corrected figure; nothing downstream should use it.
func naiveMedian(obs []Observation) (decimal.Decimal, bool) {
var filled []decimal.Decimal
for _, o := range obs {
if o.Filled {
filled = append(filled, o.Days)
}
}
if len(filled) == 0 {
return decimal.Zero, false
}
sort.Slice(filled, func(i, j int) bool { return filled[i].LessThan(filled[j]) })
n := len(filled)
if n%2 == 1 {
return filled[n/2], true
}
// Even count: the mean of the two central values, exact.
return filled[n/2-1].Add(filled[n/2]).Div(decimal.NewFromInt(2)), true
}
// timeToFillFrom assembles the reported figure from a cohort.
func timeToFillFrom(obs []Observation) TimeToFill {
t := TimeToFill{Cohort: len(obs), Curve: []SurvivalPoint{}}
for _, o := range obs {
if o.Filled {
t.FilledN++
} else {
t.OpenN++
}
}
if len(obs) == 0 {
return t
}
t.Curve = kaplanMeier(obs)
if m, ok := kmMedian(t.Curve); ok {
t.Median, t.MedianReached = m.StringFixed(2), true
}
if m, ok := naiveMedian(obs); ok {
t.NaiveMedian = m.StringFixed(2)
}
return t
}
// minCohortForRate is the floor below which a rate is not reported.
//
// A single filled requisition is a 100 percent fill rate, and a single lost one is zero. Both are
// arithmetically correct and neither means anything. Suppressing them is not hiding data; the counts are
// always shown, and only the ratio is withheld.
const minCohortForRate = 5
// FillRate reports outcomes with BOTH denominators, because the definition genuinely forks and picking
// one silently is how two reports of the same desk disagree.
//
// A cancelled requisition is a role the client pulled. Counting it against the recruiter understates
// them; excluding it flatters them. Rather than choose, both rates are reported and named.
type FillRate struct {
Filled int `json:"filled"`
ClosedUnfilled int `json:"closed_unfilled"`
Cancelled int `json:"cancelled"`
StillOpen int `json:"still_open"`
// OfResolved counts cancellations against the outcome. OfDecided excludes them. Empty when the
// cohort is below the reporting floor.
OfResolved string `json:"rate_of_resolved,omitempty"`
OfDecided string `json:"rate_of_decided,omitempty"`
Suppressed bool `json:"suppressed"`
}
func fillRateFrom(filled, closed, cancelled, open int) FillRate {
f := FillRate{Filled: filled, ClosedUnfilled: closed, Cancelled: cancelled, StillOpen: open}
resolved := filled + closed + cancelled
decided := filled + closed
if resolved < minCohortForRate {
f.Suppressed = true
return f
}
f.OfResolved = rateOf(filled, resolved)
if decided > 0 {
f.OfDecided = rateOf(filled, decided)
}
return f
}
// AgingBucket groups still open work by how long it has been open. The distribution is reported rather
// than an average, because an average age hides the tail, and the tail is the problem.
type AgingBucket struct {
Label string `json:"label"`
Min int `json:"min_days"`
Max int `json:"max_days"` // -1 means unbounded
Count int `json:"count"`
}
var agingBands = []AgingBucket{
{Label: "Under a week", Min: 0, Max: 6},
{Label: "1 to 2 weeks", Min: 7, Max: 13},
{Label: "2 to 4 weeks", Min: 14, Max: 29},
{Label: "1 to 2 months", Min: 30, Max: 59},
{Label: "Over 2 months", Min: 60, Max: -1},
}
// agingFrom buckets ages in whole days. The bands are contiguous and exhaustive over the non negative
// integers, which agingBandsCover asserts, so no open requisition can fall between two buckets and
// disappear from the report.
func agingFrom(ages []int) []AgingBucket {
out := make([]AgingBucket, len(agingBands))
copy(out, agingBands)
for _, a := range ages {
if a < 0 {
a = 0 // an order opened later today is zero days old, never negative
}
for i := range out {
if a >= out[i].Min && (out[i].Max == -1 || a <= out[i].Max) {
out[i].Count++
break
}
}
}
return out
}