Workforce Aging Chain

A workforce aging chain over seniority levels, built with the multidimensional SD DSL.
Keywords

system dynamics, aging chain, workforce planning, arrays, vectors, multidimensional, bptk, bptk-py, python, business simulation

A Workforce Aging Chain

Every professional services firm runs on the same structure: people are hired, they gain experience, some of them are promoted, and some of them leave. The interesting questions are never about one level in isolation. How long does it take for a hiring decision to show up in the senior ranks? What does the pyramid cost once it has settled? If we freeze junior hiring for a year, when do we feel it?

This is an aging chain, and it is the classic use of arrays in System Dynamics. The structure repeats: every seniority level is a stock, fed by hiring and by promotion out of the level below, drained by promotion and attrition. Writing that out three times would say the same thing three times over. With the multidimensional SD DSL you say it once, over a vector of levels.

The Structure

Three seniority levels - junior, mid, senior - and one stock per level:

Element Kind Meaning
headcount[level] stock the people at that level
hiring[level] flow lateral hires arriving from outside
promotion[level] flow people leaving the level upwards
attrition[level] flow people leaving the firm
salary[level], cost[level] constant, converter the cost of the level
total_headcount, total_cost, average_salary converters the firm as a whole

The chain is what makes this more than three separate models: promotion out of one level is an inflow to the next. What leaves junior upwards arrives in mid, and what leaves mid upwards arrives in senior. A senior is never promoted out of the chain, so the senior promotion rate is zero.

Declaring the Elements

An arrayed element starts life like any other. It becomes arrayed when you give it a shape - here setup_named_vector, which takes a dictionary of index names to initial values. The names are yours to choose, and they are what you use to address a single level later on.

Equations That Hold for Every Level

The three flows and the cost have the same form at every level, so each is written once. headcount * promotion_rate is an element-wise product: it multiplies junior by junior, mid by mid and senior by senior, and never mixes them.

The chain itself is the one part that has to be written level by level, and that is not a shortcoming of the notation - it is the model. Each level has a different neighbour above and below, so headcount[mid] genuinely references promotion[junior], which is a different index of a different element. An element-wise equation cannot express that, and should not.

The Firm as a Whole

Three aggregations turn the vector back into single numbers. arr_sum adds every index, arr_mean averages them - and the result is an ordinary scalar element, so you can plot it, reference it from another equation, or read it in a scenario.

How the Pyramid Settles

The firm starts with 100 juniors, 40 mids and 15 seniors, and the plot below shows what the current policy does with that. Each level is a separate series, addressed by its flattened name - headcount[junior] is how a sub-element appears in a plot, in a scenario constant, and in the simulation results.

Three levels, three quite different stories, from one set of equations:

  • Junior collapses, from 100 to 48 within a year. Twelve hires a month cannot keep up with a quarter of the level leaving it every month - 15 % promoted, 10 % leaving the firm.
  • Mid jumps the other way, from 40 to about 76 in six months, fed by the juniors promoted into it, then eases back to 73 as that wave passes.
  • Senior grows and keeps growing: 141 people by month 40, and still climbing long after the other two have settled.

That last one is the whole point of the model, and it is worth being precise about why. A level’s time constant is one divided by its total outflow rate. Junior loses 25 % a month, so its time constant is four months. Senior loses 4 %, so its time constant is twenty-five months. The senior level is the slow part of the system: it takes years to build, years to unwind, and it carries the highest salary. Every question about this firm’s cost structure is really a question about the senior level.

Where it ends up can be worked out by hand, by setting each level’s inflow equal to its outflow:

\[junior = \frac{12}{0.15 + 0.10} = 48.0\]

\[mid = \frac{3 + 0.15 \cdot junior}{0.08 + 0.06} = 72.9\]

\[senior = \frac{1 + 0.08 \cdot mid}{0.04} = 170.7\]

So the pyramid inverts. The firm ends up with more seniors than juniors - 171 against 48 - which at these salaries is a very different cost base from the one it started with. Note how long that takes: the plot above stops at month 40, where the senior level has reached 141 of its eventual 171. Junior and mid have been settled for years by then; senior needs about fifteen.

The cost line tells the same story in money. The firm starts at 11.6 million a month, is at 27.8 million by month 40, and settles at 31.6 million - nearly three times the starting cost, from a headcount that only grows from 155 to 292. The difference is the mix: the level that grows is the expensive one.

Experiment With the Policy

The three rates below are the policy levers, and each one addresses a single index of an arrayed constant. hiring_rate[junior] is a name like any other, so a scenario can set it, a slider can drive it, and everything downstream follows.

Two experiments worth running:

  • Pull junior hiring down to zero - a hiring freeze. The junior level empties quickly, but the senior level keeps growing for years on the promotions already in the pipe. The cost line barely notices at first.
  • Push senior attrition from 4 % to 8 %. That halves the senior time constant, and the whole cost base moves - more than any change to hiring does.

Why Three Flows and Not One

A level that both rises and falls looks like a job for a single signed flow, and this model deliberately does not do that: a flow never goes negative - see the reference page. Hiring, promotion and attrition are three flows because each of them genuinely has a direction, which is also the clearer way to read the chain: what leaves one level upwards is what arrives in the next.

The other thing worth taking away: an arrayed element is a parent with sub-elements. headcount holds no value of its own; headcount["junior"] does. That is why the plots above name headcount[junior], and it is what makes an arrayed model cheap - to everything downstream, a sub-element is an ordinary scalar element.