@forwardimpact/model 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/agent.js +754 -0
- package/lib/checklist.js +103 -0
- package/lib/derivation.js +766 -0
- package/lib/index.js +121 -0
- package/lib/interview.js +539 -0
- package/lib/job-cache.js +89 -0
- package/lib/job.js +228 -0
- package/lib/matching.js +891 -0
- package/lib/modifiers.js +158 -0
- package/lib/profile.js +262 -0
- package/lib/progression.js +510 -0
- package/package.json +35 -0
|
@@ -0,0 +1,766 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Engineering Pathway Job Derivation Functions
|
|
3
|
+
*
|
|
4
|
+
* This module provides pure functions for deriving job definitions from
|
|
5
|
+
* discipline, track, and grade combinations.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
SkillType,
|
|
10
|
+
SkillLevel,
|
|
11
|
+
BehaviourMaturity,
|
|
12
|
+
SKILL_LEVEL_ORDER,
|
|
13
|
+
getSkillLevelIndex,
|
|
14
|
+
getBehaviourMaturityIndex,
|
|
15
|
+
clampSkillLevel,
|
|
16
|
+
clampBehaviourMaturity,
|
|
17
|
+
skillLevelMeetsRequirement,
|
|
18
|
+
} from "@forwardimpact/schema/levels";
|
|
19
|
+
|
|
20
|
+
import { resolveSkillModifier } from "./modifiers.js";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Build a Map of skillId → skillType for a discipline
|
|
24
|
+
* Enables O(1) lookup instead of repeated array scans
|
|
25
|
+
* @param {import('./levels.js').Discipline} discipline - The discipline
|
|
26
|
+
* @returns {Map<string, string>} Map of skill ID to skill type
|
|
27
|
+
*/
|
|
28
|
+
export function buildSkillTypeMap(discipline) {
|
|
29
|
+
const map = new Map();
|
|
30
|
+
for (const id of discipline.coreSkills || []) {
|
|
31
|
+
map.set(id, SkillType.PRIMARY);
|
|
32
|
+
}
|
|
33
|
+
for (const id of discipline.supportingSkills || []) {
|
|
34
|
+
map.set(id, SkillType.SECONDARY);
|
|
35
|
+
}
|
|
36
|
+
for (const id of discipline.broadSkills || []) {
|
|
37
|
+
map.set(id, SkillType.BROAD);
|
|
38
|
+
}
|
|
39
|
+
return map;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Determine the skill type (primary/secondary/broad) for a skill within a discipline
|
|
44
|
+
* @param {import('./levels.js').Discipline} discipline - The discipline
|
|
45
|
+
* @param {string} skillId - The skill ID
|
|
46
|
+
* @returns {string|null} The skill type or null if skill not in discipline
|
|
47
|
+
*/
|
|
48
|
+
export function getSkillTypeForDiscipline(discipline, skillId) {
|
|
49
|
+
if (discipline.coreSkills?.includes(skillId)) {
|
|
50
|
+
return SkillType.PRIMARY;
|
|
51
|
+
}
|
|
52
|
+
if (discipline.supportingSkills?.includes(skillId)) {
|
|
53
|
+
return SkillType.SECONDARY;
|
|
54
|
+
}
|
|
55
|
+
if (discipline.broadSkills?.includes(skillId)) {
|
|
56
|
+
return SkillType.BROAD;
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Find the highest base skill level index for a grade
|
|
63
|
+
*
|
|
64
|
+
* This returns the maximum skill level index across primary, secondary, and broad
|
|
65
|
+
* skill types for the given grade. Used to cap positive skill modifiers.
|
|
66
|
+
*
|
|
67
|
+
* @param {import('./levels.js').Grade} grade - The grade
|
|
68
|
+
* @returns {number} The highest base skill level index
|
|
69
|
+
*/
|
|
70
|
+
export function findMaxBaseSkillLevel(grade) {
|
|
71
|
+
const primaryIndex = getSkillLevelIndex(grade.baseSkillLevels.primary);
|
|
72
|
+
const secondaryIndex = getSkillLevelIndex(grade.baseSkillLevels.secondary);
|
|
73
|
+
const broadIndex = getSkillLevelIndex(grade.baseSkillLevels.broad);
|
|
74
|
+
return Math.max(primaryIndex, secondaryIndex, broadIndex);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Derive the skill level for a specific skill given discipline, track, and grade
|
|
79
|
+
*
|
|
80
|
+
* Resolves capability-based modifiers (e.g., { scale: 1 }) by looking up the skill's capability.
|
|
81
|
+
*
|
|
82
|
+
* Positive modifiers are capped at the highest base skill level for the grade,
|
|
83
|
+
* ensuring skills cannot exceed what's appropriate for that career level.
|
|
84
|
+
* Negative modifiers can still bring skills below their base to create emphasis.
|
|
85
|
+
*
|
|
86
|
+
* @param {Object} params
|
|
87
|
+
* @param {import('./levels.js').Discipline} params.discipline - The discipline
|
|
88
|
+
* @param {import('./levels.js').Track} [params.track] - The track (optional)
|
|
89
|
+
* @param {import('./levels.js').Grade} params.grade - The grade
|
|
90
|
+
* @param {string} params.skillId - The skill ID
|
|
91
|
+
* @param {import('./levels.js').Skill[]} params.skills - All available skills (for capability lookup)
|
|
92
|
+
* @returns {string|null} The derived skill level or null if skill not in discipline
|
|
93
|
+
*/
|
|
94
|
+
export function deriveSkillLevel({
|
|
95
|
+
discipline,
|
|
96
|
+
grade,
|
|
97
|
+
track = null,
|
|
98
|
+
skillId,
|
|
99
|
+
skills,
|
|
100
|
+
}) {
|
|
101
|
+
// 1. Determine skill type for discipline
|
|
102
|
+
const skillType = getSkillTypeForDiscipline(discipline, skillId);
|
|
103
|
+
|
|
104
|
+
// 2. Get base level from grade for that skill type
|
|
105
|
+
// Track-added skills (null skillType) use broad as base
|
|
106
|
+
const effectiveType = skillType || SkillType.BROAD;
|
|
107
|
+
const baseLevel = grade.baseSkillLevels[effectiveType];
|
|
108
|
+
const baseIndex = getSkillLevelIndex(baseLevel);
|
|
109
|
+
|
|
110
|
+
// 3. Apply track modifier via capability lookup (if track provided)
|
|
111
|
+
const effectiveTrack = track || { skillModifiers: {} };
|
|
112
|
+
const modifier = resolveSkillModifier(
|
|
113
|
+
skillId,
|
|
114
|
+
effectiveTrack.skillModifiers,
|
|
115
|
+
skills,
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
// Track-added skills require a positive modifier to be included
|
|
119
|
+
if (!skillType && modifier <= 0) {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
let modifiedIndex = baseIndex + modifier;
|
|
124
|
+
|
|
125
|
+
// 4. Cap positive modifications at the grade's highest base skill level
|
|
126
|
+
// Negative modifiers can bring skills below base to create emphasis,
|
|
127
|
+
// but positive modifiers should not push skills beyond the grade ceiling
|
|
128
|
+
if (modifier > 0) {
|
|
129
|
+
const maxIndex = findMaxBaseSkillLevel(grade);
|
|
130
|
+
modifiedIndex = Math.min(modifiedIndex, maxIndex);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// 5. Clamp to valid range
|
|
134
|
+
return clampSkillLevel(modifiedIndex);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Derive the behaviour maturity for a specific behaviour given discipline, track, and grade
|
|
139
|
+
* @param {Object} params
|
|
140
|
+
* @param {import('./levels.js').Discipline} params.discipline - The discipline
|
|
141
|
+
* @param {import('./levels.js').Track} [params.track] - The track (optional)
|
|
142
|
+
* @param {import('./levels.js').Grade} params.grade - The grade
|
|
143
|
+
* @param {string} params.behaviourId - The behaviour ID
|
|
144
|
+
* @returns {string} The derived maturity level
|
|
145
|
+
*/
|
|
146
|
+
export function deriveBehaviourMaturity({
|
|
147
|
+
discipline,
|
|
148
|
+
grade,
|
|
149
|
+
track = null,
|
|
150
|
+
behaviourId,
|
|
151
|
+
}) {
|
|
152
|
+
// 1. Get base maturity from grade
|
|
153
|
+
const baseMaturity = grade.baseBehaviourMaturity;
|
|
154
|
+
const baseIndex = getBehaviourMaturityIndex(baseMaturity);
|
|
155
|
+
|
|
156
|
+
// 2. Calculate behaviour modifiers (additive from discipline and track)
|
|
157
|
+
const disciplineModifier = discipline.behaviourModifiers?.[behaviourId] ?? 0;
|
|
158
|
+
const effectiveTrack = track || { behaviourModifiers: {} };
|
|
159
|
+
const trackModifier = effectiveTrack.behaviourModifiers?.[behaviourId] ?? 0;
|
|
160
|
+
const totalModifier = disciplineModifier + trackModifier;
|
|
161
|
+
|
|
162
|
+
// 3. Apply modifier and clamp
|
|
163
|
+
const modifiedIndex = baseIndex + totalModifier;
|
|
164
|
+
return clampBehaviourMaturity(modifiedIndex);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Derive the complete skill matrix for a job
|
|
169
|
+
* @param {Object} params
|
|
170
|
+
* @param {import('./levels.js').Discipline} params.discipline - The discipline
|
|
171
|
+
* @param {import('./levels.js').Grade} params.grade - The grade
|
|
172
|
+
* @param {import('./levels.js').Track} [params.track] - The track (optional)
|
|
173
|
+
* @param {import('./levels.js').Skill[]} params.skills - All available skills
|
|
174
|
+
* @returns {import('./levels.js').SkillMatrixEntry[]} Complete skill matrix
|
|
175
|
+
*/
|
|
176
|
+
export function deriveSkillMatrix({ discipline, grade, track = null, skills }) {
|
|
177
|
+
const matrix = [];
|
|
178
|
+
const effectiveTrack = track || { skillModifiers: {} };
|
|
179
|
+
|
|
180
|
+
// Collect all skills for this discipline
|
|
181
|
+
const allDisciplineSkills = new Set([
|
|
182
|
+
...(discipline.coreSkills || []),
|
|
183
|
+
...(discipline.supportingSkills || []),
|
|
184
|
+
...(discipline.broadSkills || []),
|
|
185
|
+
]);
|
|
186
|
+
|
|
187
|
+
// Collect capabilities with positive track modifiers
|
|
188
|
+
const trackCapabilities = new Set(
|
|
189
|
+
Object.entries(effectiveTrack.skillModifiers || {})
|
|
190
|
+
.filter(([_, modifier]) => modifier > 0)
|
|
191
|
+
.map(([capability]) => capability),
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
for (const skill of skills) {
|
|
195
|
+
// Include skill if it's in the discipline OR in a track-modified capability
|
|
196
|
+
const inDiscipline = allDisciplineSkills.has(skill.id);
|
|
197
|
+
const inTrackCapability = trackCapabilities.has(skill.capability);
|
|
198
|
+
|
|
199
|
+
if (!inDiscipline && !inTrackCapability) {
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const skillType = getSkillTypeForDiscipline(discipline, skill.id);
|
|
204
|
+
const level = deriveSkillLevel({
|
|
205
|
+
discipline,
|
|
206
|
+
grade,
|
|
207
|
+
track,
|
|
208
|
+
skillId: skill.id,
|
|
209
|
+
skills, // Pass skills array to enable capability-based modifiers
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
// Skip if deriveSkillLevel returns null (track-added skill with no positive modifier)
|
|
213
|
+
if (level === null) {
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
matrix.push({
|
|
218
|
+
skillId: skill.id,
|
|
219
|
+
skillName: skill.name,
|
|
220
|
+
capability: skill.capability,
|
|
221
|
+
isHumanOnly: skill.isHumanOnly || false,
|
|
222
|
+
type: skillType || SkillType.TRACK,
|
|
223
|
+
level,
|
|
224
|
+
levelDescription: skill.levelDescriptions?.[level] || "",
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Sort by type (primary first, then secondary, then broad, then track) and then by name
|
|
229
|
+
const typeOrder = {
|
|
230
|
+
[SkillType.PRIMARY]: 0,
|
|
231
|
+
[SkillType.SECONDARY]: 1,
|
|
232
|
+
[SkillType.BROAD]: 2,
|
|
233
|
+
[SkillType.TRACK]: 3,
|
|
234
|
+
};
|
|
235
|
+
matrix.sort((a, b) => {
|
|
236
|
+
const typeCompare = typeOrder[a.type] - typeOrder[b.type];
|
|
237
|
+
if (typeCompare !== 0) return typeCompare;
|
|
238
|
+
return a.skillName.localeCompare(b.skillName);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
return matrix;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Derive the complete behaviour profile for a job
|
|
246
|
+
* @param {Object} params
|
|
247
|
+
* @param {import('./levels.js').Discipline} params.discipline - The discipline
|
|
248
|
+
* @param {import('./levels.js').Grade} params.grade - The grade
|
|
249
|
+
* @param {import('./levels.js').Track} [params.track] - The track (optional)
|
|
250
|
+
* @param {import('./levels.js').Behaviour[]} params.behaviours - All available behaviours
|
|
251
|
+
* @returns {import('./levels.js').BehaviourProfileEntry[]} Complete behaviour profile
|
|
252
|
+
*/
|
|
253
|
+
export function deriveBehaviourProfile({
|
|
254
|
+
discipline,
|
|
255
|
+
grade,
|
|
256
|
+
track = null,
|
|
257
|
+
behaviours,
|
|
258
|
+
}) {
|
|
259
|
+
const profile = [];
|
|
260
|
+
|
|
261
|
+
for (const behaviour of behaviours) {
|
|
262
|
+
const maturity = deriveBehaviourMaturity({
|
|
263
|
+
discipline,
|
|
264
|
+
grade,
|
|
265
|
+
track,
|
|
266
|
+
behaviourId: behaviour.id,
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
profile.push({
|
|
270
|
+
behaviourId: behaviour.id,
|
|
271
|
+
behaviourName: behaviour.name,
|
|
272
|
+
maturity,
|
|
273
|
+
maturityDescription: behaviour.maturityDescriptions?.[maturity] || "",
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Sort by name
|
|
278
|
+
profile.sort((a, b) => a.behaviourName.localeCompare(b.behaviourName));
|
|
279
|
+
|
|
280
|
+
return profile;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Check if a job combination is valid
|
|
285
|
+
* @param {Object} params
|
|
286
|
+
* @param {import('./levels.js').Discipline} params.discipline - The discipline
|
|
287
|
+
* @param {import('./levels.js').Grade} params.grade - The grade
|
|
288
|
+
* @param {import('./levels.js').Track} [params.track] - The track (optional)
|
|
289
|
+
* @param {import('./levels.js').JobValidationRules} [params.validationRules] - Optional validation rules
|
|
290
|
+
* @param {Array<import('./levels.js').Grade>} [params.grades] - Optional array of all grades for minGrade validation
|
|
291
|
+
* @returns {boolean} True if the combination is valid
|
|
292
|
+
*/
|
|
293
|
+
export function isValidJobCombination({
|
|
294
|
+
discipline,
|
|
295
|
+
grade,
|
|
296
|
+
track = null,
|
|
297
|
+
validationRules,
|
|
298
|
+
grades,
|
|
299
|
+
}) {
|
|
300
|
+
// 1. Check discipline's minGrade constraint
|
|
301
|
+
if (discipline.minGrade && grades) {
|
|
302
|
+
const minGradeObj = grades.find((g) => g.id === discipline.minGrade);
|
|
303
|
+
if (minGradeObj && grade.ordinalRank < minGradeObj.ordinalRank) {
|
|
304
|
+
return false;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// 2. Handle trackless vs tracked jobs based on validTracks
|
|
309
|
+
// validTracks semantics:
|
|
310
|
+
// - null in array means "allow trackless (generalist)"
|
|
311
|
+
// - string values mean "allow this specific track"
|
|
312
|
+
// - empty array = discipline cannot have any jobs
|
|
313
|
+
if (!track) {
|
|
314
|
+
// Trackless job: only valid if null is in validTracks
|
|
315
|
+
// Note: for backwards compatibility, empty array also allows trackless
|
|
316
|
+
const validTracks = discipline.validTracks ?? [];
|
|
317
|
+
if (validTracks.length === 0) {
|
|
318
|
+
// Empty array = allow trackless (legacy behavior)
|
|
319
|
+
return true;
|
|
320
|
+
}
|
|
321
|
+
// Check if null is explicitly in the array
|
|
322
|
+
return validTracks.includes(null);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// 3. Check discipline's validTracks constraint for tracked jobs
|
|
326
|
+
// Only string entries matter here (null = trackless, not a track ID)
|
|
327
|
+
const validTracks = discipline.validTracks ?? [];
|
|
328
|
+
if (validTracks.length > 0) {
|
|
329
|
+
const trackIds = validTracks.filter((t) => t !== null);
|
|
330
|
+
if (trackIds.length > 0 && !trackIds.includes(track.id)) {
|
|
331
|
+
return false;
|
|
332
|
+
}
|
|
333
|
+
// If validTracks only contains null (no track IDs), reject all tracks
|
|
334
|
+
if (trackIds.length === 0) {
|
|
335
|
+
return false;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// 4. Check track's minGrade constraint
|
|
340
|
+
if (track.minGrade && grades) {
|
|
341
|
+
const minGradeObj = grades.find((g) => g.id === track.minGrade);
|
|
342
|
+
if (minGradeObj && grade.ordinalRank < minGradeObj.ordinalRank) {
|
|
343
|
+
return false;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// 5. Apply framework-level validation rules
|
|
348
|
+
if (validationRules?.invalidCombinations) {
|
|
349
|
+
for (const combo of validationRules.invalidCombinations) {
|
|
350
|
+
const disciplineMatch =
|
|
351
|
+
!combo.discipline || combo.discipline === discipline.id;
|
|
352
|
+
const trackMatch = !combo.track || combo.track === track.id;
|
|
353
|
+
const gradeMatch = !combo.grade || combo.grade === grade.id;
|
|
354
|
+
|
|
355
|
+
if (disciplineMatch && trackMatch && gradeMatch) {
|
|
356
|
+
return false;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
return true;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Generate a job title from discipline, track, and grade
|
|
366
|
+
*
|
|
367
|
+
* Rules:
|
|
368
|
+
* - Management discipline without track: ${grade.managementTitle}, ${discipline.specialization}
|
|
369
|
+
* - Management discipline with track: ${grade.managementTitle}, ${track.name}
|
|
370
|
+
* - IC discipline with track: ${grade.professionalTitle} ${discipline.roleTitle} - ${track.name}
|
|
371
|
+
* - IC discipline without track: ${grade.professionalTitle} ${discipline.roleTitle}
|
|
372
|
+
*
|
|
373
|
+
* @param {import('./levels.js').Discipline} discipline - The discipline
|
|
374
|
+
* @param {import('./levels.js').Grade} grade - The grade
|
|
375
|
+
* @param {import('./levels.js').Track} [track] - The track (optional)
|
|
376
|
+
* @returns {string} Generated job title
|
|
377
|
+
*/
|
|
378
|
+
export function generateJobTitle(discipline, grade, track = null) {
|
|
379
|
+
const { roleTitle, isManagement } = discipline;
|
|
380
|
+
const { professionalTitle, managementTitle } = grade;
|
|
381
|
+
|
|
382
|
+
// Management discipline (no track needed)
|
|
383
|
+
if (isManagement && !track) {
|
|
384
|
+
return `${managementTitle}, ${roleTitle}`;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Management discipline with track
|
|
388
|
+
if (isManagement && track) {
|
|
389
|
+
return `${managementTitle}, ${roleTitle} – ${track.name}`;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// IC discipline with track
|
|
393
|
+
if (track) {
|
|
394
|
+
if (professionalTitle.startsWith("Level")) {
|
|
395
|
+
// Professional track with Level grade: "Software Engineer Level II - Platform"
|
|
396
|
+
return `${roleTitle} ${professionalTitle} - ${track.name}`;
|
|
397
|
+
}
|
|
398
|
+
// Professional track with non-Level grade: "Staff Software Engineer - Platform"
|
|
399
|
+
return `${professionalTitle} ${roleTitle} - ${track.name}`;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// IC discipline without track (generalist)
|
|
403
|
+
if (professionalTitle.startsWith("Level")) {
|
|
404
|
+
return `${roleTitle} ${professionalTitle}`;
|
|
405
|
+
}
|
|
406
|
+
return `${professionalTitle} ${roleTitle}`;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Generate a job ID from discipline, grade, and track
|
|
411
|
+
* @param {import('./levels.js').Discipline} discipline - The discipline
|
|
412
|
+
* @param {import('./levels.js').Grade} grade - The grade
|
|
413
|
+
* @param {import('./levels.js').Track} [track] - The track (optional)
|
|
414
|
+
* @returns {string} Generated job ID
|
|
415
|
+
*/
|
|
416
|
+
function generateJobId(discipline, grade, track = null) {
|
|
417
|
+
if (track) {
|
|
418
|
+
return `${discipline.id}_${grade.id}_${track.id}`;
|
|
419
|
+
}
|
|
420
|
+
return `${discipline.id}_${grade.id}`;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Derive role responsibilities from skill matrix and capabilities
|
|
425
|
+
*
|
|
426
|
+
* Responsibilities are determined by finding the maximum skill level
|
|
427
|
+
* achieved in each capability, then looking up the corresponding
|
|
428
|
+
* responsibility statement from the capability definition.
|
|
429
|
+
*
|
|
430
|
+
* Capabilities are sorted by their maximum skill level (descending),
|
|
431
|
+
* so Expert-level capabilities appear before Practitioner-level, etc.
|
|
432
|
+
*
|
|
433
|
+
* Uses professionalResponsibilities for professional disciplines (isProfessional: true)
|
|
434
|
+
* and managementResponsibilities for management disciplines (isManagement: true).
|
|
435
|
+
*
|
|
436
|
+
* @param {Object} params
|
|
437
|
+
* @param {import('./levels.js').SkillMatrixEntry[]} params.skillMatrix - Derived skill matrix for the job
|
|
438
|
+
* @param {Object[]} params.capabilities - Capability definitions with responsibilities
|
|
439
|
+
* @param {import('./levels.js').Discipline} params.discipline - The discipline (determines which responsibilities to use)
|
|
440
|
+
* @returns {Array<{capability: string, capabilityName: string, emojiIcon: string, responsibility: string, level: string}>}
|
|
441
|
+
*/
|
|
442
|
+
export function deriveResponsibilities({
|
|
443
|
+
skillMatrix,
|
|
444
|
+
capabilities,
|
|
445
|
+
discipline,
|
|
446
|
+
}) {
|
|
447
|
+
if (!capabilities || capabilities.length === 0) {
|
|
448
|
+
return [];
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// Determine which responsibility set to use based on discipline type
|
|
452
|
+
// Management disciplines use managementResponsibilities, professional disciplines use professionalResponsibilities
|
|
453
|
+
const responsibilityKey = discipline?.isManagement
|
|
454
|
+
? "managementResponsibilities"
|
|
455
|
+
: "professionalResponsibilities";
|
|
456
|
+
|
|
457
|
+
// Group skills by capability and find max level per capability
|
|
458
|
+
const capabilityLevels = new Map();
|
|
459
|
+
|
|
460
|
+
for (const skill of skillMatrix) {
|
|
461
|
+
const currentLevel = capabilityLevels.get(skill.capability);
|
|
462
|
+
const skillLevelIndex = SKILL_LEVEL_ORDER.indexOf(skill.level);
|
|
463
|
+
const currentIndex = currentLevel
|
|
464
|
+
? SKILL_LEVEL_ORDER.indexOf(currentLevel)
|
|
465
|
+
: -1;
|
|
466
|
+
|
|
467
|
+
if (skillLevelIndex > currentIndex) {
|
|
468
|
+
capabilityLevels.set(skill.capability, skill.level);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// Build capability lookup map
|
|
473
|
+
const capabilityMap = new Map(capabilities.map((c) => [c.id, c]));
|
|
474
|
+
|
|
475
|
+
// Build responsibilities from all capabilities with meaningful levels
|
|
476
|
+
const responsibilities = [];
|
|
477
|
+
|
|
478
|
+
for (const [capabilityId, level] of capabilityLevels) {
|
|
479
|
+
if (level === "awareness") continue; // Skip awareness-only capabilities
|
|
480
|
+
|
|
481
|
+
const capability = capabilityMap.get(capabilityId);
|
|
482
|
+
const responsibilityText = capability?.[responsibilityKey]?.[level];
|
|
483
|
+
if (responsibilityText) {
|
|
484
|
+
responsibilities.push({
|
|
485
|
+
capability: capabilityId,
|
|
486
|
+
capabilityName: capability.name,
|
|
487
|
+
emojiIcon: capability.emojiIcon || "💡",
|
|
488
|
+
displayOrder: capability.displayOrder ?? 999,
|
|
489
|
+
responsibility: responsibilityText,
|
|
490
|
+
level,
|
|
491
|
+
levelIndex: SKILL_LEVEL_ORDER.indexOf(level),
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// Sort by level descending (expert first), then by capability order
|
|
497
|
+
responsibilities.sort((a, b) => {
|
|
498
|
+
if (b.levelIndex !== a.levelIndex) {
|
|
499
|
+
return b.levelIndex - a.levelIndex;
|
|
500
|
+
}
|
|
501
|
+
return a.displayOrder - b.displayOrder;
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
// Remove levelIndex from output (internal use only)
|
|
505
|
+
return responsibilities.map(({ levelIndex: _levelIndex, ...rest }) => rest);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Create a complete job definition from discipline, grade, and optional track
|
|
510
|
+
* @param {Object} params
|
|
511
|
+
* @param {import('./levels.js').Discipline} params.discipline - The discipline
|
|
512
|
+
* @param {import('./levels.js').Grade} params.grade - The grade
|
|
513
|
+
* @param {import('./levels.js').Track} [params.track] - The track (optional)
|
|
514
|
+
* @param {import('./levels.js').Skill[]} params.skills - All available skills
|
|
515
|
+
* @param {import('./levels.js').Behaviour[]} params.behaviours - All available behaviours
|
|
516
|
+
* @param {Object[]} [params.capabilities] - Optional capabilities for responsibility derivation
|
|
517
|
+
* @param {import('./levels.js').JobValidationRules} [params.validationRules] - Optional validation rules
|
|
518
|
+
* @returns {import('./levels.js').JobDefinition|null} The job definition or null if invalid
|
|
519
|
+
*/
|
|
520
|
+
export function deriveJob({
|
|
521
|
+
discipline,
|
|
522
|
+
grade,
|
|
523
|
+
track = null,
|
|
524
|
+
skills,
|
|
525
|
+
behaviours,
|
|
526
|
+
capabilities,
|
|
527
|
+
validationRules,
|
|
528
|
+
}) {
|
|
529
|
+
// Check if combination is valid
|
|
530
|
+
if (
|
|
531
|
+
!isValidJobCombination({
|
|
532
|
+
discipline,
|
|
533
|
+
grade,
|
|
534
|
+
track,
|
|
535
|
+
validationRules,
|
|
536
|
+
grades: validationRules?.grades,
|
|
537
|
+
})
|
|
538
|
+
) {
|
|
539
|
+
return null;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
const skillMatrix = deriveSkillMatrix({ discipline, grade, track, skills });
|
|
543
|
+
const behaviourProfile = deriveBehaviourProfile({
|
|
544
|
+
discipline,
|
|
545
|
+
grade,
|
|
546
|
+
track,
|
|
547
|
+
behaviours,
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
// Derive responsibilities if capabilities are provided
|
|
551
|
+
let derivedResponsibilities = [];
|
|
552
|
+
if (capabilities && capabilities.length > 0) {
|
|
553
|
+
derivedResponsibilities = deriveResponsibilities({
|
|
554
|
+
skillMatrix,
|
|
555
|
+
capabilities,
|
|
556
|
+
discipline,
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
return {
|
|
561
|
+
id: generateJobId(discipline, grade, track),
|
|
562
|
+
title: generateJobTitle(discipline, grade, track),
|
|
563
|
+
discipline,
|
|
564
|
+
grade,
|
|
565
|
+
track,
|
|
566
|
+
skillMatrix,
|
|
567
|
+
behaviourProfile,
|
|
568
|
+
derivedResponsibilities,
|
|
569
|
+
expectations: grade.expectations || {},
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* Calculate driver coverage for a job
|
|
575
|
+
* @param {Object} params
|
|
576
|
+
* @param {import('./levels.js').JobDefinition} params.job - The job definition
|
|
577
|
+
* @param {import('./levels.js').Driver[]} params.drivers - All drivers
|
|
578
|
+
* @returns {import('./levels.js').DriverCoverage[]} Coverage analysis for each driver
|
|
579
|
+
*/
|
|
580
|
+
export function calculateDriverCoverage({ job, drivers }) {
|
|
581
|
+
const coverageResults = [];
|
|
582
|
+
|
|
583
|
+
// Create lookup maps for the job's skills and behaviours
|
|
584
|
+
const jobSkillLevels = new Map(
|
|
585
|
+
job.skillMatrix.map((s) => [s.skillId, s.level]),
|
|
586
|
+
);
|
|
587
|
+
const jobBehaviourMaturities = new Map(
|
|
588
|
+
job.behaviourProfile.map((b) => [b.behaviourId, b.maturity]),
|
|
589
|
+
);
|
|
590
|
+
|
|
591
|
+
for (const driver of drivers) {
|
|
592
|
+
const contributingSkills = driver.contributingSkills || [];
|
|
593
|
+
const contributingBehaviours = driver.contributingBehaviours || [];
|
|
594
|
+
|
|
595
|
+
// Calculate skill coverage (Working+ level threshold)
|
|
596
|
+
const coveredSkills = [];
|
|
597
|
+
const missingSkills = [];
|
|
598
|
+
|
|
599
|
+
for (const skillId of contributingSkills) {
|
|
600
|
+
const level = jobSkillLevels.get(skillId);
|
|
601
|
+
if (level && skillLevelMeetsRequirement(level, SkillLevel.WORKING)) {
|
|
602
|
+
coveredSkills.push(skillId);
|
|
603
|
+
} else {
|
|
604
|
+
missingSkills.push(skillId);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
const skillCoverage =
|
|
609
|
+
contributingSkills.length > 0
|
|
610
|
+
? coveredSkills.length / contributingSkills.length
|
|
611
|
+
: 1;
|
|
612
|
+
|
|
613
|
+
// Calculate behaviour coverage (Practicing+ maturity threshold)
|
|
614
|
+
const coveredBehaviours = [];
|
|
615
|
+
const missingBehaviours = [];
|
|
616
|
+
const practicingIndex = getBehaviourMaturityIndex(
|
|
617
|
+
BehaviourMaturity.PRACTICING,
|
|
618
|
+
);
|
|
619
|
+
|
|
620
|
+
for (const behaviourId of contributingBehaviours) {
|
|
621
|
+
const maturity = jobBehaviourMaturities.get(behaviourId);
|
|
622
|
+
if (maturity && getBehaviourMaturityIndex(maturity) >= practicingIndex) {
|
|
623
|
+
coveredBehaviours.push(behaviourId);
|
|
624
|
+
} else {
|
|
625
|
+
missingBehaviours.push(behaviourId);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
const behaviourCoverage =
|
|
630
|
+
contributingBehaviours.length > 0
|
|
631
|
+
? coveredBehaviours.length / contributingBehaviours.length
|
|
632
|
+
: 1;
|
|
633
|
+
|
|
634
|
+
// Overall score is weighted average (50/50)
|
|
635
|
+
const overallScore = (skillCoverage + behaviourCoverage) / 2;
|
|
636
|
+
|
|
637
|
+
coverageResults.push({
|
|
638
|
+
driverId: driver.id,
|
|
639
|
+
driverName: driver.name,
|
|
640
|
+
skillCoverage,
|
|
641
|
+
behaviourCoverage,
|
|
642
|
+
overallScore,
|
|
643
|
+
coveredSkills,
|
|
644
|
+
coveredBehaviours,
|
|
645
|
+
missingSkills,
|
|
646
|
+
missingBehaviours,
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// Sort by overall score descending
|
|
651
|
+
coverageResults.sort((a, b) => b.overallScore - a.overallScore);
|
|
652
|
+
|
|
653
|
+
return coverageResults;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* Get all skills in a discipline
|
|
658
|
+
* @param {import('./levels.js').Discipline} discipline - The discipline
|
|
659
|
+
* @returns {string[]} All skill IDs in the discipline
|
|
660
|
+
*/
|
|
661
|
+
export function getDisciplineSkillIds(discipline) {
|
|
662
|
+
return [
|
|
663
|
+
...(discipline.coreSkills || []),
|
|
664
|
+
...(discipline.supportingSkills || []),
|
|
665
|
+
...(discipline.broadSkills || []),
|
|
666
|
+
];
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* Get the grade level number (for comparison/sorting)
|
|
671
|
+
* @param {import('./levels.js').Grade} grade - The grade
|
|
672
|
+
* @returns {number} The grade level
|
|
673
|
+
*/
|
|
674
|
+
export function getGradeLevel(grade) {
|
|
675
|
+
return grade.ordinalRank;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/**
|
|
679
|
+
* Check if a grade is senior level (Staff+)
|
|
680
|
+
* @param {import('./levels.js').Grade} grade - The grade
|
|
681
|
+
* @returns {boolean} True if the grade is senior level
|
|
682
|
+
*/
|
|
683
|
+
export function isSeniorGrade(grade) {
|
|
684
|
+
// Typically Staff+ is level 5 or higher
|
|
685
|
+
return grade.ordinalRank >= 5;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* Generate all valid job definitions from the data
|
|
690
|
+
* Generates both trackless jobs and jobs with tracks based on discipline.validTracks
|
|
691
|
+
* @param {Object} params
|
|
692
|
+
* @param {import('./levels.js').Discipline[]} params.disciplines - All disciplines
|
|
693
|
+
* @param {import('./levels.js').Grade[]} params.grades - All grades
|
|
694
|
+
* @param {import('./levels.js').Track[]} params.tracks - All tracks
|
|
695
|
+
* @param {import('./levels.js').Skill[]} params.skills - All skills
|
|
696
|
+
* @param {import('./levels.js').Behaviour[]} params.behaviours - All behaviours
|
|
697
|
+
* @param {import('./levels.js').JobValidationRules} [params.validationRules] - Optional validation rules
|
|
698
|
+
* @returns {import('./levels.js').JobDefinition[]} All valid job definitions
|
|
699
|
+
*/
|
|
700
|
+
export function generateAllJobs({
|
|
701
|
+
disciplines,
|
|
702
|
+
grades,
|
|
703
|
+
tracks,
|
|
704
|
+
skills,
|
|
705
|
+
behaviours,
|
|
706
|
+
validationRules,
|
|
707
|
+
}) {
|
|
708
|
+
const jobs = [];
|
|
709
|
+
|
|
710
|
+
for (const discipline of disciplines) {
|
|
711
|
+
for (const grade of grades) {
|
|
712
|
+
// First, generate trackless job for this discipline/grade
|
|
713
|
+
if (
|
|
714
|
+
isValidJobCombination({
|
|
715
|
+
discipline,
|
|
716
|
+
grade,
|
|
717
|
+
track: null,
|
|
718
|
+
validationRules,
|
|
719
|
+
grades,
|
|
720
|
+
})
|
|
721
|
+
) {
|
|
722
|
+
const tracklessJob = deriveJob({
|
|
723
|
+
discipline,
|
|
724
|
+
grade,
|
|
725
|
+
track: null,
|
|
726
|
+
skills,
|
|
727
|
+
behaviours,
|
|
728
|
+
validationRules,
|
|
729
|
+
});
|
|
730
|
+
if (tracklessJob) {
|
|
731
|
+
jobs.push(tracklessJob);
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
// Then, generate jobs with valid tracks
|
|
736
|
+
for (const track of tracks) {
|
|
737
|
+
if (
|
|
738
|
+
!isValidJobCombination({
|
|
739
|
+
discipline,
|
|
740
|
+
grade,
|
|
741
|
+
track,
|
|
742
|
+
validationRules,
|
|
743
|
+
grades,
|
|
744
|
+
})
|
|
745
|
+
) {
|
|
746
|
+
continue;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
const job = deriveJob({
|
|
750
|
+
discipline,
|
|
751
|
+
grade,
|
|
752
|
+
track,
|
|
753
|
+
skills,
|
|
754
|
+
behaviours,
|
|
755
|
+
validationRules,
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
if (job) {
|
|
759
|
+
jobs.push(job);
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
return jobs;
|
|
766
|
+
}
|