@forwardimpact/model 0.5.0 → 0.7.1

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,1001 @@
1
+ /**
2
+ * Engineering Pathway Interview Question Generation
3
+ *
4
+ * This module provides pure functions for generating interview questions
5
+ * based on job definitions and question banks.
6
+ */
7
+
8
+ import {
9
+ getSkillLevelIndex,
10
+ getBehaviourMaturityIndex,
11
+ SKILL_LEVEL_ORDER,
12
+ Capability,
13
+ } from "@forwardimpact/schema/levels";
14
+
15
+ import {
16
+ WEIGHT_SKILL_TYPE,
17
+ WEIGHT_CAPABILITY_BOOST,
18
+ WEIGHT_BEHAVIOUR_BASE,
19
+ WEIGHT_BEHAVIOUR_MATURITY,
20
+ WEIGHT_SKILL_LEVEL,
21
+ WEIGHT_BELOW_LEVEL_PENALTY,
22
+ RATIO_SKILL_BEHAVIOUR,
23
+ DEFAULT_INTERVIEW_QUESTION_MINUTES,
24
+ DEFAULT_DECOMPOSITION_QUESTION_MINUTES,
25
+ DEFAULT_SIMULATION_QUESTION_MINUTES,
26
+ TOLERANCE_INTERVIEW_BUDGET_MINUTES,
27
+ WEIGHT_CAPABILITY_DECOMP_DELIVERY,
28
+ WEIGHT_CAPABILITY_DECOMP_SCALE,
29
+ WEIGHT_CAPABILITY_DECOMP_RELIABILITY,
30
+ WEIGHT_FOCUS_BOOST,
31
+ } from "./policies/thresholds.js";
32
+
33
+ import { compareByMaturityDesc } from "./policies/orderings.js";
34
+
35
+ /**
36
+ * Get questions from the question bank for a specific skill and level
37
+ * @param {import('./levels.js').QuestionBank} questionBank - The question bank
38
+ * @param {string} skillId - The skill ID
39
+ * @param {string} level - The skill level
40
+ * @param {string} [roleType='professionalQuestions'] - Role type ('professionalQuestions' or 'managementQuestions')
41
+ * @returns {import('./levels.js').Question[]} Array of questions
42
+ */
43
+ function getSkillQuestions(
44
+ questionBank,
45
+ skillId,
46
+ level,
47
+ roleType = "professionalQuestions",
48
+ ) {
49
+ return questionBank.skillLevels?.[skillId]?.[roleType]?.[level] || [];
50
+ }
51
+
52
+ /**
53
+ * Get questions from the question bank for a specific behaviour and maturity
54
+ * @param {import('./levels.js').QuestionBank} questionBank - The question bank
55
+ * @param {string} behaviourId - The behaviour ID
56
+ * @param {string} maturity - The maturity level
57
+ * @param {string} [roleType='professionalQuestions'] - Role type ('professionalQuestions' or 'managementQuestions')
58
+ * @returns {import('./levels.js').Question[]} Array of questions
59
+ */
60
+ function getBehaviourQuestions(
61
+ questionBank,
62
+ behaviourId,
63
+ maturity,
64
+ roleType = "professionalQuestions",
65
+ ) {
66
+ return (
67
+ questionBank.behaviourMaturities?.[behaviourId]?.[roleType]?.[maturity] ||
68
+ []
69
+ );
70
+ }
71
+
72
+ /**
73
+ * Get decomposition questions from the question bank for a specific capability and level
74
+ * @param {import('./levels.js').QuestionBank} questionBank - The question bank
75
+ * @param {string} capabilityId - The capability ID
76
+ * @param {string} level - The skill level (capabilities use same levels as skills)
77
+ * @param {string} [roleType='professionalQuestions'] - Role type ('professionalQuestions' or 'managementQuestions')
78
+ * @returns {import('./levels.js').Question[]} Array of questions
79
+ */
80
+ function getCapabilityQuestions(
81
+ questionBank,
82
+ capabilityId,
83
+ level,
84
+ roleType = "professionalQuestions",
85
+ ) {
86
+ return (
87
+ questionBank.capabilityLevels?.[capabilityId]?.[roleType]?.[level] || []
88
+ );
89
+ }
90
+
91
+ /**
92
+ * Derive capability levels from a job's skill matrix
93
+ * Uses the maximum skill level in each capability.
94
+ * @param {import('./levels.js').JobDefinition} job - The job definition
95
+ * @returns {Map<string, {capabilityId: string, level: string, levelIndex: number}>} Map of capability to level info
96
+ */
97
+ function deriveCapabilityLevels(job) {
98
+ const capabilityLevels = new Map();
99
+
100
+ for (const skill of job.skillMatrix) {
101
+ const capabilityId = skill.capability;
102
+ const levelIndex = getSkillLevelIndex(skill.level);
103
+
104
+ const existing = capabilityLevels.get(capabilityId);
105
+ if (!existing || levelIndex > existing.levelIndex) {
106
+ capabilityLevels.set(capabilityId, {
107
+ capabilityId,
108
+ level: skill.level,
109
+ levelIndex,
110
+ });
111
+ }
112
+ }
113
+
114
+ return capabilityLevels;
115
+ }
116
+
117
+ /**
118
+ * Calculate priority for a skill question
119
+ * @param {import('./levels.js').SkillMatrixEntry} skill - The skill entry
120
+ * @param {boolean} includeBelowLevel - Whether this is a below-level question
121
+ * @returns {number} Priority score (higher = more important)
122
+ */
123
+ function calculateSkillPriority(skill, includeBelowLevel = false) {
124
+ let priority = 0;
125
+
126
+ // Skill type priority from policy weights
127
+ priority += WEIGHT_SKILL_TYPE[skill.type] || WEIGHT_SKILL_TYPE.broad;
128
+
129
+ // AI skills get a boost for "AI-era focus"
130
+ if (skill.capability === Capability.AI) {
131
+ priority += WEIGHT_CAPABILITY_BOOST.ai;
132
+ }
133
+
134
+ // Delivery skills get a core technical boost
135
+ if (skill.capability === Capability.DELIVERY) {
136
+ priority += WEIGHT_CAPABILITY_BOOST.delivery;
137
+ }
138
+
139
+ // Higher skill level = higher priority
140
+ priority += getSkillLevelIndex(skill.level) * WEIGHT_SKILL_LEVEL;
141
+
142
+ // Below-level questions have lower priority
143
+ if (includeBelowLevel) {
144
+ priority += WEIGHT_BELOW_LEVEL_PENALTY;
145
+ }
146
+
147
+ return priority;
148
+ }
149
+
150
+ /**
151
+ * Calculate priority for a behaviour question
152
+ * @param {import('./levels.js').BehaviourProfileEntry} behaviour - The behaviour entry
153
+ * @returns {number} Priority score (higher = more important)
154
+ */
155
+ function calculateBehaviourPriority(behaviour) {
156
+ let priority = WEIGHT_BEHAVIOUR_BASE;
157
+
158
+ // Higher maturity level = higher priority
159
+ priority +=
160
+ getBehaviourMaturityIndex(behaviour.maturity) * WEIGHT_BEHAVIOUR_MATURITY;
161
+
162
+ return priority;
163
+ }
164
+
165
+ /**
166
+ * Calculate priority for a capability decomposition question
167
+ * @param {string} capabilityId - The capability ID
168
+ * @param {number} levelIndex - The skill level index
169
+ * @returns {number} Priority score (higher = more important)
170
+ */
171
+ function calculateCapabilityPriority(capabilityId, levelIndex) {
172
+ let priority = 0;
173
+
174
+ // Delivery and scale capabilities are typically more important for decomposition
175
+ if (capabilityId === Capability.DELIVERY) {
176
+ priority += WEIGHT_CAPABILITY_DECOMP_DELIVERY;
177
+ } else if (capabilityId === Capability.SCALE) {
178
+ priority += WEIGHT_CAPABILITY_DECOMP_SCALE;
179
+ } else if (capabilityId === Capability.RELIABILITY) {
180
+ priority += WEIGHT_CAPABILITY_DECOMP_RELIABILITY;
181
+ }
182
+
183
+ // Higher level = higher priority
184
+ priority += levelIndex * WEIGHT_SKILL_LEVEL;
185
+
186
+ return priority;
187
+ }
188
+
189
+ /**
190
+ * Select a random question from an array (or first if deterministic)
191
+ * @param {import('./levels.js').Question[]} questions - Array of questions
192
+ * @param {boolean} deterministic - If true, always select first question
193
+ * @returns {import('./levels.js').Question|null} Selected question or null
194
+ */
195
+ function selectQuestion(questions, deterministic = false) {
196
+ if (!questions || questions.length === 0) {
197
+ return null;
198
+ }
199
+ if (deterministic) {
200
+ return questions[0];
201
+ }
202
+ return questions[Math.floor(Math.random() * questions.length)];
203
+ }
204
+
205
+ /**
206
+ * Derive interview questions for a job
207
+ * @param {Object} params
208
+ * @param {import('./levels.js').JobDefinition} params.job - The job definition
209
+ * @param {import('./levels.js').QuestionBank} params.questionBank - The question bank
210
+ * @param {Object} [params.options] - Generation options
211
+ * @param {boolean} [params.options.includeBelowLevel=true] - Include one question from level below
212
+ * @param {boolean} [params.options.deterministic=false] - Use deterministic selection
213
+ * @param {number} [params.options.maxQuestionsPerSkill=2] - Max questions per skill
214
+ * @param {number} [params.options.maxQuestionsPerBehaviour=2] - Max questions per behaviour
215
+ * @param {number} [params.options.targetMinutes=60] - Target interview length in minutes
216
+ * @param {number} [params.options.skillBehaviourRatio=RATIO_SKILL_BEHAVIOUR] - Ratio of time for skills vs behaviours
217
+ * @param {string} [params.options.roleType='professionalQuestions'] - Role type ('professionalQuestions' or 'managementQuestions')
218
+ * @returns {import('./levels.js').InterviewGuide}
219
+ */
220
+ export function deriveInterviewQuestions({ job, questionBank, options = {} }) {
221
+ const {
222
+ includeBelowLevel = true,
223
+ deterministic = false,
224
+ maxQuestionsPerSkill = 2,
225
+ maxQuestionsPerBehaviour = 2,
226
+ targetMinutes = 60,
227
+ skillBehaviourRatio = RATIO_SKILL_BEHAVIOUR,
228
+ roleType = "professionalQuestions",
229
+ } = options;
230
+
231
+ const allSkillQuestions = [];
232
+ const allBehaviourQuestions = [];
233
+ const coveredSkills = new Set();
234
+ const coveredBehaviours = new Set();
235
+
236
+ // Generate all potential skill questions with priority
237
+ for (const skill of job.skillMatrix) {
238
+ const targetLevel = skill.level;
239
+ const targetLevelIndex = getSkillLevelIndex(targetLevel);
240
+
241
+ // Get questions at target level
242
+ const targetQuestions = getSkillQuestions(
243
+ questionBank,
244
+ skill.skillId,
245
+ targetLevel,
246
+ roleType,
247
+ );
248
+ let questionsAdded = 0;
249
+
250
+ // Add question(s) at target level
251
+ for (const question of targetQuestions) {
252
+ if (questionsAdded >= maxQuestionsPerSkill) break;
253
+
254
+ allSkillQuestions.push({
255
+ question,
256
+ targetId: skill.skillId,
257
+ targetName: skill.skillName,
258
+ targetType: "skill",
259
+ targetLevel,
260
+ priority: calculateSkillPriority(skill, false),
261
+ });
262
+ questionsAdded++;
263
+ }
264
+
265
+ // Optionally add question from level below
266
+ if (
267
+ includeBelowLevel &&
268
+ targetLevelIndex > 0 &&
269
+ questionsAdded < maxQuestionsPerSkill
270
+ ) {
271
+ const belowLevel = SKILL_LEVEL_ORDER[targetLevelIndex - 1];
272
+ const belowQuestions = getSkillQuestions(
273
+ questionBank,
274
+ skill.skillId,
275
+ belowLevel,
276
+ roleType,
277
+ );
278
+
279
+ const belowQuestion = selectQuestion(belowQuestions, deterministic);
280
+ if (belowQuestion) {
281
+ allSkillQuestions.push({
282
+ question: belowQuestion,
283
+ targetId: skill.skillId,
284
+ targetName: skill.skillName,
285
+ targetType: "skill",
286
+ targetLevel: belowLevel,
287
+ priority: calculateSkillPriority(skill, true),
288
+ });
289
+ }
290
+ }
291
+ }
292
+
293
+ // Generate all potential behaviour questions with priority
294
+ for (const behaviour of job.behaviourProfile) {
295
+ const targetMaturity = behaviour.maturity;
296
+ const questions = getBehaviourQuestions(
297
+ questionBank,
298
+ behaviour.behaviourId,
299
+ targetMaturity,
300
+ roleType,
301
+ targetMaturity,
302
+ );
303
+ let questionsAdded = 0;
304
+
305
+ for (const question of questions) {
306
+ if (questionsAdded >= maxQuestionsPerBehaviour) break;
307
+
308
+ allBehaviourQuestions.push({
309
+ question,
310
+ targetId: behaviour.behaviourId,
311
+ targetName: behaviour.behaviourName,
312
+ targetType: "behaviour",
313
+ targetLevel: targetMaturity,
314
+ priority: calculateBehaviourPriority(behaviour),
315
+ });
316
+ questionsAdded++;
317
+ }
318
+ }
319
+
320
+ // Sort both lists by priority (highest first)
321
+ allSkillQuestions.sort((a, b) => b.priority - a.priority);
322
+ allBehaviourQuestions.sort((a, b) => b.priority - a.priority);
323
+
324
+ // Calculate time budgets
325
+ const skillTimeBudget = targetMinutes * skillBehaviourRatio;
326
+ const behaviourTimeBudget = targetMinutes * (1 - skillBehaviourRatio);
327
+
328
+ // Select skill questions within budget, prioritizing coverage diversity
329
+ // First pass: one question per skill (highest priority first)
330
+ const selectedQuestions = [];
331
+ const selectedSkillIds = new Set();
332
+ let skillMinutes = 0;
333
+
334
+ for (const q of allSkillQuestions) {
335
+ if (selectedSkillIds.has(q.targetId)) continue; // Skip if we already have this skill
336
+ const questionTime =
337
+ q.question.expectedDurationMinutes || DEFAULT_INTERVIEW_QUESTION_MINUTES;
338
+ if (
339
+ skillMinutes + questionTime <=
340
+ skillTimeBudget + TOLERANCE_INTERVIEW_BUDGET_MINUTES
341
+ ) {
342
+ selectedQuestions.push(q);
343
+ selectedSkillIds.add(q.targetId);
344
+ coveredSkills.add(q.targetId);
345
+ skillMinutes += questionTime;
346
+ }
347
+ }
348
+
349
+ // Second pass: add more questions if time allows
350
+ for (const q of allSkillQuestions) {
351
+ if (selectedQuestions.includes(q)) continue; // Skip already selected
352
+ const questionTime =
353
+ q.question.expectedDurationMinutes || DEFAULT_INTERVIEW_QUESTION_MINUTES;
354
+ if (
355
+ skillMinutes + questionTime <=
356
+ skillTimeBudget + TOLERANCE_INTERVIEW_BUDGET_MINUTES
357
+ ) {
358
+ selectedQuestions.push(q);
359
+ coveredSkills.add(q.targetId);
360
+ skillMinutes += questionTime;
361
+ }
362
+ }
363
+
364
+ // Select behaviour questions within budget, prioritizing coverage diversity
365
+ // First pass: one question per behaviour (highest priority first)
366
+ const selectedBehaviourIds = new Set();
367
+ let behaviourMinutes = 0;
368
+
369
+ for (const q of allBehaviourQuestions) {
370
+ if (selectedBehaviourIds.has(q.targetId)) continue; // Skip if we already have this behaviour
371
+ const questionTime =
372
+ q.question.expectedDurationMinutes || DEFAULT_INTERVIEW_QUESTION_MINUTES;
373
+ if (
374
+ behaviourMinutes + questionTime <=
375
+ behaviourTimeBudget + TOLERANCE_INTERVIEW_BUDGET_MINUTES
376
+ ) {
377
+ selectedQuestions.push(q);
378
+ selectedBehaviourIds.add(q.targetId);
379
+ coveredBehaviours.add(q.targetId);
380
+ behaviourMinutes += questionTime;
381
+ }
382
+ }
383
+
384
+ // Second pass: add more behaviour questions if time allows
385
+ for (const q of allBehaviourQuestions) {
386
+ if (selectedQuestions.includes(q)) continue; // Skip already selected
387
+ const questionTime =
388
+ q.question.expectedDurationMinutes || DEFAULT_INTERVIEW_QUESTION_MINUTES;
389
+ if (
390
+ behaviourMinutes + questionTime <=
391
+ behaviourTimeBudget + TOLERANCE_INTERVIEW_BUDGET_MINUTES
392
+ ) {
393
+ selectedQuestions.push(q);
394
+ coveredBehaviours.add(q.targetId);
395
+ behaviourMinutes += questionTime;
396
+ }
397
+ }
398
+
399
+ // Re-sort selected questions by priority
400
+ selectedQuestions.sort((a, b) => b.priority - a.priority);
401
+
402
+ // Calculate total time
403
+ const expectedDurationMinutes = selectedQuestions.reduce(
404
+ (sum, q) =>
405
+ sum +
406
+ (q.question.expectedDurationMinutes ||
407
+ DEFAULT_INTERVIEW_QUESTION_MINUTES),
408
+ 0,
409
+ );
410
+
411
+ return {
412
+ job,
413
+ questions: selectedQuestions,
414
+ expectedDurationMinutes,
415
+ coverage: {
416
+ skills: Array.from(coveredSkills),
417
+ behaviours: Array.from(coveredBehaviours),
418
+ },
419
+ };
420
+ }
421
+
422
+ /**
423
+ * Derive a short/screening interview within a time budget
424
+ * @param {Object} params
425
+ * @param {import('./levels.js').JobDefinition} params.job - The job definition
426
+ * @param {import('./levels.js').QuestionBank} params.questionBank - The question bank
427
+ * @param {number} [params.targetMinutes=20] - Target interview length in minutes
428
+ * @returns {import('./levels.js').InterviewGuide}
429
+ */
430
+ export function deriveShortInterview({
431
+ job,
432
+ questionBank,
433
+ targetMinutes = 20,
434
+ }) {
435
+ // First get all potential questions with priority
436
+ const fullInterview = deriveInterviewQuestions({
437
+ job,
438
+ questionBank,
439
+ options: {
440
+ includeBelowLevel: false, // Skip below-level for short interviews
441
+ maxQuestionsPerSkill: 1,
442
+ maxQuestionsPerBehaviour: 1,
443
+ },
444
+ });
445
+
446
+ // Select questions until we hit the time budget
447
+ const selectedQuestions = [];
448
+ let totalMinutes = 0;
449
+ const coveredSkills = new Set();
450
+ const coveredBehaviours = new Set();
451
+
452
+ // Ensure we have at least some skill and behaviour coverage
453
+ // by alternating between skill and behaviour questions
454
+ const skillQuestions = fullInterview.questions.filter(
455
+ (q) => q.targetType === "skill",
456
+ );
457
+ const behaviourQuestions = fullInterview.questions.filter(
458
+ (q) => q.targetType === "behaviour",
459
+ );
460
+
461
+ let skillIndex = 0;
462
+ let behaviourIndex = 0;
463
+ let preferSkill = true;
464
+
465
+ while (totalMinutes < targetMinutes) {
466
+ let nextQuestion = null;
467
+
468
+ if (preferSkill && skillIndex < skillQuestions.length) {
469
+ nextQuestion = skillQuestions[skillIndex++];
470
+ } else if (!preferSkill && behaviourIndex < behaviourQuestions.length) {
471
+ nextQuestion = behaviourQuestions[behaviourIndex++];
472
+ } else if (skillIndex < skillQuestions.length) {
473
+ nextQuestion = skillQuestions[skillIndex++];
474
+ } else if (behaviourIndex < behaviourQuestions.length) {
475
+ nextQuestion = behaviourQuestions[behaviourIndex++];
476
+ } else {
477
+ break; // No more questions
478
+ }
479
+
480
+ const questionTime =
481
+ nextQuestion.question.expectedDurationMinutes ||
482
+ DEFAULT_INTERVIEW_QUESTION_MINUTES;
483
+
484
+ // Don't exceed budget by too much
485
+ if (
486
+ totalMinutes + questionTime >
487
+ targetMinutes + TOLERANCE_INTERVIEW_BUDGET_MINUTES
488
+ ) {
489
+ break;
490
+ }
491
+
492
+ selectedQuestions.push(nextQuestion);
493
+ totalMinutes += questionTime;
494
+
495
+ if (nextQuestion.targetType === "skill") {
496
+ coveredSkills.add(nextQuestion.targetId);
497
+ } else {
498
+ coveredBehaviours.add(nextQuestion.targetId);
499
+ }
500
+
501
+ preferSkill = !preferSkill;
502
+ }
503
+
504
+ // Re-sort selected questions by priority
505
+ selectedQuestions.sort((a, b) => b.priority - a.priority);
506
+
507
+ return {
508
+ job,
509
+ questions: selectedQuestions,
510
+ expectedDurationMinutes: totalMinutes,
511
+ coverage: {
512
+ skills: Array.from(coveredSkills),
513
+ behaviours: Array.from(coveredBehaviours),
514
+ },
515
+ };
516
+ }
517
+
518
+ /**
519
+ * Derive behaviour-focused interview questions
520
+ * @param {Object} params
521
+ * @param {import('./levels.js').JobDefinition} params.job - The job definition
522
+ * @param {import('./levels.js').QuestionBank} params.questionBank - The question bank
523
+ * @param {string} [params.roleType='professionalQuestions'] - Role type ('professionalQuestions' or 'managementQuestions')
524
+ * @returns {import('./levels.js').InterviewGuide}
525
+ */
526
+ export function deriveBehaviourQuestions({
527
+ job,
528
+ questionBank,
529
+ roleType = "professionalQuestions",
530
+ }) {
531
+ const interviewQuestions = [];
532
+ const coveredBehaviours = new Set();
533
+
534
+ // Focus only on behaviours, with more depth
535
+ for (const behaviour of job.behaviourProfile) {
536
+ const targetMaturity = behaviour.maturity;
537
+
538
+ // Get questions at target maturity
539
+ const targetQuestions = getBehaviourQuestions(
540
+ questionBank,
541
+ behaviour.behaviourId,
542
+ targetMaturity,
543
+ roleType,
544
+ );
545
+
546
+ for (const question of targetQuestions) {
547
+ interviewQuestions.push({
548
+ question,
549
+ targetId: behaviour.behaviourId,
550
+ targetName: behaviour.behaviourName,
551
+ targetType: "behaviour",
552
+ targetLevel: targetMaturity,
553
+ priority: calculateBehaviourPriority(behaviour),
554
+ });
555
+ coveredBehaviours.add(behaviour.behaviourId);
556
+ }
557
+ }
558
+
559
+ // Sort by priority
560
+ interviewQuestions.sort((a, b) => b.priority - a.priority);
561
+
562
+ // Calculate total time
563
+ const expectedDurationMinutes = interviewQuestions.reduce(
564
+ (sum, q) =>
565
+ sum +
566
+ (q.question.expectedDurationMinutes ||
567
+ DEFAULT_INTERVIEW_QUESTION_MINUTES),
568
+ 0,
569
+ );
570
+
571
+ return {
572
+ job,
573
+ questions: interviewQuestions,
574
+ expectedDurationMinutes,
575
+ coverage: {
576
+ skills: [],
577
+ behaviours: Array.from(coveredBehaviours),
578
+ },
579
+ };
580
+ }
581
+
582
+ /**
583
+ * Generate a focused interview for specific skills/behaviours
584
+ * @param {Object} params
585
+ * @param {import('./levels.js').JobDefinition} params.job - The job definition
586
+ * @param {import('./levels.js').QuestionBank} params.questionBank - The question bank
587
+ * @param {string[]} [params.focusSkills] - Skill IDs to focus on
588
+ * @param {string[]} [params.focusBehaviours] - Behaviour IDs to focus on
589
+ * @param {string} [params.roleType='professionalQuestions'] - Role type ('professionalQuestions' or 'managementQuestions')
590
+ * @returns {import('./levels.js').InterviewGuide}
591
+ */
592
+ export function deriveFocusedInterview({
593
+ job,
594
+ questionBank,
595
+ focusSkills = [],
596
+ focusBehaviours = [],
597
+ roleType = "professionalQuestions",
598
+ }) {
599
+ const interviewQuestions = [];
600
+ const coveredSkills = new Set();
601
+ const coveredBehaviours = new Set();
602
+
603
+ // Focus skills
604
+ const focusSkillSet = new Set(focusSkills);
605
+ for (const skill of job.skillMatrix) {
606
+ if (!focusSkillSet.has(skill.skillId)) continue;
607
+
608
+ const questions = getSkillQuestions(
609
+ questionBank,
610
+ skill.skillId,
611
+ skill.level,
612
+ roleType,
613
+ );
614
+ for (const question of questions) {
615
+ interviewQuestions.push({
616
+ question,
617
+ targetId: skill.skillId,
618
+ targetName: skill.skillName,
619
+ targetType: "skill",
620
+ targetLevel: skill.level,
621
+ priority: calculateSkillPriority(skill) + WEIGHT_FOCUS_BOOST,
622
+ });
623
+ coveredSkills.add(skill.skillId);
624
+ }
625
+ }
626
+
627
+ // Focus behaviours
628
+ const focusBehaviourSet = new Set(focusBehaviours);
629
+ for (const behaviour of job.behaviourProfile) {
630
+ if (!focusBehaviourSet.has(behaviour.behaviourId)) continue;
631
+
632
+ const questions = getBehaviourQuestions(
633
+ questionBank,
634
+ behaviour.behaviourId,
635
+ behaviour.maturity,
636
+ roleType,
637
+ );
638
+ for (const question of questions) {
639
+ interviewQuestions.push({
640
+ question,
641
+ targetId: behaviour.behaviourId,
642
+ targetName: behaviour.behaviourName,
643
+ targetType: "behaviour",
644
+ targetLevel: behaviour.maturity,
645
+ priority: calculateBehaviourPriority(behaviour) + WEIGHT_FOCUS_BOOST,
646
+ });
647
+ coveredBehaviours.add(behaviour.behaviourId);
648
+ }
649
+ }
650
+
651
+ // Sort by priority
652
+ interviewQuestions.sort((a, b) => b.priority - a.priority);
653
+
654
+ const expectedDurationMinutes = interviewQuestions.reduce(
655
+ (sum, q) =>
656
+ sum +
657
+ (q.question.expectedDurationMinutes ||
658
+ DEFAULT_INTERVIEW_QUESTION_MINUTES),
659
+ 0,
660
+ );
661
+
662
+ return {
663
+ job,
664
+ questions: interviewQuestions,
665
+ expectedDurationMinutes,
666
+ coverage: {
667
+ skills: Array.from(coveredSkills),
668
+ behaviours: Array.from(coveredBehaviours),
669
+ },
670
+ };
671
+ }
672
+
673
+ /**
674
+ * Derive Mission Fit interview questions (skill-focused)
675
+ *
676
+ * 45-minute interview with Recruiting Manager + 1 Senior Engineer
677
+ * Focuses on skill questions to assess technical capability and fit.
678
+ *
679
+ * @param {Object} params
680
+ * @param {import('./levels.js').JobDefinition} params.job - The job definition
681
+ * @param {import('./levels.js').QuestionBank} params.questionBank - The question bank
682
+ * @param {number} [params.targetMinutes=45] - Target interview length in minutes
683
+ * @param {string} [params.roleType='professionalQuestions'] - Role type ('professionalQuestions' or 'managementQuestions')
684
+ * @returns {import('./levels.js').InterviewGuide}
685
+ */
686
+ export function deriveMissionFitInterview({
687
+ job,
688
+ questionBank,
689
+ targetMinutes = 45,
690
+ roleType = "professionalQuestions",
691
+ }) {
692
+ const allSkillQuestions = [];
693
+ const coveredSkills = new Set();
694
+
695
+ // Generate all potential skill questions with priority
696
+ for (const skill of job.skillMatrix) {
697
+ const targetLevel = skill.level;
698
+ const targetLevelIndex = getSkillLevelIndex(targetLevel);
699
+
700
+ // Get questions at target level
701
+ const targetQuestions = getSkillQuestions(
702
+ questionBank,
703
+ skill.skillId,
704
+ targetLevel,
705
+ roleType,
706
+ );
707
+
708
+ for (const question of targetQuestions) {
709
+ allSkillQuestions.push({
710
+ question,
711
+ targetId: skill.skillId,
712
+ targetName: skill.skillName,
713
+ targetType: "skill",
714
+ targetLevel,
715
+ priority: calculateSkillPriority(skill, false),
716
+ });
717
+ }
718
+
719
+ // Also add question from level below for depth
720
+ if (targetLevelIndex > 0) {
721
+ const belowLevel = SKILL_LEVEL_ORDER[targetLevelIndex - 1];
722
+ const belowQuestions = getSkillQuestions(
723
+ questionBank,
724
+ skill.skillId,
725
+ belowLevel,
726
+ roleType,
727
+ );
728
+
729
+ for (const question of belowQuestions) {
730
+ allSkillQuestions.push({
731
+ question,
732
+ targetId: skill.skillId,
733
+ targetName: skill.skillName,
734
+ targetType: "skill",
735
+ targetLevel: belowLevel,
736
+ priority: calculateSkillPriority(skill, true),
737
+ });
738
+ }
739
+ }
740
+ }
741
+
742
+ // Sort by priority (highest first)
743
+ allSkillQuestions.sort((a, b) => b.priority - a.priority);
744
+
745
+ // Select questions within budget, prioritizing coverage diversity
746
+ const selectedQuestions = [];
747
+ const selectedSkillIds = new Set();
748
+ let totalMinutes = 0;
749
+
750
+ // First pass: one question per skill (highest priority first)
751
+ for (const q of allSkillQuestions) {
752
+ if (selectedSkillIds.has(q.targetId)) continue;
753
+ const questionTime =
754
+ q.question.expectedDurationMinutes || DEFAULT_INTERVIEW_QUESTION_MINUTES;
755
+ if (
756
+ totalMinutes + questionTime <=
757
+ targetMinutes + TOLERANCE_INTERVIEW_BUDGET_MINUTES
758
+ ) {
759
+ selectedQuestions.push(q);
760
+ selectedSkillIds.add(q.targetId);
761
+ coveredSkills.add(q.targetId);
762
+ totalMinutes += questionTime;
763
+ }
764
+ }
765
+
766
+ // Second pass: add more questions if time allows
767
+ for (const q of allSkillQuestions) {
768
+ if (selectedQuestions.includes(q)) continue;
769
+ const questionTime =
770
+ q.question.expectedDurationMinutes || DEFAULT_INTERVIEW_QUESTION_MINUTES;
771
+ if (
772
+ totalMinutes + questionTime <=
773
+ targetMinutes + TOLERANCE_INTERVIEW_BUDGET_MINUTES
774
+ ) {
775
+ selectedQuestions.push(q);
776
+ coveredSkills.add(q.targetId);
777
+ totalMinutes += questionTime;
778
+ }
779
+ }
780
+
781
+ // Re-sort by priority
782
+ selectedQuestions.sort((a, b) => b.priority - a.priority);
783
+
784
+ return {
785
+ job,
786
+ questions: selectedQuestions,
787
+ expectedDurationMinutes: totalMinutes,
788
+ coverage: {
789
+ skills: Array.from(coveredSkills),
790
+ behaviours: [],
791
+ capabilities: [],
792
+ },
793
+ };
794
+ }
795
+
796
+ /**
797
+ * Derive Decomposition interview questions (capability-focused)
798
+ *
799
+ * 60-minute interview with 2 Senior Engineers
800
+ * Focuses on capability decomposition questions inspired by Palantir's technique.
801
+ * Capabilities are selected based on the job's skill matrix.
802
+ *
803
+ * @param {Object} params
804
+ * @param {import('./levels.js').JobDefinition} params.job - The job definition
805
+ * @param {import('./levels.js').QuestionBank} params.questionBank - The question bank
806
+ * @param {number} [params.targetMinutes=60] - Target interview length in minutes
807
+ * @param {string} [params.roleType='professionalQuestions'] - Role type ('professionalQuestions' or 'managementQuestions')
808
+ * @returns {import('./levels.js').InterviewGuide}
809
+ */
810
+ export function deriveDecompositionInterview({
811
+ job,
812
+ questionBank,
813
+ targetMinutes = 60,
814
+ roleType = "professionalQuestions",
815
+ }) {
816
+ const allCapabilityQuestions = [];
817
+ const coveredCapabilities = new Set();
818
+
819
+ // Derive capability levels from the job's skill matrix
820
+ const capabilityLevels = deriveCapabilityLevels(job);
821
+
822
+ // Generate capability questions based on derived levels
823
+ for (const [capabilityId, levelInfo] of capabilityLevels) {
824
+ const { level, levelIndex } = levelInfo;
825
+
826
+ // Get questions at the derived level
827
+ const questions = getCapabilityQuestions(
828
+ questionBank,
829
+ capabilityId,
830
+ level,
831
+ roleType,
832
+ );
833
+
834
+ for (const question of questions) {
835
+ allCapabilityQuestions.push({
836
+ question,
837
+ targetId: capabilityId,
838
+ targetName: capabilityId, // Capability name can be enhanced if needed
839
+ targetType: "capability",
840
+ targetLevel: level,
841
+ priority: calculateCapabilityPriority(capabilityId, levelIndex),
842
+ });
843
+ }
844
+
845
+ // Also try level below if available
846
+ if (levelIndex > 0) {
847
+ const belowLevel = SKILL_LEVEL_ORDER[levelIndex - 1];
848
+ const belowQuestions = getCapabilityQuestions(
849
+ questionBank,
850
+ capabilityId,
851
+ belowLevel,
852
+ roleType,
853
+ );
854
+
855
+ for (const question of belowQuestions) {
856
+ allCapabilityQuestions.push({
857
+ question,
858
+ targetId: capabilityId,
859
+ targetName: capabilityId,
860
+ targetType: "capability",
861
+ targetLevel: belowLevel,
862
+ priority: calculateCapabilityPriority(capabilityId, levelIndex - 1),
863
+ });
864
+ }
865
+ }
866
+ }
867
+
868
+ // Sort by priority (highest first)
869
+ allCapabilityQuestions.sort((a, b) => b.priority - a.priority);
870
+
871
+ // Select questions within budget, prioritizing coverage diversity
872
+ const selectedQuestions = [];
873
+ const selectedCapabilityIds = new Set();
874
+ let totalMinutes = 0;
875
+
876
+ // First pass: one question per capability (highest priority first)
877
+ for (const q of allCapabilityQuestions) {
878
+ if (selectedCapabilityIds.has(q.targetId)) continue;
879
+ const questionTime =
880
+ q.question.expectedDurationMinutes ||
881
+ DEFAULT_DECOMPOSITION_QUESTION_MINUTES;
882
+ if (
883
+ totalMinutes + questionTime <=
884
+ targetMinutes + TOLERANCE_INTERVIEW_BUDGET_MINUTES
885
+ ) {
886
+ selectedQuestions.push(q);
887
+ selectedCapabilityIds.add(q.targetId);
888
+ coveredCapabilities.add(q.targetId);
889
+ totalMinutes += questionTime;
890
+ }
891
+ }
892
+
893
+ // Second pass: add more questions if time allows
894
+ for (const q of allCapabilityQuestions) {
895
+ if (selectedQuestions.includes(q)) continue;
896
+ const questionTime =
897
+ q.question.expectedDurationMinutes ||
898
+ DEFAULT_DECOMPOSITION_QUESTION_MINUTES;
899
+ if (
900
+ totalMinutes + questionTime <=
901
+ targetMinutes + TOLERANCE_INTERVIEW_BUDGET_MINUTES
902
+ ) {
903
+ selectedQuestions.push(q);
904
+ coveredCapabilities.add(q.targetId);
905
+ totalMinutes += questionTime;
906
+ }
907
+ }
908
+
909
+ // Re-sort by priority
910
+ selectedQuestions.sort((a, b) => b.priority - a.priority);
911
+
912
+ return {
913
+ job,
914
+ questions: selectedQuestions,
915
+ expectedDurationMinutes: totalMinutes,
916
+ coverage: {
917
+ skills: [],
918
+ behaviours: [],
919
+ capabilities: Array.from(coveredCapabilities),
920
+ },
921
+ };
922
+ }
923
+
924
+ /**
925
+ * Derive Stakeholder Simulation interview questions (behaviour-focused)
926
+ *
927
+ * 60-minute interview with 3-4 stakeholders.
928
+ * Selects the highest-maturity behaviours for the role and picks one chunky
929
+ * simulation question per behaviour. For most jobs this means 2-3 behaviours
930
+ * with ~20-minute simulation scenarios each.
931
+ *
932
+ * @param {Object} params
933
+ * @param {import('./levels.js').JobDefinition} params.job - The job definition
934
+ * @param {import('./levels.js').QuestionBank} params.questionBank - The question bank
935
+ * @param {number} [params.targetMinutes=60] - Target interview length in minutes
936
+ * @param {string} [params.roleType='professionalQuestions'] - Role type ('professionalQuestions' or 'managementQuestions')
937
+ * @returns {import('./levels.js').InterviewGuide}
938
+ */
939
+ export function deriveStakeholderInterview({
940
+ job,
941
+ questionBank,
942
+ targetMinutes = 60,
943
+ roleType = "professionalQuestions",
944
+ }) {
945
+ const coveredBehaviours = new Set();
946
+
947
+ // Sort behaviours by maturity (highest first) to prioritize the most emphasized
948
+ const sortedBehaviours = [...job.behaviourProfile].sort(
949
+ compareByMaturityDesc,
950
+ );
951
+
952
+ // Select one question per behaviour, highest maturity first, within budget
953
+ const selectedQuestions = [];
954
+ let totalMinutes = 0;
955
+
956
+ for (const behaviour of sortedBehaviours) {
957
+ const targetMaturity = behaviour.maturity;
958
+
959
+ // Get questions at target maturity
960
+ const questions = getBehaviourQuestions(
961
+ questionBank,
962
+ behaviour.behaviourId,
963
+ targetMaturity,
964
+ roleType,
965
+ );
966
+
967
+ // Pick the first available question (1 per behaviour)
968
+ const question = questions[0];
969
+ if (!question) continue;
970
+
971
+ const questionTime =
972
+ question.expectedDurationMinutes || DEFAULT_SIMULATION_QUESTION_MINUTES;
973
+ if (
974
+ totalMinutes + questionTime >
975
+ targetMinutes + TOLERANCE_INTERVIEW_BUDGET_MINUTES
976
+ )
977
+ break;
978
+
979
+ selectedQuestions.push({
980
+ question,
981
+ targetId: behaviour.behaviourId,
982
+ targetName: behaviour.behaviourName,
983
+ targetType: "behaviour",
984
+ targetLevel: targetMaturity,
985
+ priority: calculateBehaviourPriority(behaviour),
986
+ });
987
+ coveredBehaviours.add(behaviour.behaviourId);
988
+ totalMinutes += questionTime;
989
+ }
990
+
991
+ return {
992
+ job,
993
+ questions: selectedQuestions,
994
+ expectedDurationMinutes: totalMinutes,
995
+ coverage: {
996
+ skills: [],
997
+ behaviours: Array.from(coveredBehaviours),
998
+ capabilities: [],
999
+ },
1000
+ };
1001
+ }