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:
Croissant Le Doux
2026-07-09 00:59:03 -04:00
parent d80e1266ee
commit 1636d6bada
15 changed files with 416 additions and 13 deletions

View 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
})
})

View 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
}

View File

@@ -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)
})
})

View File

@@ -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
}
}
}

View File

@@ -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,

View 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)
}

View File

@@ -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 {