@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,891 @@
1
+ /**
2
+ * Engineering Pathway Matching Functions
3
+ *
4
+ * This module provides pure functions for self-assessment validation,
5
+ * job matching, and development path derivation.
6
+ */
7
+
8
+ import {
9
+ getSkillLevelIndex,
10
+ getBehaviourMaturityIndex,
11
+ } from "@forwardimpact/schema/levels";
12
+
13
+ import {
14
+ deriveJob,
15
+ isValidJobCombination,
16
+ isSeniorGrade,
17
+ } from "./derivation.js";
18
+
19
+ // ============================================================================
20
+ // Match Tier Types and Constants
21
+ // ============================================================================
22
+
23
+ /**
24
+ * Match tier identifiers
25
+ * @readonly
26
+ * @enum {number}
27
+ */
28
+ export const MatchTier = {
29
+ STRONG: 1,
30
+ GOOD: 2,
31
+ STRETCH: 3,
32
+ ASPIRATIONAL: 4,
33
+ };
34
+
35
+ /**
36
+ * Match tier configuration with thresholds and display properties
37
+ * @type {Object<number, {label: string, color: string, minScore: number, description: string}>}
38
+ */
39
+ export const MATCH_TIER_CONFIG = {
40
+ [MatchTier.STRONG]: {
41
+ label: "Strong Match",
42
+ color: "green",
43
+ minScore: 0.85,
44
+ description: "Ready for this role now",
45
+ },
46
+ [MatchTier.GOOD]: {
47
+ label: "Good Match",
48
+ color: "blue",
49
+ minScore: 0.7,
50
+ description: "Ready within 6-12 months of focused growth",
51
+ },
52
+ [MatchTier.STRETCH]: {
53
+ label: "Stretch Role",
54
+ color: "amber",
55
+ minScore: 0.55,
56
+ description: "Ambitious but achievable with dedicated development",
57
+ },
58
+ [MatchTier.ASPIRATIONAL]: {
59
+ label: "Aspirational",
60
+ color: "gray",
61
+ minScore: 0,
62
+ description: "Long-term career goal requiring significant growth",
63
+ },
64
+ };
65
+
66
+ /**
67
+ * @typedef {Object} MatchTierInfo
68
+ * @property {number} tier - The tier number (1-4)
69
+ * @property {string} label - Human-readable tier label
70
+ * @property {string} color - Color for UI display
71
+ * @property {string} description - Description of what this tier means
72
+ */
73
+
74
+ /**
75
+ * Classify a match score into a tier
76
+ * @param {number} score - Match score from 0 to 1
77
+ * @returns {MatchTierInfo} Tier classification
78
+ */
79
+ export function classifyMatchTier(score) {
80
+ if (score >= MATCH_TIER_CONFIG[MatchTier.STRONG].minScore) {
81
+ return { tier: MatchTier.STRONG, ...MATCH_TIER_CONFIG[MatchTier.STRONG] };
82
+ }
83
+ if (score >= MATCH_TIER_CONFIG[MatchTier.GOOD].minScore) {
84
+ return { tier: MatchTier.GOOD, ...MATCH_TIER_CONFIG[MatchTier.GOOD] };
85
+ }
86
+ if (score >= MATCH_TIER_CONFIG[MatchTier.STRETCH].minScore) {
87
+ return { tier: MatchTier.STRETCH, ...MATCH_TIER_CONFIG[MatchTier.STRETCH] };
88
+ }
89
+ return {
90
+ tier: MatchTier.ASPIRATIONAL,
91
+ ...MATCH_TIER_CONFIG[MatchTier.ASPIRATIONAL],
92
+ };
93
+ }
94
+
95
+ // ============================================================================
96
+ // Gap Scoring Constants
97
+ // ============================================================================
98
+
99
+ /**
100
+ * Score values for different gap sizes
101
+ * Uses a smooth decay that reflects real-world readiness
102
+ * @type {Object<number, number>}
103
+ */
104
+ export const GAP_SCORES = {
105
+ 0: 1.0, // Meets or exceeds
106
+ 1: 0.7, // Minor development needed
107
+ 2: 0.4, // Significant but achievable gap
108
+ 3: 0.15, // Major development required
109
+ 4: 0.05, // Aspirational only
110
+ };
111
+
112
+ /**
113
+ * Calculate gap score with smooth decay
114
+ * @param {number} gap - The gap size (negative = exceeds, positive = below)
115
+ * @returns {number} Score from 0 to 1
116
+ */
117
+ export function calculateGapScore(gap) {
118
+ if (gap <= 0) return GAP_SCORES[0]; // Meets or exceeds
119
+ if (gap === 1) return GAP_SCORES[1];
120
+ if (gap === 2) return GAP_SCORES[2];
121
+ if (gap === 3) return GAP_SCORES[3];
122
+ return GAP_SCORES[4]; // 4+ levels below
123
+ }
124
+
125
+ /**
126
+ * Calculate skill match score using smooth decay scoring
127
+ * @param {Object<string, string>} selfSkills - Self-assessed skill levels
128
+ * @param {import('./levels.js').SkillMatrixEntry[]} jobSkills - Required job skill levels
129
+ * @returns {{score: number, gaps: import('./levels.js').MatchGap[]}}
130
+ */
131
+ function calculateSkillScore(selfSkills, jobSkills) {
132
+ if (jobSkills.length === 0) {
133
+ return { score: 1, gaps: [] };
134
+ }
135
+
136
+ let totalScore = 0;
137
+ const gaps = [];
138
+
139
+ for (const jobSkill of jobSkills) {
140
+ const selfLevel = selfSkills[jobSkill.skillId];
141
+ const requiredIndex = getSkillLevelIndex(jobSkill.level);
142
+
143
+ if (!selfLevel) {
144
+ // No self-assessment for this skill - count as gap with max penalty
145
+ const gap = requiredIndex + 1;
146
+ totalScore += calculateGapScore(gap);
147
+ gaps.push({
148
+ id: jobSkill.skillId,
149
+ name: jobSkill.skillName,
150
+ type: "skill",
151
+ current: "none",
152
+ required: jobSkill.level,
153
+ gap,
154
+ });
155
+ continue;
156
+ }
157
+
158
+ const selfIndex = getSkillLevelIndex(selfLevel);
159
+ const difference = selfIndex - requiredIndex;
160
+
161
+ if (difference >= 0) {
162
+ // Meets or exceeds requirement
163
+ totalScore += 1;
164
+ } else {
165
+ // Below requirement - use smooth decay scoring
166
+ const gap = -difference;
167
+ totalScore += calculateGapScore(gap);
168
+ gaps.push({
169
+ id: jobSkill.skillId,
170
+ name: jobSkill.skillName,
171
+ type: "skill",
172
+ current: selfLevel,
173
+ required: jobSkill.level,
174
+ gap,
175
+ });
176
+ }
177
+ }
178
+
179
+ return {
180
+ score: totalScore / jobSkills.length,
181
+ gaps,
182
+ };
183
+ }
184
+
185
+ /**
186
+ * Calculate behaviour match score using smooth decay scoring
187
+ * @param {Object<string, string>} selfBehaviours - Self-assessed behaviour maturities
188
+ * @param {import('./levels.js').BehaviourProfileEntry[]} jobBehaviours - Required job behaviour maturities
189
+ * @returns {{score: number, gaps: import('./levels.js').MatchGap[]}}
190
+ */
191
+ function calculateBehaviourScore(selfBehaviours, jobBehaviours) {
192
+ if (jobBehaviours.length === 0) {
193
+ return { score: 1, gaps: [] };
194
+ }
195
+
196
+ let totalScore = 0;
197
+ const gaps = [];
198
+
199
+ for (const jobBehaviour of jobBehaviours) {
200
+ const selfMaturity = selfBehaviours[jobBehaviour.behaviourId];
201
+ const requiredIndex = getBehaviourMaturityIndex(jobBehaviour.maturity);
202
+
203
+ if (!selfMaturity) {
204
+ // No self-assessment for this behaviour - count as gap with max penalty
205
+ const gap = requiredIndex + 1;
206
+ totalScore += calculateGapScore(gap);
207
+ gaps.push({
208
+ id: jobBehaviour.behaviourId,
209
+ name: jobBehaviour.behaviourName,
210
+ type: "behaviour",
211
+ current: "none",
212
+ required: jobBehaviour.maturity,
213
+ gap,
214
+ });
215
+ continue;
216
+ }
217
+
218
+ const selfIndex = getBehaviourMaturityIndex(selfMaturity);
219
+ const difference = selfIndex - requiredIndex;
220
+
221
+ if (difference >= 0) {
222
+ // Meets or exceeds requirement
223
+ totalScore += 1;
224
+ } else {
225
+ // Below requirement - use smooth decay scoring
226
+ const gap = -difference;
227
+ totalScore += calculateGapScore(gap);
228
+ gaps.push({
229
+ id: jobBehaviour.behaviourId,
230
+ name: jobBehaviour.behaviourName,
231
+ type: "behaviour",
232
+ current: selfMaturity,
233
+ required: jobBehaviour.maturity,
234
+ gap,
235
+ });
236
+ }
237
+ }
238
+
239
+ return {
240
+ score: totalScore / jobBehaviours.length,
241
+ gaps,
242
+ };
243
+ }
244
+
245
+ /**
246
+ * Calculate expectations match score for senior roles
247
+ * @param {Object} selfExpectations - Self-assessed expectations
248
+ * @param {import('./levels.js').GradeExpectations} jobExpectations - Required grade expectations
249
+ * @returns {number} Score from 0 to 1
250
+ */
251
+ function calculateExpectationsScore(selfExpectations, jobExpectations) {
252
+ if (!selfExpectations || !jobExpectations) {
253
+ return 0;
254
+ }
255
+
256
+ // Simple text matching - in a real system this would be more sophisticated
257
+ const fields = ["scope", "autonomy", "influence"];
258
+ let matches = 0;
259
+ let total = 0;
260
+
261
+ for (const field of fields) {
262
+ if (jobExpectations[field]) {
263
+ total++;
264
+ if (selfExpectations[field]) {
265
+ // Basic matching - could be enhanced with semantic similarity
266
+ matches++;
267
+ }
268
+ }
269
+ }
270
+
271
+ return total > 0 ? matches / total : 0;
272
+ }
273
+
274
+ /**
275
+ * Calculate job match analysis between a self-assessment and a job
276
+ * @param {import('./levels.js').SelfAssessment} selfAssessment - The self-assessment
277
+ * @param {import('./levels.js').JobDefinition} job - The job definition
278
+ * @returns {import('./levels.js').MatchAnalysis}
279
+ */
280
+ export function calculateJobMatch(selfAssessment, job) {
281
+ // Get weights from track or use defaults (track may be null for trackless jobs)
282
+ const skillWeight = job.track?.assessmentWeights?.skillWeight ?? 0.5;
283
+ const behaviourWeight = job.track?.assessmentWeights?.behaviourWeight ?? 0.5;
284
+
285
+ // Calculate skill score
286
+ const skillResult = calculateSkillScore(
287
+ selfAssessment.skillLevels || {},
288
+ job.skillMatrix,
289
+ );
290
+
291
+ // Calculate behaviour score
292
+ const behaviourResult = calculateBehaviourScore(
293
+ selfAssessment.behaviourMaturities || {},
294
+ job.behaviourProfile,
295
+ );
296
+
297
+ // Calculate weighted overall score
298
+ let overallScore =
299
+ skillResult.score * skillWeight + behaviourResult.score * behaviourWeight;
300
+
301
+ // For senior roles, add expectations score as a bonus
302
+ let expectationsScore = undefined;
303
+ if (isSeniorGrade(job.grade)) {
304
+ expectationsScore = calculateExpectationsScore(
305
+ selfAssessment.expectations,
306
+ job.expectations,
307
+ );
308
+ // Add up to 10% bonus for expectations match
309
+ overallScore = overallScore * 0.9 + expectationsScore * 0.1;
310
+ }
311
+
312
+ // Combine all gaps
313
+ const allGaps = [...skillResult.gaps, ...behaviourResult.gaps];
314
+
315
+ // Sort gaps by gap size (largest first)
316
+ allGaps.sort((a, b) => b.gap - a.gap);
317
+
318
+ // Classify match into tier
319
+ const tier = classifyMatchTier(overallScore);
320
+
321
+ // Identify top priority gaps (top 3 by gap size)
322
+ const priorityGaps = allGaps.slice(0, 3);
323
+
324
+ const result = {
325
+ overallScore,
326
+ skillScore: skillResult.score,
327
+ behaviourScore: behaviourResult.score,
328
+ weightsUsed: { skillWeight, behaviourWeight },
329
+ gaps: allGaps,
330
+ tier,
331
+ priorityGaps,
332
+ };
333
+
334
+ if (expectationsScore !== undefined) {
335
+ result.expectationsScore = expectationsScore;
336
+ }
337
+
338
+ return result;
339
+ }
340
+
341
+ /**
342
+ * Find matching jobs for a self-assessment
343
+ * @param {Object} params
344
+ * @param {import('./levels.js').SelfAssessment} params.selfAssessment - The self-assessment
345
+ * @param {import('./levels.js').Discipline[]} params.disciplines - All disciplines
346
+ * @param {import('./levels.js').Grade[]} params.grades - All grades
347
+ * @param {import('./levels.js').Track[]} params.tracks - All tracks
348
+ * @param {import('./levels.js').Skill[]} params.skills - All skills
349
+ * @param {import('./levels.js').Behaviour[]} params.behaviours - All behaviours
350
+ * @param {import('./levels.js').JobValidationRules} [params.validationRules] - Optional validation rules
351
+ * @param {number} [params.topN=10] - Number of top matches to return
352
+ * @returns {import('./levels.js').JobMatch[]} Ranked job matches
353
+ */
354
+ export function findMatchingJobs({
355
+ selfAssessment,
356
+ disciplines,
357
+ grades,
358
+ tracks,
359
+ skills,
360
+ behaviours,
361
+ validationRules,
362
+ topN = 10,
363
+ }) {
364
+ const matches = [];
365
+
366
+ // Generate all valid job combinations
367
+ for (const discipline of disciplines) {
368
+ // First generate trackless jobs for each discipline × grade
369
+ for (const grade of grades) {
370
+ if (
371
+ !isValidJobCombination({
372
+ discipline,
373
+ grade,
374
+ track: null,
375
+ validationRules,
376
+ grades,
377
+ })
378
+ ) {
379
+ continue;
380
+ }
381
+
382
+ const job = deriveJob({
383
+ discipline,
384
+ grade,
385
+ track: null,
386
+ skills,
387
+ behaviours,
388
+ validationRules,
389
+ });
390
+
391
+ if (job) {
392
+ const analysis = calculateJobMatch(selfAssessment, job);
393
+ matches.push({ job, analysis });
394
+ }
395
+ }
396
+
397
+ // Then generate jobs with valid tracks
398
+ for (const track of tracks) {
399
+ for (const grade of grades) {
400
+ // Skip invalid combinations
401
+ if (
402
+ !isValidJobCombination({
403
+ discipline,
404
+ grade,
405
+ track,
406
+ validationRules,
407
+ grades,
408
+ })
409
+ ) {
410
+ continue;
411
+ }
412
+
413
+ const job = deriveJob({
414
+ discipline,
415
+ grade,
416
+ track,
417
+ skills,
418
+ behaviours,
419
+ validationRules,
420
+ });
421
+
422
+ if (!job) {
423
+ continue;
424
+ }
425
+
426
+ const analysis = calculateJobMatch(selfAssessment, job);
427
+ matches.push({ job, analysis });
428
+ }
429
+ }
430
+ }
431
+
432
+ // Sort by overall score descending
433
+ matches.sort((a, b) => b.analysis.overallScore - a.analysis.overallScore);
434
+
435
+ // Return top N
436
+ return matches.slice(0, topN);
437
+ }
438
+
439
+ /**
440
+ * Estimate the best-fit grade level for a self-assessment
441
+ * Maps the candidate's average skill level to the most appropriate grade
442
+ * @param {Object} params
443
+ * @param {import('./levels.js').SelfAssessment} params.selfAssessment - The self-assessment
444
+ * @param {import('./levels.js').Grade[]} params.grades - All grades (sorted by level)
445
+ * @param {import('./levels.js').Skill[]} params.skills - All skills
446
+ * @returns {{grade: import('./levels.js').Grade, confidence: number, averageSkillIndex: number}}
447
+ */
448
+ export function estimateBestFitGrade({ selfAssessment, grades, _skills }) {
449
+ const assessedSkills = Object.entries(selfAssessment.skillLevels || {});
450
+
451
+ if (assessedSkills.length === 0) {
452
+ // No skills assessed - return lowest grade
453
+ const sortedGrades = [...grades].sort(
454
+ (a, b) => a.ordinalRank - b.ordinalRank,
455
+ );
456
+ return {
457
+ grade: sortedGrades[0],
458
+ confidence: 0,
459
+ averageSkillIndex: 0,
460
+ };
461
+ }
462
+
463
+ // Calculate average skill level index
464
+ let totalIndex = 0;
465
+ for (const [, level] of assessedSkills) {
466
+ totalIndex += getSkillLevelIndex(level);
467
+ }
468
+ const averageSkillIndex = totalIndex / assessedSkills.length;
469
+
470
+ // Sort grades by ordinalRank
471
+ const sortedGrades = [...grades].sort(
472
+ (a, b) => a.ordinalRank - b.ordinalRank,
473
+ );
474
+
475
+ // Map skill index to grade
476
+ // Skill levels: 0=awareness, 1=foundational, 2=working, 3=practitioner, 4=expert
477
+ // We estimate based on what primary skill level the grade expects
478
+ let bestGrade = sortedGrades[0];
479
+ let minDistance = Infinity;
480
+
481
+ for (const grade of sortedGrades) {
482
+ const primaryLevelIndex = getSkillLevelIndex(
483
+ grade.baseSkillLevels?.primary || "awareness",
484
+ );
485
+ const distance = Math.abs(averageSkillIndex - primaryLevelIndex);
486
+ if (distance < minDistance) {
487
+ minDistance = distance;
488
+ bestGrade = grade;
489
+ }
490
+ }
491
+
492
+ // Confidence is higher when the average skill level closely matches a grade
493
+ // Max confidence when exactly matching, lower when between grades
494
+ const confidence = Math.max(0, 1 - minDistance / 2);
495
+
496
+ return {
497
+ grade: bestGrade,
498
+ confidence,
499
+ averageSkillIndex,
500
+ };
501
+ }
502
+
503
+ /**
504
+ * Find realistic job matches with tier filtering
505
+ * Returns matches grouped by tier, filtered to a realistic range (±1 grade from best fit)
506
+ * @param {Object} params
507
+ * @param {import('./levels.js').SelfAssessment} params.selfAssessment - The self-assessment
508
+ * @param {import('./levels.js').Discipline[]} params.disciplines - All disciplines
509
+ * @param {import('./levels.js').Grade[]} params.grades - All grades
510
+ * @param {import('./levels.js').Track[]} params.tracks - All tracks
511
+ * @param {import('./levels.js').Skill[]} params.skills - All skills
512
+ * @param {import('./levels.js').Behaviour[]} params.behaviours - All behaviours
513
+ * @param {import('./levels.js').JobValidationRules} [params.validationRules] - Optional validation rules
514
+ * @param {boolean} [params.filterByGrade=true] - Whether to filter to ±1 grade from best fit
515
+ * @param {number} [params.topN=20] - Maximum matches to return
516
+ * @returns {{
517
+ * matches: import('./levels.js').JobMatch[],
518
+ * matchesByTier: Object<number, import('./levels.js').JobMatch[]>,
519
+ * estimatedGrade: {grade: import('./levels.js').Grade, confidence: number},
520
+ * gradeRange: {min: number, max: number}
521
+ * }}
522
+ */
523
+ export function findRealisticMatches({
524
+ selfAssessment,
525
+ disciplines,
526
+ grades,
527
+ tracks,
528
+ skills,
529
+ behaviours,
530
+ validationRules,
531
+ filterByGrade = true,
532
+ topN = 20,
533
+ }) {
534
+ // Estimate best-fit grade
535
+ const estimatedGrade = estimateBestFitGrade({
536
+ selfAssessment,
537
+ grades,
538
+ skills,
539
+ });
540
+
541
+ // Determine grade range (±1 level)
542
+ const bestFitLevel = estimatedGrade.grade.ordinalRank;
543
+ const gradeRange = {
544
+ min: bestFitLevel - 1,
545
+ max: bestFitLevel + 1,
546
+ };
547
+
548
+ // Find all matches
549
+ const allMatches = findMatchingJobs({
550
+ selfAssessment,
551
+ disciplines,
552
+ grades,
553
+ tracks,
554
+ skills,
555
+ behaviours,
556
+ validationRules,
557
+ topN: 100, // Get more than needed for filtering
558
+ });
559
+
560
+ // Filter by grade range if enabled
561
+ let filteredMatches = allMatches;
562
+ if (filterByGrade) {
563
+ filteredMatches = allMatches.filter(
564
+ (m) =>
565
+ m.job.grade.ordinalRank >= gradeRange.min &&
566
+ m.job.grade.ordinalRank <= gradeRange.max,
567
+ );
568
+ }
569
+
570
+ // Group by tier
571
+ const matchesByTier = {
572
+ 1: [],
573
+ 2: [],
574
+ 3: [],
575
+ 4: [],
576
+ };
577
+
578
+ for (const match of filteredMatches) {
579
+ const tierNum = match.analysis.tier.tier;
580
+ matchesByTier[tierNum].push(match);
581
+ }
582
+
583
+ // Sort each tier by grade ordinalRank (descending - more senior first), then by score
584
+ for (const tierNum of Object.keys(matchesByTier)) {
585
+ matchesByTier[tierNum].sort((a, b) => {
586
+ // First sort by grade ordinalRank descending (more senior first)
587
+ const gradeDiff = b.job.grade.ordinalRank - a.job.grade.ordinalRank;
588
+ if (gradeDiff !== 0) return gradeDiff;
589
+ // Then by score descending
590
+ return b.analysis.overallScore - a.analysis.overallScore;
591
+ });
592
+ }
593
+
594
+ // Intelligent filtering: limit lower-level matches when strong matches exist
595
+ // Find the highest grade ordinalRank with a Strong or Good match
596
+ const strongAndGoodMatches = [...matchesByTier[1], ...matchesByTier[2]];
597
+ let highestMatchedLevel = 0;
598
+ for (const match of strongAndGoodMatches) {
599
+ if (match.job.grade.ordinalRank > highestMatchedLevel) {
600
+ highestMatchedLevel = match.job.grade.ordinalRank;
601
+ }
602
+ }
603
+
604
+ // Filter each tier to only show grades within reasonable range of highest match
605
+ // For Strong/Good matches: show up to 2 levels below highest match
606
+ // For Stretch/Aspirational: show only at or above highest match (growth opportunities)
607
+ if (highestMatchedLevel > 0) {
608
+ const minLevelForReady = highestMatchedLevel - 2; // Show some consolidation options
609
+ const minLevelForStretch = highestMatchedLevel; // Stretch roles should be at or above current
610
+
611
+ matchesByTier[1] = matchesByTier[1].filter(
612
+ (m) => m.job.grade.ordinalRank >= minLevelForReady,
613
+ );
614
+ matchesByTier[2] = matchesByTier[2].filter(
615
+ (m) => m.job.grade.ordinalRank >= minLevelForReady,
616
+ );
617
+ matchesByTier[3] = matchesByTier[3].filter(
618
+ (m) => m.job.grade.ordinalRank >= minLevelForStretch,
619
+ );
620
+ matchesByTier[4] = matchesByTier[4].filter(
621
+ (m) => m.job.grade.ordinalRank >= minLevelForStretch,
622
+ );
623
+ }
624
+
625
+ // Combine all filtered matches, sorted by grade (descending) then score
626
+ const allFilteredMatches = [
627
+ ...matchesByTier[1],
628
+ ...matchesByTier[2],
629
+ ...matchesByTier[3],
630
+ ...matchesByTier[4],
631
+ ];
632
+
633
+ // Return top N overall
634
+ const matches = allFilteredMatches.slice(0, topN);
635
+
636
+ return {
637
+ matches,
638
+ matchesByTier,
639
+ estimatedGrade: {
640
+ grade: estimatedGrade.grade,
641
+ confidence: estimatedGrade.confidence,
642
+ },
643
+ gradeRange,
644
+ };
645
+ }
646
+
647
+ /**
648
+ * Derive a development path from current self-assessment to a target job
649
+ * @param {Object} params
650
+ * @param {import('./levels.js').SelfAssessment} params.selfAssessment - Current self-assessment
651
+ * @param {import('./levels.js').JobDefinition} params.targetJob - Target job
652
+ * @returns {import('./levels.js').DevelopmentPath}
653
+ */
654
+ export function deriveDevelopmentPath({ selfAssessment, targetJob }) {
655
+ const items = [];
656
+
657
+ // Analyze skill gaps
658
+ for (const jobSkill of targetJob.skillMatrix) {
659
+ const selfLevel = selfAssessment.skillLevels?.[jobSkill.skillId];
660
+ const selfIndex = selfLevel ? getSkillLevelIndex(selfLevel) : -1;
661
+ const targetIndex = getSkillLevelIndex(jobSkill.level);
662
+
663
+ if (selfIndex < targetIndex) {
664
+ // Calculate priority based on:
665
+ // - Gap size (larger gaps = higher priority)
666
+ // - Skill type (primary > secondary > broad)
667
+ // - AI skills get a boost for "AI-era focus"
668
+ const gapSize = targetIndex - selfIndex;
669
+ const typeMultiplier =
670
+ jobSkill.type === "primary" ? 3 : jobSkill.type === "secondary" ? 2 : 1;
671
+ const aiBoost = jobSkill.capability === "ai" ? 1.5 : 1;
672
+ const priority = gapSize * typeMultiplier * aiBoost;
673
+
674
+ items.push({
675
+ id: jobSkill.skillId,
676
+ name: jobSkill.skillName,
677
+ type: "skill",
678
+ currentLevel: selfLevel || "none",
679
+ targetLevel: jobSkill.level,
680
+ priority,
681
+ rationale:
682
+ jobSkill.type === "primary"
683
+ ? "Primary skill for this discipline - essential for the role"
684
+ : jobSkill.type === "secondary"
685
+ ? "Secondary skill - important for full effectiveness"
686
+ : "Broad skill - needed for collaboration and context",
687
+ });
688
+ }
689
+ }
690
+
691
+ // Analyze behaviour gaps
692
+ for (const jobBehaviour of targetJob.behaviourProfile) {
693
+ const selfMaturity =
694
+ selfAssessment.behaviourMaturities?.[jobBehaviour.behaviourId];
695
+ const selfIndex = selfMaturity
696
+ ? getBehaviourMaturityIndex(selfMaturity)
697
+ : -1;
698
+ const targetIndex = getBehaviourMaturityIndex(jobBehaviour.maturity);
699
+
700
+ if (selfIndex < targetIndex) {
701
+ // Priority for behaviours considers gap size
702
+ const gapSize = targetIndex - selfIndex;
703
+ const priority = gapSize;
704
+
705
+ items.push({
706
+ id: jobBehaviour.behaviourId,
707
+ name: jobBehaviour.behaviourName,
708
+ type: "behaviour",
709
+ currentLevel: selfMaturity || "none",
710
+ targetLevel: jobBehaviour.maturity,
711
+ priority,
712
+ rationale:
713
+ "Required behaviour - important for professional effectiveness",
714
+ });
715
+ }
716
+ }
717
+
718
+ // Sort by priority (highest first)
719
+ items.sort((a, b) => b.priority - a.priority);
720
+
721
+ // Calculate readiness score
722
+ const matchAnalysis = calculateJobMatch(selfAssessment, targetJob);
723
+ const estimatedReadiness = matchAnalysis.overallScore;
724
+
725
+ return {
726
+ targetJob,
727
+ items,
728
+ estimatedReadiness,
729
+ };
730
+ }
731
+
732
+ /**
733
+ * Find the best next step job (one grade level up) based on current assessment
734
+ * @param {Object} params
735
+ * @param {import('./levels.js').SelfAssessment} params.selfAssessment - The self-assessment
736
+ * @param {import('./levels.js').JobDefinition} params.currentJob - Current job (or best match)
737
+ * @param {import('./levels.js').Discipline[]} params.disciplines - All disciplines
738
+ * @param {import('./levels.js').Grade[]} params.grades - All grades (sorted by level)
739
+ * @param {import('./levels.js').Track[]} params.tracks - All tracks
740
+ * @param {import('./levels.js').Skill[]} params.skills - All skills
741
+ * @param {import('./levels.js').Behaviour[]} params.behaviours - All behaviours
742
+ * @param {import('./levels.js').JobValidationRules} [params.validationRules] - Optional validation rules
743
+ * @returns {import('./levels.js').JobMatch|null} Best next-step job or null if at top
744
+ */
745
+ export function findNextStepJob({
746
+ selfAssessment,
747
+ currentJob,
748
+ _disciplines,
749
+ grades,
750
+ tracks,
751
+ skills,
752
+ behaviours,
753
+ validationRules,
754
+ }) {
755
+ const currentGradeLevel = currentJob.grade.ordinalRank;
756
+
757
+ // Find next grade level
758
+ const sortedGrades = [...grades].sort(
759
+ (a, b) => a.ordinalRank - b.ordinalRank,
760
+ );
761
+ const nextGrade = sortedGrades.find((g) => g.ordinalRank > currentGradeLevel);
762
+
763
+ if (!nextGrade) {
764
+ return null; // Already at top grade
765
+ }
766
+
767
+ // Find best match at the next grade level, same discipline preferred
768
+ const candidates = [];
769
+
770
+ for (const track of tracks) {
771
+ // Check same discipline first
772
+ if (
773
+ isValidJobCombination({
774
+ discipline: currentJob.discipline,
775
+ grade: nextGrade,
776
+ track,
777
+ validationRules,
778
+ grades,
779
+ })
780
+ ) {
781
+ const job = deriveJob({
782
+ discipline: currentJob.discipline,
783
+ grade: nextGrade,
784
+ track,
785
+ skills,
786
+ behaviours,
787
+ validationRules,
788
+ });
789
+
790
+ if (job) {
791
+ const analysis = calculateJobMatch(selfAssessment, job);
792
+ // Boost score for same track
793
+ const trackBonus = track.id === currentJob.track.id ? 0.1 : 0;
794
+ candidates.push({
795
+ job,
796
+ analysis,
797
+ adjustedScore: analysis.overallScore + trackBonus,
798
+ });
799
+ }
800
+ }
801
+ }
802
+
803
+ if (candidates.length === 0) {
804
+ return null;
805
+ }
806
+
807
+ // Sort by adjusted score
808
+ candidates.sort((a, b) => b.adjustedScore - a.adjustedScore);
809
+
810
+ return { job: candidates[0].job, analysis: candidates[0].analysis };
811
+ }
812
+
813
+ /**
814
+ * Comprehensive analysis of a candidate's self-assessment
815
+ * @param {Object} params
816
+ * @param {import('./levels.js').SelfAssessment} params.selfAssessment - The self-assessment
817
+ * @param {import('./levels.js').Discipline[]} params.disciplines - All disciplines
818
+ * @param {import('./levels.js').Grade[]} params.grades - All grades
819
+ * @param {import('./levels.js').Track[]} params.tracks - All tracks
820
+ * @param {import('./levels.js').Skill[]} params.skills - All skills
821
+ * @param {import('./levels.js').Behaviour[]} params.behaviours - All behaviours
822
+ * @param {import('./levels.js').JobValidationRules} [params.validationRules] - Optional validation rules
823
+ * @param {number} [params.topN=5] - Number of top job matches to return
824
+ * @returns {Object} Comprehensive analysis
825
+ */
826
+ export function analyzeCandidate({
827
+ selfAssessment,
828
+ disciplines,
829
+ grades,
830
+ tracks,
831
+ skills,
832
+ behaviours,
833
+ validationRules,
834
+ topN = 5,
835
+ }) {
836
+ // Find best matching jobs
837
+ const matches = findMatchingJobs({
838
+ selfAssessment,
839
+ disciplines,
840
+ grades,
841
+ tracks,
842
+ skills,
843
+ behaviours,
844
+ validationRules,
845
+ topN,
846
+ });
847
+
848
+ // Generate development path for the best match
849
+ const bestMatch = matches[0];
850
+ const developmentPath = bestMatch
851
+ ? deriveDevelopmentPath({ selfAssessment, targetJob: bestMatch.job })
852
+ : null;
853
+
854
+ // Calculate overall skill profile
855
+ const skillProfile = {};
856
+ for (const [skillId, level] of Object.entries(
857
+ selfAssessment.skillLevels || {},
858
+ )) {
859
+ const skill = skills.find((s) => s.id === skillId);
860
+ if (skill) {
861
+ skillProfile[skillId] = {
862
+ name: skill.name,
863
+ capability: skill.capability,
864
+ level,
865
+ };
866
+ }
867
+ }
868
+
869
+ // Calculate overall behaviour profile
870
+ const behaviourProfile = {};
871
+ for (const [behaviourId, maturity] of Object.entries(
872
+ selfAssessment.behaviourMaturities || {},
873
+ )) {
874
+ const behaviour = behaviours.find((b) => b.id === behaviourId);
875
+ if (behaviour) {
876
+ behaviourProfile[behaviourId] = {
877
+ name: behaviour.name,
878
+ maturity,
879
+ };
880
+ }
881
+ }
882
+
883
+ return {
884
+ selfAssessment,
885
+ topMatches: matches,
886
+ bestMatch,
887
+ developmentPath,
888
+ skillProfile,
889
+ behaviourProfile,
890
+ };
891
+ }