feat: capacity-aware scheduling (#8) — real focus factors drive every forecast
Turns the single-serial-worker scheduler into a capacity-aware, multi-lane one. Configured team members become lanes; an issue runs on its assignee's lane (or the earliest-free lane), its duration scaled by that lane's throughput (focusFactor × allocation). Every forecast — Focus cone, Runway, milestone drill-in — is now capacity-aware. core (@commitea/core): - capacity/capacity-v0: CapacityMember + capacityPerWorkday + parseCapacityConfig (clamps, drops invalid; degrades to []). - scheduler/scheduler-capacity-v0: scheduleWithCapacity reuses the v0 topo order + critical path, re-lays work across lanes (layoutOnLanes, resolveLanes, makespan). Empty workers → the single serial plan verbatim. - forecast() gains options.workers: each MC trial lays sampled durations across the lanes and takes the makespan; serial path unchanged. SchedulableIssue gains assignee; ScheduledItem gains worker. - 11 new tests (parse/clamp, parallelism halves makespan, speed scaling, assignee routing, cross-lane deps, forecast makespan shrinks with lanes). app: - pm-state capacity/members.json read (readCapacity + pmstate:capacity bridge); useCapacity hook → workers; forecastBacklog/runwayView/milestoneView pass workers. - Runway Capacity card shows the real config (person · focus · alloc · pd/day). Config lives in pm-state (D4); seeded christian(0.8)/stephen(0.6×0.5). Degrades to the fixture/serial when absent. Verified: 128 core tests green, desktop typecheck clean, 14 fixture e2e green. Live: the capacity card is real, and the P2 forecast shifts 32d→37d — honest, since real focus factors (<1) replace the v0 focus-1.0 assumption. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
88
packages/core/src/capacity/capacity-v0.test.ts
Normal file
88
packages/core/src/capacity/capacity-v0.test.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { type DependencyEdge, type SchedulableIssue } from '../scheduler/scheduler-v0.js'
|
||||
import { makespan, scheduleWithCapacity, type Worker } from '../scheduler/scheduler-capacity-v0.js'
|
||||
import { capacityPerWorkday, parseCapacityConfig } from './capacity-v0.js'
|
||||
|
||||
describe('capacity model', () => {
|
||||
it('capacityPerWorkday = focusFactor × allocation', () => {
|
||||
expect(capacityPerWorkday({ person: 'a', focusFactor: 0.8, allocation: 0.5 })).toBeCloseTo(0.4)
|
||||
})
|
||||
|
||||
it('parses + clamps config, drops invalid members', () => {
|
||||
const members = parseCapacityConfig({
|
||||
members: [
|
||||
{ person: 'christian', focusFactor: 0.8, allocation: 1 },
|
||||
{ person: 'ak', focusFactor: 1.5, allocation: -1 }, // clamps to 1 / 0 → zero capacity → dropped
|
||||
{ focusFactor: 0.8 }, // no person → dropped
|
||||
],
|
||||
})
|
||||
expect(members).toEqual([{ person: 'christian', focusFactor: 0.8, allocation: 1 }])
|
||||
})
|
||||
|
||||
it('returns [] for a non-array/absent members field', () => {
|
||||
expect(parseCapacityConfig({})).toEqual([])
|
||||
expect(parseCapacityConfig({ members: 'nope' })).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
function issue(number: number, over: Partial<SchedulableIssue> = {}): SchedulableIssue {
|
||||
return { number, title: `#${number}`, labels: [], estimateDays: 4, priority: 2, ...over }
|
||||
}
|
||||
|
||||
describe('scheduleWithCapacity', () => {
|
||||
it('with no workers, falls back to the single serial plan', () => {
|
||||
const issues = [issue(1), issue(2)]
|
||||
const plan = scheduleWithCapacity(issues, [], [])
|
||||
expect(makespan(plan)).toBe(8) // 4 + 4 serial
|
||||
})
|
||||
|
||||
it('parallelizes independent work across lanes (makespan shrinks)', () => {
|
||||
const issues = [issue(1), issue(2), issue(3), issue(4)] // 4×4d = 16d serial
|
||||
const one: Worker[] = [{ person: 'a', speed: 1 }]
|
||||
const two: Worker[] = [
|
||||
{ person: 'a', speed: 1 },
|
||||
{ person: 'b', speed: 1 },
|
||||
]
|
||||
expect(makespan(scheduleWithCapacity(issues, [], one))).toBe(16)
|
||||
expect(makespan(scheduleWithCapacity(issues, [], two))).toBe(8) // two lanes → half
|
||||
})
|
||||
|
||||
it('scales duration by a lane speed (slower lane takes longer)', () => {
|
||||
const plan = scheduleWithCapacity([issue(1, { estimateDays: 4 })], [], [{ person: 'a', speed: 0.5 }])
|
||||
expect(plan.items[0].durationDays).toBe(8) // 4 / 0.5
|
||||
expect(plan.items[0].worker).toBe('a')
|
||||
})
|
||||
|
||||
it('routes an issue to its assignee lane', () => {
|
||||
const issues = [issue(1, { assignee: 'ak' }), issue(2, { assignee: 'sm' })]
|
||||
const workers: Worker[] = [
|
||||
{ person: 'ak', speed: 1 },
|
||||
{ person: 'sm', speed: 1 },
|
||||
]
|
||||
const plan = scheduleWithCapacity(issues, [], workers)
|
||||
const byN = Object.fromEntries(plan.items.map((i) => [i.number, i]))
|
||||
expect(byN[1].worker).toBe('ak')
|
||||
expect(byN[2].worker).toBe('sm')
|
||||
// both start at 0 (different lanes) → parallel
|
||||
expect(byN[1].startDay).toBe(0)
|
||||
expect(byN[2].startDay).toBe(0)
|
||||
})
|
||||
|
||||
it('adds a lane for an assignee not in the config (mean speed)', () => {
|
||||
const plan = scheduleWithCapacity([issue(1, { assignee: 'newbie' })], [], [{ person: 'a', speed: 0.5 }])
|
||||
expect(plan.items[0].worker).toBe('newbie')
|
||||
expect(plan.items[0].durationDays).toBe(8) // mean speed 0.5 → 4/0.5
|
||||
})
|
||||
|
||||
it('respects dependencies across lanes (a blocker finishes before its dependent starts)', () => {
|
||||
const edges: DependencyEdge[] = [{ issue: 2, dependsOn: 1 }]
|
||||
const workers: Worker[] = [
|
||||
{ person: 'a', speed: 1 },
|
||||
{ person: 'b', speed: 1 },
|
||||
]
|
||||
const plan = scheduleWithCapacity([issue(1), issue(2)], edges, workers)
|
||||
const byN = Object.fromEntries(plan.items.map((i) => [i.number, i]))
|
||||
expect(byN[2].startDay).toBeGreaterThanOrEqual(byN[1].endDay) // #2 waits for #1 even on another lane
|
||||
})
|
||||
})
|
||||
49
packages/core/src/capacity/capacity-v0.ts
Normal file
49
packages/core/src/capacity/capacity-v0.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Capacity model (#8). A member's throughput is `focusFactor × allocation` =
|
||||
* ideal person-days of project work delivered per calendar working day. The
|
||||
* scheduler treats each member as a lane whose task durations are scaled by that
|
||||
* rate (a 0.5-capacity person takes twice as long on an est/2d task). Config
|
||||
* lives in the pm-state repo (`capacity/members.yaml/json`, D4); estimates are
|
||||
* in ideal person-days (pm-state.md).
|
||||
*/
|
||||
|
||||
export interface CapacityMember {
|
||||
/** gitea login. */
|
||||
person: string
|
||||
/** Productive fraction of a working day (0..1). */
|
||||
focusFactor: number
|
||||
/** Fraction of that allocated to this project (0..1). */
|
||||
allocation: number
|
||||
}
|
||||
|
||||
/** Ideal person-days delivered per calendar working day. */
|
||||
export function capacityPerWorkday(m: CapacityMember): number {
|
||||
return m.focusFactor * m.allocation
|
||||
}
|
||||
|
||||
function clamp01(n: unknown, fallback: number): number {
|
||||
return typeof n === 'number' && Number.isFinite(n) ? Math.min(1, Math.max(0, n)) : fallback
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a raw capacity config (`{ members: [...] }`) into members. Unknown
|
||||
* shapes degrade to `[]` (→ the scheduler falls back to a single worker), never
|
||||
* throw. focusFactor/allocation clamp to [0,1]; a member without a person is dropped.
|
||||
*/
|
||||
export function parseCapacityConfig(raw: unknown): CapacityMember[] {
|
||||
const list = (raw as { members?: unknown })?.members
|
||||
if (!Array.isArray(list)) return []
|
||||
const out: CapacityMember[] = []
|
||||
for (const m of list) {
|
||||
const r = (m ?? {}) as Record<string, unknown>
|
||||
const person = typeof r.person === 'string' ? r.person.trim() : ''
|
||||
if (!person) continue
|
||||
const member: CapacityMember = {
|
||||
person,
|
||||
focusFactor: clamp01(r.focusFactor, 0.8),
|
||||
allocation: clamp01(r.allocation, 1),
|
||||
}
|
||||
if (capacityPerWorkday(member) > 0) out.push(member)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -120,4 +120,20 @@ describe('forecast', () => {
|
||||
expect(fitted.coldStart).toBe(false)
|
||||
expect(fitted.p50Day).toBeLessThan(priors.p50Day)
|
||||
})
|
||||
|
||||
it('capacity lanes parallelize the sim — the makespan shrinks with more workers', () => {
|
||||
const independent = [issue(1), issue(2), issue(3), issue(4)] // no deps → fully parallelizable
|
||||
const serial = forecast(independent, [], { trials: 2000, seed: 7 })
|
||||
const twoLanes = forecast(independent, [], {
|
||||
trials: 2000,
|
||||
seed: 7,
|
||||
workers: [
|
||||
{ person: 'a', speed: 1 },
|
||||
{ person: 'b', speed: 1 },
|
||||
],
|
||||
})
|
||||
// two equal lanes ≈ half the serial landing (independent work)
|
||||
expect(twoLanes.p80Day).toBeLessThan(serial.p80Day)
|
||||
expect(twoLanes.p80Day).toBeLessThan(serial.p80Day * 0.7)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
schedule,
|
||||
type SchedulableIssue,
|
||||
} from '../scheduler/scheduler-v0.js'
|
||||
import { type LaneInputs, layoutOnLanes, resolveLanes, type Worker } from '../scheduler/scheduler-capacity-v0.js'
|
||||
|
||||
export interface LognormalPrior {
|
||||
/** Median log-ratio: sampled median duration = estimate * e^mu. */
|
||||
@@ -76,6 +77,12 @@ export interface ForecastOptions {
|
||||
* the sim; otherwise the code-resident cold-start priors do.
|
||||
*/
|
||||
model?: DurationModel
|
||||
/**
|
||||
* Capacity lanes. When provided, each trial lays sampled durations across the
|
||||
* lanes (parallel) instead of a single serial worker — the makespan shrinks
|
||||
* toward the critical path. Empty/absent → single serial worker.
|
||||
*/
|
||||
workers?: Worker[]
|
||||
}
|
||||
|
||||
/** Resolve the lognormal params for an estimate, preferring a fitted model. */
|
||||
@@ -158,15 +165,32 @@ export function forecast(
|
||||
const priors = order.map((it) => durationParams(it.durationDays, options.model))
|
||||
const rng = mulberry32(seed)
|
||||
|
||||
// endByRank[k][t] = working day the (k+1)-th scheduled issue completes on trial t.
|
||||
// Capacity lanes, when configured — the per-trial layout goes parallel.
|
||||
const laneInputs: LaneInputs = {
|
||||
order: order.map((it) => it.number),
|
||||
blockedBy: new Map(order.map((it) => [it.number, it.blockedBy])),
|
||||
assignee: new Map(issues.map((i) => [i.number, i.assignee ?? null])),
|
||||
}
|
||||
const lanes =
|
||||
options.workers && options.workers.length ? resolveLanes(options.workers, [...laneInputs.assignee.values()]) : null
|
||||
|
||||
// endByRank[k][t] = working day the (k+1)-th issue *to finish* completes on trial t.
|
||||
const endByRank: number[][] = Array.from({ length: n }, () => new Array<number>(trials))
|
||||
for (let t = 0; t < trials; t++) {
|
||||
let cursor = 0
|
||||
for (let k = 0; k < n; k++) {
|
||||
const p = priors[k]
|
||||
const sampled = order[k].durationDays * Math.exp(p.mu + p.sigma * standardNormal(rng))
|
||||
cursor += sampled
|
||||
endByRank[k][t] = cursor
|
||||
const sampled = order.map((it, k) => it.durationDays * Math.exp(priors[k].mu + priors[k].sigma * standardNormal(rng)))
|
||||
if (lanes) {
|
||||
// parallel: lay the sampled durations across lanes, then sort finish days
|
||||
const byNumber = new Map(order.map((it, k) => [it.number, sampled[k]]))
|
||||
const { finishAt } = layoutOnLanes(laneInputs, lanes, (num) => byNumber.get(num)!)
|
||||
const finishes = order.map((it) => finishAt.get(it.number)!).sort((a, b) => a - b)
|
||||
for (let k = 0; k < n; k++) endByRank[k][t] = finishes[k]
|
||||
} else {
|
||||
// single serial worker: cumulative sum (already sorted ascending)
|
||||
let cursor = 0
|
||||
for (let k = 0; k < n; k++) {
|
||||
cursor += sampled[k]
|
||||
endByRank[k][t] = cursor
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -49,6 +49,12 @@ export type {
|
||||
SchedulePlan,
|
||||
} from './scheduler/scheduler-v0.js'
|
||||
|
||||
export { makespan, scheduleWithCapacity } from './scheduler/scheduler-capacity-v0.js'
|
||||
export type { Worker } from './scheduler/scheduler-capacity-v0.js'
|
||||
|
||||
export { capacityPerWorkday, parseCapacityConfig } from './capacity/capacity-v0.js'
|
||||
export type { CapacityMember } from './capacity/capacity-v0.js'
|
||||
|
||||
export {
|
||||
COLD_START_PRIORS,
|
||||
durationParams,
|
||||
|
||||
119
packages/core/src/scheduler/scheduler-capacity-v0.ts
Normal file
119
packages/core/src/scheduler/scheduler-capacity-v0.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Capacity-aware scheduler (#8). Reuses the single-worker scheduler's topological
|
||||
* order + critical-path marking, then re-lays the work across lanes: an issue
|
||||
* runs on its assignee's lane (or the earliest-free lane when unassigned), its
|
||||
* duration scaled by that lane's speed (ideal person-days/workday). Makespan
|
||||
* shrinks toward the critical path as lanes are added. Deterministic; falls back
|
||||
* to the single serial worker when no capacity is configured. The lane layout is
|
||||
* factored so the Monte Carlo forecast reuses it per trial with sampled durations.
|
||||
*/
|
||||
|
||||
import {
|
||||
type DependencyEdge,
|
||||
schedule,
|
||||
type SchedulableIssue,
|
||||
type SchedulePlan,
|
||||
} from './scheduler-v0.js'
|
||||
|
||||
/** A scheduling lane: a person and their throughput (ideal person-days / workday). */
|
||||
export interface Worker {
|
||||
person: string
|
||||
speed: number
|
||||
}
|
||||
|
||||
/** The topological order + per-issue relations the layout needs (from the base plan). */
|
||||
export interface LaneInputs {
|
||||
order: number[]
|
||||
blockedBy: Map<number, number[]>
|
||||
assignee: Map<number, string | null>
|
||||
}
|
||||
|
||||
/** Ensure a lane exists for every assignee; unconfigured assignees get the mean speed. */
|
||||
export function resolveLanes(workers: Worker[], assignees: (string | null)[]): Worker[] {
|
||||
const lanes = [...workers]
|
||||
const known = new Set(lanes.map((w) => w.person))
|
||||
const meanSpeed = lanes.length ? lanes.reduce((s, w) => s + w.speed, 0) / lanes.length : 1
|
||||
for (const a of assignees) {
|
||||
if (a && !known.has(a)) {
|
||||
lanes.push({ person: a, speed: meanSpeed })
|
||||
known.add(a)
|
||||
}
|
||||
}
|
||||
return lanes
|
||||
}
|
||||
|
||||
function pickWorker(lanes: Worker[], freeAt: Map<string, number>, assignee: string | null | undefined): Worker {
|
||||
if (assignee) {
|
||||
const own = lanes.find((w) => w.person === assignee)
|
||||
if (own) return own
|
||||
}
|
||||
let best = lanes[0]
|
||||
for (const w of lanes) if (freeAt.get(w.person)! < freeAt.get(best.person)!) best = w
|
||||
return best
|
||||
}
|
||||
|
||||
/**
|
||||
* Lay a topologically-ordered set out across lanes. `duration(n)` supplies each
|
||||
* issue's duration for this layout (estimate, or a sampled value in a MC trial).
|
||||
* Returns each issue's finish day + the lane it ran on. Order guarantees a
|
||||
* dependency is always laid out before its dependents.
|
||||
*/
|
||||
export function layoutOnLanes(
|
||||
inputs: LaneInputs,
|
||||
lanes: Worker[],
|
||||
duration: (n: number) => number,
|
||||
): { finishAt: Map<number, number>; startAt: Map<number, number>; laneOf: Map<number, string> } {
|
||||
const freeAt = new Map(lanes.map((w) => [w.person, 0]))
|
||||
const finishAt = new Map<number, number>()
|
||||
const startAt = new Map<number, number>()
|
||||
const laneOf = new Map<number, string>()
|
||||
for (const n of inputs.order) {
|
||||
const worker = pickWorker(lanes, freeAt, inputs.assignee.get(n))
|
||||
const blockers = inputs.blockedBy.get(n) ?? []
|
||||
const depFinish = blockers.length ? Math.max(...blockers.map((d) => finishAt.get(d) ?? 0)) : 0
|
||||
const start = Math.max(freeAt.get(worker.person)!, depFinish)
|
||||
const end = start + duration(n) / worker.speed
|
||||
startAt.set(n, start)
|
||||
finishAt.set(n, end)
|
||||
laneOf.set(n, worker.person)
|
||||
freeAt.set(worker.person, end)
|
||||
}
|
||||
return { finishAt, startAt, laneOf }
|
||||
}
|
||||
|
||||
/**
|
||||
* Capacity-aware plan. An empty `workers` means no capacity is configured → the
|
||||
* single-worker plan verbatim.
|
||||
*/
|
||||
export function scheduleWithCapacity(
|
||||
issues: SchedulableIssue[],
|
||||
edges: DependencyEdge[],
|
||||
workers: Worker[],
|
||||
): SchedulePlan {
|
||||
if (workers.length === 0) return schedule(issues, edges)
|
||||
const base = schedule(issues, edges)
|
||||
if (base.cycle || base.items.length === 0) return base
|
||||
|
||||
const inputs: LaneInputs = {
|
||||
order: base.items.map((it) => it.number),
|
||||
blockedBy: new Map(base.items.map((it) => [it.number, it.blockedBy])),
|
||||
assignee: new Map(issues.map((i) => [i.number, i.assignee ?? null])),
|
||||
}
|
||||
const lanes = resolveLanes(workers, [...inputs.assignee.values()])
|
||||
const durationOf = new Map(base.items.map((it) => [it.number, it.durationDays]))
|
||||
const { finishAt, startAt, laneOf } = layoutOnLanes(inputs, lanes, (n) => durationOf.get(n)!)
|
||||
|
||||
const items = base.items.map((it) => ({
|
||||
...it,
|
||||
startDay: startAt.get(it.number)!,
|
||||
endDay: finishAt.get(it.number)!,
|
||||
durationDays: finishAt.get(it.number)! - startAt.get(it.number)!,
|
||||
worker: laneOf.get(it.number),
|
||||
}))
|
||||
return { items, cycle: null }
|
||||
}
|
||||
|
||||
/** Makespan (last finish) of a plan — the project's landing day. */
|
||||
export function makespan(plan: SchedulePlan): number {
|
||||
return plan.items.reduce((m, it) => Math.max(m, it.endDay), 0)
|
||||
}
|
||||
@@ -21,6 +21,8 @@ export interface SchedulableIssue {
|
||||
estimateDays: number | null
|
||||
/** 1 (most urgent) … 4; null when unset. */
|
||||
priority: number | null
|
||||
/** gitea assignee login, for capacity-aware lane routing; optional. */
|
||||
assignee?: string | null
|
||||
}
|
||||
|
||||
export interface DependencyEdge {
|
||||
@@ -45,6 +47,8 @@ export interface ScheduledItem {
|
||||
/** On a longest-duration dependency chain. */
|
||||
critical: boolean
|
||||
rationale: string
|
||||
/** Lane it's scheduled on, when capacity-aware; absent for the single-worker plan. */
|
||||
worker?: string
|
||||
}
|
||||
|
||||
export interface SchedulePlan {
|
||||
|
||||
Reference in New Issue
Block a user