@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,510 @@
1
+ /**
2
+ * Career Progression Functions
3
+ *
4
+ * This module provides pure functions for calculating skill and behaviour
5
+ * changes between job definitions, supporting both grade progression and
6
+ * track comparison scenarios.
7
+ */
8
+
9
+ import {
10
+ getSkillLevelIndex,
11
+ getBehaviourMaturityIndex,
12
+ } from "@forwardimpact/schema/levels";
13
+ import { deriveJob, isValidJobCombination } from "./derivation.js";
14
+
15
+ /**
16
+ * @typedef {Object} SkillChange
17
+ * @property {string} id - Skill ID
18
+ * @property {string} name - Skill name
19
+ * @property {string} capability - Skill capability
20
+ * @property {string} type - Skill type (primary/secondary/broad)
21
+ * @property {string|null} currentLevel - Current skill level (null if skill is gained)
22
+ * @property {string|null} targetLevel - Target skill level (null if skill is lost)
23
+ * @property {number} currentIndex - Current level index (0-4, or -1 if not present)
24
+ * @property {number} targetIndex - Target level index (0-4, or -1 if not present)
25
+ * @property {number} change - Difference between target and current index
26
+ * @property {string|null} currentDescription - Description at current level
27
+ * @property {string|null} targetDescription - Description at target level
28
+ * @property {boolean} [isGained] - True if skill is new in target (not in current)
29
+ * @property {boolean} [isLost] - True if skill is removed in target (not in target)
30
+ */
31
+
32
+ /**
33
+ * @typedef {Object} BehaviourChange
34
+ * @property {string} id - Behaviour ID
35
+ * @property {string} name - Behaviour name
36
+ * @property {string} currentLevel - Current maturity level
37
+ * @property {string} targetLevel - Target maturity level
38
+ * @property {number} currentIndex - Current level index (0-4)
39
+ * @property {number} targetIndex - Target level index (0-4)
40
+ * @property {number} change - Difference between target and current index
41
+
42
+ * @property {string} currentDescription - Description at current level
43
+ * @property {string} targetDescription - Description at target level
44
+ */
45
+
46
+ /**
47
+ * @typedef {Object} ProgressionAnalysis
48
+ * @property {Object} current - Current job definition
49
+ * @property {Object} target - Target job definition
50
+ * @property {SkillChange[]} skillChanges - All skill changes
51
+ * @property {BehaviourChange[]} behaviourChanges - All behaviour changes
52
+ * @property {Object} summary - Summary statistics
53
+ */
54
+
55
+ /**
56
+ * Calculate skill level changes between two skill matrices
57
+ * Handles cross-discipline comparisons by including gained and lost skills
58
+ * @param {Array} currentMatrix - Current skill matrix entries
59
+ * @param {Array} targetMatrix - Target skill matrix entries
60
+ * @returns {SkillChange[]} Array of skill changes, sorted by change magnitude
61
+ */
62
+ export function calculateSkillChanges(currentMatrix, targetMatrix) {
63
+ const changes = [];
64
+ const processedSkillIds = new Set();
65
+
66
+ // Process skills in current matrix
67
+ for (const current of currentMatrix) {
68
+ processedSkillIds.add(current.skillId);
69
+ const target = targetMatrix.find((t) => t.skillId === current.skillId);
70
+
71
+ if (target) {
72
+ // Skill exists in both - calculate level change
73
+ const currentIndex = getSkillLevelIndex(current.level);
74
+ const targetIndex = getSkillLevelIndex(target.level);
75
+ const change = targetIndex - currentIndex;
76
+
77
+ changes.push({
78
+ id: current.skillId,
79
+ name: current.skillName,
80
+ capability: current.capability,
81
+ type: current.type,
82
+ currentLevel: current.level,
83
+ targetLevel: target.level,
84
+ currentIndex,
85
+ targetIndex,
86
+ change,
87
+ currentDescription: current.levelDescription,
88
+ targetDescription: target.levelDescription,
89
+ });
90
+ } else {
91
+ // Skill is lost (in current but not in target)
92
+ const currentIndex = getSkillLevelIndex(current.level);
93
+ changes.push({
94
+ id: current.skillId,
95
+ name: current.skillName,
96
+ capability: current.capability,
97
+ type: current.type,
98
+ currentLevel: current.level,
99
+ targetLevel: null,
100
+ currentIndex,
101
+ targetIndex: -1,
102
+ change: -(currentIndex + 1), // Negative change representing loss
103
+ currentDescription: current.levelDescription,
104
+ targetDescription: null,
105
+ isLost: true,
106
+ });
107
+ }
108
+ }
109
+
110
+ // Process skills only in target matrix (gained skills)
111
+ for (const target of targetMatrix) {
112
+ if (!processedSkillIds.has(target.skillId)) {
113
+ const targetIndex = getSkillLevelIndex(target.level);
114
+ changes.push({
115
+ id: target.skillId,
116
+ name: target.skillName,
117
+ capability: target.capability,
118
+ type: target.type,
119
+ currentLevel: null,
120
+ targetLevel: target.level,
121
+ currentIndex: -1,
122
+ targetIndex,
123
+ change: targetIndex + 1, // Positive change representing gain
124
+ currentDescription: null,
125
+ targetDescription: target.levelDescription,
126
+ isGained: true,
127
+ });
128
+ }
129
+ }
130
+
131
+ // Sort by change (largest first), then by type, then by name
132
+ const typeOrder = { primary: 0, secondary: 1, broad: 2 };
133
+ changes.sort((a, b) => {
134
+ if (b.change !== a.change) return b.change - a.change;
135
+ if (typeOrder[a.type] !== typeOrder[b.type])
136
+ return typeOrder[a.type] - typeOrder[b.type];
137
+ return a.name.localeCompare(b.name);
138
+ });
139
+
140
+ return changes;
141
+ }
142
+
143
+ /**
144
+ * Calculate behaviour maturity changes between two profiles
145
+ * @param {Array} currentProfile - Current behaviour profile entries
146
+ * @param {Array} targetProfile - Target behaviour profile entries
147
+ * @returns {BehaviourChange[]} Array of behaviour changes, sorted by change magnitude
148
+ */
149
+ export function calculateBehaviourChanges(currentProfile, targetProfile) {
150
+ const changes = [];
151
+
152
+ for (const current of currentProfile) {
153
+ const target = targetProfile.find(
154
+ (t) => t.behaviourId === current.behaviourId,
155
+ );
156
+ if (target) {
157
+ const currentIndex = getBehaviourMaturityIndex(current.maturity);
158
+ const targetIndex = getBehaviourMaturityIndex(target.maturity);
159
+ const change = targetIndex - currentIndex;
160
+
161
+ changes.push({
162
+ id: current.behaviourId,
163
+ name: current.behaviourName,
164
+ currentLevel: current.maturity,
165
+ targetLevel: target.maturity,
166
+ currentIndex,
167
+ targetIndex,
168
+ change,
169
+ currentDescription: current.maturityDescription,
170
+ targetDescription: target.maturityDescription,
171
+ });
172
+ }
173
+ }
174
+
175
+ // Sort by change (largest first), then by name
176
+ changes.sort((a, b) => {
177
+ if (b.change !== a.change) return b.change - a.change;
178
+ return a.name.localeCompare(b.name);
179
+ });
180
+
181
+ return changes;
182
+ }
183
+
184
+ /**
185
+ * Analyze progression between two job definitions
186
+ * @param {Object} currentJob - Current job definition
187
+ * @param {Object} targetJob - Target job definition
188
+ * @returns {ProgressionAnalysis} Complete progression analysis
189
+ */
190
+ export function analyzeProgression(currentJob, targetJob) {
191
+ const skillChanges = calculateSkillChanges(
192
+ currentJob.skillMatrix,
193
+ targetJob.skillMatrix,
194
+ );
195
+ const behaviourChanges = calculateBehaviourChanges(
196
+ currentJob.behaviourProfile,
197
+ targetJob.behaviourProfile,
198
+ );
199
+
200
+ const skillsUp = skillChanges.filter(
201
+ (s) => s.change > 0 && !s.isGained,
202
+ ).length;
203
+ const skillsDown = skillChanges.filter(
204
+ (s) => s.change < 0 && !s.isLost,
205
+ ).length;
206
+ const skillsSame = skillChanges.filter((s) => s.change === 0).length;
207
+ const skillsGained = skillChanges.filter((s) => s.isGained).length;
208
+ const skillsLost = skillChanges.filter((s) => s.isLost).length;
209
+
210
+ const behavioursUp = behaviourChanges.filter((b) => b.change > 0).length;
211
+ const behavioursDown = behaviourChanges.filter((b) => b.change < 0).length;
212
+ const behavioursSame = behaviourChanges.filter((b) => b.change === 0).length;
213
+
214
+ return {
215
+ current: currentJob,
216
+ target: targetJob,
217
+ skillChanges,
218
+ behaviourChanges,
219
+ summary: {
220
+ skillsUp,
221
+ skillsDown,
222
+ skillsSame,
223
+ skillsGained,
224
+ skillsLost,
225
+ totalSkillChange: skillChanges.reduce((sum, s) => sum + s.change, 0),
226
+ behavioursUp,
227
+ behavioursDown,
228
+ behavioursSame,
229
+ totalBehaviourChange: behaviourChanges.reduce(
230
+ (sum, b) => sum + b.change,
231
+ 0,
232
+ ),
233
+ },
234
+ };
235
+ }
236
+
237
+ /**
238
+ * Analyze grade progression for a role
239
+ * @param {Object} params
240
+ * @param {Object} params.discipline - The discipline
241
+ * @param {Object} params.grade - Current grade
242
+ * @param {Object} params.track - The track
243
+ * @param {Object} params.nextGrade - Target grade (optional, will find next if not provided)
244
+ * @param {Array} params.grades - All grades (needed if nextGrade not provided)
245
+ * @param {Array} params.skills - All skills
246
+ * @param {Array} params.behaviours - All behaviours
247
+ * @returns {ProgressionAnalysis|null} Progression analysis or null if no next grade
248
+ */
249
+ export function analyzeGradeProgression({
250
+ discipline,
251
+ grade,
252
+ track,
253
+ nextGrade,
254
+ grades,
255
+ skills,
256
+ behaviours,
257
+ }) {
258
+ // Find next grade if not provided
259
+ let targetGrade = nextGrade;
260
+ if (!targetGrade && grades) {
261
+ const sortedGrades = [...grades].sort(
262
+ (a, b) => a.ordinalRank - b.ordinalRank,
263
+ );
264
+ const currentIndex = sortedGrades.findIndex((g) => g.id === grade.id);
265
+ targetGrade = sortedGrades[currentIndex + 1];
266
+ }
267
+
268
+ if (!targetGrade) {
269
+ return null;
270
+ }
271
+
272
+ // Create job definitions
273
+ const currentJob = deriveJob({
274
+ discipline,
275
+ grade,
276
+ track,
277
+ skills,
278
+ behaviours,
279
+ });
280
+
281
+ const targetJob = deriveJob({
282
+ discipline,
283
+ grade: targetGrade,
284
+ track,
285
+ skills,
286
+ behaviours,
287
+ });
288
+
289
+ if (!currentJob || !targetJob) {
290
+ return null;
291
+ }
292
+
293
+ return analyzeProgression(currentJob, targetJob);
294
+ }
295
+
296
+ /**
297
+ * Analyze track comparison at the same grade
298
+ * @param {Object} params
299
+ * @param {Object} params.discipline - The discipline
300
+ * @param {Object} params.grade - The grade
301
+ * @param {Object} params.currentTrack - Current track
302
+ * @param {Object} params.targetTrack - Target track to compare
303
+ * @param {Array} params.skills - All skills
304
+ * @param {Array} params.behaviours - All behaviours
305
+ * @param {Array} params.grades - All grades (for validation)
306
+ * @returns {ProgressionAnalysis|null} Progression analysis or null if invalid combination
307
+ */
308
+ export function analyzeTrackComparison({
309
+ discipline,
310
+ grade,
311
+ currentTrack,
312
+ targetTrack,
313
+ skills,
314
+ behaviours,
315
+ grades,
316
+ }) {
317
+ // Check if target track is valid for this discipline
318
+ if (
319
+ !isValidJobCombination({ discipline, grade, track: targetTrack, grades })
320
+ ) {
321
+ return null;
322
+ }
323
+
324
+ // Create job definitions
325
+ const currentJob = deriveJob({
326
+ discipline,
327
+ grade,
328
+ track: currentTrack,
329
+ skills,
330
+ behaviours,
331
+ });
332
+
333
+ const targetJob = deriveJob({
334
+ discipline,
335
+ grade,
336
+ track: targetTrack,
337
+ skills,
338
+ behaviours,
339
+ });
340
+
341
+ if (!currentJob || !targetJob) {
342
+ return null;
343
+ }
344
+
345
+ return analyzeProgression(currentJob, targetJob);
346
+ }
347
+
348
+ /**
349
+ * Get all valid tracks for comparison given a discipline and grade
350
+ * @param {Object} params
351
+ * @param {Object} params.discipline - The discipline
352
+ * @param {Object} params.grade - The grade
353
+ * @param {Object} params.currentTrack - Current track (will be excluded from results)
354
+ * @param {Array} params.tracks - All available tracks
355
+ * @param {Array} params.grades - All grades (for validation)
356
+ * @returns {Array} Valid tracks for comparison
357
+ */
358
+ export function getValidTracksForComparison({
359
+ discipline,
360
+ grade,
361
+ currentTrack,
362
+ tracks,
363
+ grades,
364
+ }) {
365
+ return tracks.filter(
366
+ (t) =>
367
+ t.id !== currentTrack.id &&
368
+ isValidJobCombination({ discipline, grade, track: t, grades }),
369
+ );
370
+ }
371
+
372
+ /**
373
+ * Get the next grade in the progression
374
+ * @param {Object} grade - Current grade
375
+ * @param {Array} grades - All grades
376
+ * @returns {Object|null} Next grade or null if at highest
377
+ */
378
+ export function getNextGrade(grade, grades) {
379
+ const sortedGrades = [...grades].sort(
380
+ (a, b) => a.ordinalRank - b.ordinalRank,
381
+ );
382
+ const currentIndex = sortedGrades.findIndex((g) => g.id === grade.id);
383
+ return sortedGrades[currentIndex + 1] || null;
384
+ }
385
+
386
+ /**
387
+ * Get the previous grade in the progression
388
+ * @param {Object} grade - Current grade
389
+ * @param {Array} grades - All grades
390
+ * @returns {Object|null} Previous grade or null if at lowest
391
+ */
392
+ export function getPreviousGrade(grade, grades) {
393
+ const sortedGrades = [...grades].sort(
394
+ (a, b) => a.ordinalRank - b.ordinalRank,
395
+ );
396
+ const currentIndex = sortedGrades.findIndex((g) => g.id === grade.id);
397
+ return currentIndex > 0 ? sortedGrades[currentIndex - 1] : null;
398
+ }
399
+
400
+ /**
401
+ * Analyze custom progression from current role to any target discipline × grade × track combination
402
+ * This is the main abstraction for comparing arbitrary role combinations.
403
+ *
404
+ * @param {Object} params
405
+ * @param {Object} params.discipline - Current discipline
406
+ * @param {Object} params.currentGrade - Current grade
407
+ * @param {Object} params.currentTrack - Current track
408
+ * @param {Object} [params.targetDiscipline] - Target discipline (defaults to current discipline)
409
+ * @param {Object} params.targetGrade - Target grade for comparison
410
+ * @param {Object} params.targetTrack - Target track for comparison
411
+ * @param {Array} params.skills - All skills
412
+ * @param {Array} params.behaviours - All behaviours
413
+ * @param {Array} params.grades - All grades (for validation)
414
+ * @returns {ProgressionAnalysis|null} Progression analysis or null if invalid combination
415
+ */
416
+ export function analyzeCustomProgression({
417
+ discipline,
418
+ currentGrade,
419
+ currentTrack,
420
+ targetDiscipline,
421
+ targetGrade,
422
+ targetTrack,
423
+ skills,
424
+ behaviours,
425
+ grades,
426
+ }) {
427
+ // Use current discipline if target not specified
428
+ const targetDisc = targetDiscipline || discipline;
429
+
430
+ // Validate target combination is valid
431
+ if (
432
+ !isValidJobCombination({
433
+ discipline: targetDisc,
434
+ grade: targetGrade,
435
+ track: targetTrack,
436
+ grades,
437
+ })
438
+ ) {
439
+ return null;
440
+ }
441
+
442
+ // Create current job definition
443
+ const currentJob = deriveJob({
444
+ discipline,
445
+ grade: currentGrade,
446
+ track: currentTrack,
447
+ skills,
448
+ behaviours,
449
+ });
450
+
451
+ // Create target job definition
452
+ const targetJob = deriveJob({
453
+ discipline: targetDisc,
454
+ grade: targetGrade,
455
+ track: targetTrack,
456
+ skills,
457
+ behaviours,
458
+ });
459
+
460
+ if (!currentJob || !targetJob) {
461
+ return null;
462
+ }
463
+
464
+ return analyzeProgression(currentJob, targetJob);
465
+ }
466
+
467
+ /**
468
+ * Get all valid grade × track combinations for a discipline
469
+ * Useful for populating dropdowns in the UI
470
+ *
471
+ * @param {Object} params
472
+ * @param {Object} params.discipline - The discipline
473
+ * @param {Array} params.grades - All grades
474
+ * @param {Array} params.tracks - All tracks
475
+ * @param {Object} [params.excludeGrade] - Optional grade to exclude
476
+ * @param {Object} [params.excludeTrack] - Optional track to exclude
477
+ * @returns {Array<{grade: Object, track: Object}>} Valid combinations
478
+ */
479
+ export function getValidGradeTrackCombinations({
480
+ discipline,
481
+ grades,
482
+ tracks,
483
+ excludeGrade,
484
+ excludeTrack,
485
+ }) {
486
+ const combinations = [];
487
+
488
+ for (const grade of grades) {
489
+ for (const track of tracks) {
490
+ // Skip if this is the excluded combination
491
+ if (excludeGrade?.id === grade.id && excludeTrack?.id === track.id) {
492
+ continue;
493
+ }
494
+
495
+ if (isValidJobCombination({ discipline, grade, track, grades })) {
496
+ combinations.push({ grade, track });
497
+ }
498
+ }
499
+ }
500
+
501
+ // Sort by grade level, then by track name
502
+ combinations.sort((a, b) => {
503
+ if (a.grade.ordinalRank !== b.grade.ordinalRank) {
504
+ return a.grade.ordinalRank - b.grade.ordinalRank;
505
+ }
506
+ return a.track.name.localeCompare(b.track.name);
507
+ });
508
+
509
+ return combinations;
510
+ }
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@forwardimpact/model",
3
+ "version": "0.1.0",
4
+ "description": "Business logic for Engineering Pathway framework",
5
+ "license": "Apache-2.0",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/forwardimpact/pathway",
9
+ "directory": "apps/model"
10
+ },
11
+ "type": "module",
12
+ "main": "lib/index.js",
13
+ "files": [
14
+ "lib/"
15
+ ],
16
+ "exports": {
17
+ ".": "./lib/index.js",
18
+ "./derivation": "./lib/derivation.js",
19
+ "./modifiers": "./lib/modifiers.js",
20
+ "./agent": "./lib/agent.js",
21
+ "./interview": "./lib/interview.js",
22
+ "./job": "./lib/job.js",
23
+ "./job-cache": "./lib/job-cache.js",
24
+ "./checklist": "./lib/checklist.js",
25
+ "./matching": "./lib/matching.js",
26
+ "./profile": "./lib/profile.js",
27
+ "./progression": "./lib/progression.js"
28
+ },
29
+ "dependencies": {
30
+ "@forwardimpact/schema": "^0.1.0"
31
+ },
32
+ "engines": {
33
+ "node": ">=18.0.0"
34
+ }
35
+ }