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