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