From 1d3383a9dc89e654400fa3dcf92736693e4eb16b Mon Sep 17 00:00:00 2001 From: John Angel Date: Mon, 9 Mar 2026 16:53:40 +0100 Subject: [PATCH 1/2] feat: Apply waiting time bonus to pupils --- common/match/matching.perf.ts | 32 +++++++++--------- common/match/matching.spec.ts | 64 ++++++++++++++++++++++------------- common/match/matching.ts | 6 ++-- 3 files changed, 60 insertions(+), 42 deletions(-) diff --git a/common/match/matching.perf.ts b/common/match/matching.perf.ts index 3dbd92132..0d456df86 100644 --- a/common/match/matching.perf.ts +++ b/common/match/matching.perf.ts @@ -146,33 +146,33 @@ describe('Real World Matching Performance', () => { 10, { matchCountSum: 1045, - matchingSubjectsAvg: 1.6794258373205742, + matchingSubjectsAvg: 1.6803827751196172, matchingSubjects: { '>= 1': 1045, - '>= 2': 555, - '>= 3': 119, + '>= 2': 538, + '>= 3': 136, '>= 4': 31, - '>= 5': 4, + '>= 5': 5, }, // matchingState: 0.11100478468899522, - pupilWaitingTimeAvg: 8.342127835891821, + pupilWaitingTimeAvg: 8.297880297592158, pupilWaitingTime: { '>= 0': 1045, - '>= 1': 959, - '>= 7': 429, - '>= 14': 72, + '>= 1': 962, + '>= 7': 439, + '>= 14': 82, '>= 21': 53, - '>= 28': 41, + '>= 28': 35, }, - studentWaitingTimeAvg: 53.936342931009634, + studentWaitingTimeAvg: 53.57448000132908, studentWaitingTime: { '>= 0': 1045, - '>= 1': 1034, - '>= 7': 901, - '>= 14': 776, - '>= 21': 715, - '>= 28': 654, - '>= 56': 465, + '>= 1': 1029, + '>= 7': 880, + '>= 14': 754, + '>= 21': 695, + '>= 28': 651, + '>= 56': 469, }, }, ], diff --git a/common/match/matching.spec.ts b/common/match/matching.spec.ts index a5e829c31..01ac15cd4 100644 --- a/common/match/matching.spec.ts +++ b/common/match/matching.spec.ts @@ -1,16 +1,19 @@ import { pupil_languages_enum, student_languages_enum } from '@prisma/client'; +import moment from 'moment'; import { computeMatchings, Matching, MatchOffer, MatchRequest, matchScore, NO_MATCH } from './matching'; +const TODAY_TEST_DATE = new Date('2026-02-01T00:00:00Z'); + function testScore(name: string, request: MatchRequest, offer: MatchOffer, expected: number) { it(name, () => { - const actual = matchScore(request, offer); + const actual = matchScore(request, offer, TODAY_TEST_DATE); expect(actual).toEqual(expected); }); } function test(name: string, requests: MatchRequest[], offers: MatchOffer[], expected: Matching, excludeMatchings: Set = new Set()) { it(name, () => { - const actual = computeMatchings(requests, offers, excludeMatchings, new Date(100000000000)); + const actual = computeMatchings(requests, offers, excludeMatchings, TODAY_TEST_DATE); expect(actual).toEqual(expected); }); } @@ -20,7 +23,7 @@ const requestOne = { pupilId: 1, state: 'at' as const, subjects: [{ name: 'Deutsch', mandatory: false }], - requestAt: new Date(0), + requestAt: TODAY_TEST_DATE, languages: [pupil_languages_enum.Englisch, pupil_languages_enum.Spanisch], }; @@ -29,7 +32,7 @@ const requestTwo = { pupilId: 2, state: 'at' as const, subjects: [{ name: 'Mathematik', mandatory: false }], - requestAt: new Date(0), + requestAt: TODAY_TEST_DATE, languages: [], }; @@ -38,7 +41,7 @@ const requestThree = { pupilId: 3, state: 'at' as const, subjects: [{ name: 'Klingonisch', mandatory: false }], - requestAt: new Date(0), + requestAt: TODAY_TEST_DATE, languages: [], }; @@ -50,7 +53,7 @@ const requestFour = { { name: 'Mathematik', mandatory: false }, { name: 'Deutsch', mandatory: false }, ], - requestAt: new Date(0), + requestAt: TODAY_TEST_DATE, languages: [], }; @@ -62,7 +65,7 @@ const requestFive = { { name: 'Mathematik', mandatory: true }, { name: 'Deutsch', mandatory: false }, ], - requestAt: new Date(0), + requestAt: TODAY_TEST_DATE, calendarPreferences: { weeklyAvailability: { monday: [{ from: 600, to: 660 }], @@ -82,7 +85,7 @@ const requestSix = { pupilId: 5, state: 'at' as const, subjects: [{ name: 'Mathematik' }, { name: 'Deutsch' }], - requestAt: new Date(0), + requestAt: TODAY_TEST_DATE, onlyMatchWith: 'female' as const, languages: [], }; @@ -92,7 +95,7 @@ const requestSeven = { pupilId: 5, state: 'at' as const, subjects: [{ name: 'Mathematik' }, { name: 'Deutsch' }], - requestAt: new Date(0), + requestAt: TODAY_TEST_DATE, hasSpecialNeeds: true, languages: [], }; @@ -102,7 +105,7 @@ const requestEight = { pupilId: 8, state: 'at' as const, subjects: [{ name: 'Mathematik' }, { name: 'Deutsch' }], - requestAt: new Date(0), + requestAt: TODAY_TEST_DATE, calendarPreferences: { weeklyAvailability: { monday: [ @@ -125,7 +128,7 @@ const offerOne = { studentId: 1, state: 'at' as const, subjects: [{ name: 'Deutsch', grade: { min: 1, max: 10 } }], - requestAt: new Date(0), + requestAt: TODAY_TEST_DATE, languages: [], }; @@ -133,7 +136,7 @@ const offerTwo = { studentId: 2, state: 'at' as const, subjects: [{ name: 'Mathematik', grade: { min: 1, max: 10 } }], - requestAt: new Date(0), + requestAt: TODAY_TEST_DATE, languages: [], }; @@ -141,7 +144,7 @@ const offerThree = { studentId: 3, state: 'at' as const, subjects: [{ name: 'Klingonisch', grade: { min: 1, max: 10 } }], - requestAt: new Date(0), + requestAt: TODAY_TEST_DATE, languages: [], }; @@ -152,7 +155,7 @@ const offerFour = { { name: 'Deutsch', grade: { min: 1, max: 10 } }, { name: 'Mathematik', mandatory: false, grade: { min: 1, max: 10 } }, ], - requestAt: new Date(0), + requestAt: TODAY_TEST_DATE, gender: 'male' as const, hasSpecialExperience: false, languages: [], @@ -165,7 +168,7 @@ const offerFive = { { name: 'Deutsch', grade: { min: 1, max: 10 } }, { name: 'Mathematik', mandatory: false, grade: { min: 1, max: 10 } }, ], - requestAt: new Date(0), + requestAt: TODAY_TEST_DATE, gender: 'female' as const, hasSpecialExperience: true, languages: [student_languages_enum.Deutsch, student_languages_enum.Spanisch], @@ -178,7 +181,7 @@ const offerSix = { { name: 'Deutsch', grade: { min: 1, max: 10 } }, { name: 'Mathematik', mandatory: false, grade: { min: 1, max: 10 } }, ], - requestAt: new Date(0), + requestAt: TODAY_TEST_DATE, calendarPreferences: { weeklyAvailability: { monday: [ @@ -199,18 +202,33 @@ const offerSix = { describe('Matching Score Basics', () => { testScore('no subject', requestOne, offerTwo, NO_MATCH); - testScore('one subject', requestOne, offerOne, 0.6000000000000001); - testScore('two subjects', requestFour, offerFour, 0.9046376623823058); - testScore('two requested one offered', requestFour, offerOne, 0.6000000000000001); - testScore('one requested two offered', requestOne, offerFour, 0.6000000000000001); + testScore('one subject', requestOne, offerOne, 0.3); + testScore('two subjects', requestFour, offerFour, 0.41423912339336466); + testScore('two requested one offered', requestFour, offerOne, 0.3); + testScore('one requested two offered', requestOne, offerFour, 0.3); // testScore('one requested two offered - different state', requestOne, offerFive, 0.495); - testScore('one requested two offered - share english', requestOne, offerSix, 0.625); - testScore('one requested two offered - share spanish', requestOne, offerFive, 0.65); + testScore('one requested two offered - share english', requestOne, offerSix, 0.35); + testScore('one requested two offered - share spanish', requestOne, offerFive, 0.4); }); describe('Matching Score Mandatory', () => { testScore('mandatory not offered', requestFive, offerOne, NO_MATCH); - testScore('mandatory offered', requestFive, offerTwo, 0.6000000000000001); + testScore('mandatory offered', requestFive, offerTwo, 0.3); +}); + +describe('Matching Score Waiting Bonus', () => { + testScore( + 'pupil waiting for less than 14 days', + { ...requestOne, requestAt: moment(TODAY_TEST_DATE).subtract(10, 'days').toDate() }, + offerOne, + 0.4499863806393892 + ); + testScore( + 'pupil waiting for more than 14 days', + { ...requestOne, requestAt: moment(TODAY_TEST_DATE).subtract(20, 'days').toDate() }, + offerOne, + 0.7499999987633078 + ); }); describe('Matching Basics', () => { diff --git a/common/match/matching.ts b/common/match/matching.ts index a01c9c451..9300680ee 100644 --- a/common/match/matching.ts +++ b/common/match/matching.ts @@ -202,11 +202,11 @@ export function matchScore(request: MatchRequest, offer: MatchOffer, currentDate } } - const offerWaitDays = (+currentDate - +offer.requestAt) / MS_PER_DAY; - const offerWaitingBonus = offerWaitDays > 20 ? sigmoid(offerWaitDays - 20) : 0; + const requestWaitDays = (+currentDate - +request.requestAt) / MS_PER_DAY; + const requestWaitingBonus = requestWaitDays >= 14 ? sigmoid(requestWaitDays) : sigmoid(requestWaitDays) * 0.5; // how good a match is in (0, 1) - const score = 0.8 * subjectBonus + 0.05 * languageBonus /* + 0.02 * stateBonus + */ + 0.2 * offerWaitingBonus; + const score = 0.3 * subjectBonus + 0.1 * languageBonus + 0.6 * requestWaitingBonus; // TODO: Fix retention for matches with only few subjects (e.g. both helper and helpee only have math as subject) // in that case the score is not so high, and thus they are retained for a long time, although the match is perfect From fd4db4f48c8835ab6154ce95b5f3d5106265b95c Mon Sep 17 00:00:00 2001 From: John Angel Date: Thu, 12 Mar 2026 17:39:24 +0100 Subject: [PATCH 2/2] feat: Update subject bonus calculation / update score results --- common/match/matching.perf.ts | 36 +++++++++++++++++------------------ common/match/matching.spec.ts | 8 ++++---- common/match/matching.ts | 2 +- 3 files changed, 23 insertions(+), 23 deletions(-) diff --git a/common/match/matching.perf.ts b/common/match/matching.perf.ts index 0d456df86..2edf98362 100644 --- a/common/match/matching.perf.ts +++ b/common/match/matching.perf.ts @@ -146,33 +146,33 @@ describe('Real World Matching Performance', () => { 10, { matchCountSum: 1045, - matchingSubjectsAvg: 1.6803827751196172, + matchingSubjectsAvg: 1.647846889952153, matchingSubjects: { '>= 1': 1045, - '>= 2': 538, - '>= 3': 136, - '>= 4': 31, - '>= 5': 5, + '>= 2': 479, + '>= 3': 145, + '>= 4': 37, + '>= 5': 13, }, // matchingState: 0.11100478468899522, - pupilWaitingTimeAvg: 8.297880297592158, + pupilWaitingTimeAvg: 7.9641828155568914, pupilWaitingTime: { '>= 0': 1045, - '>= 1': 962, - '>= 7': 439, - '>= 14': 82, - '>= 21': 53, - '>= 28': 35, + '>= 1': 960, + '>= 7': 430, + '>= 14': 71, + '>= 21': 45, + '>= 28': 30, }, - studentWaitingTimeAvg: 53.57448000132908, + studentWaitingTimeAvg: 46.19043382709773, studentWaitingTime: { '>= 0': 1045, - '>= 1': 1029, - '>= 7': 880, - '>= 14': 754, - '>= 21': 695, - '>= 28': 651, - '>= 56': 469, + '>= 1': 1017, + '>= 7': 832, + '>= 14': 683, + '>= 21': 577, + '>= 28': 500, + '>= 56': 305, }, }, ], diff --git a/common/match/matching.spec.ts b/common/match/matching.spec.ts index 01ac15cd4..5c1c9848d 100644 --- a/common/match/matching.spec.ts +++ b/common/match/matching.spec.ts @@ -202,8 +202,8 @@ const offerSix = { describe('Matching Score Basics', () => { testScore('no subject', requestOne, offerTwo, NO_MATCH); - testScore('one subject', requestOne, offerOne, 0.3); - testScore('two subjects', requestFour, offerFour, 0.41423912339336466); + testScore('one subject', requestOne, offerOne, 0.44999999999999996); + testScore('two subjects', requestFour, offerFour, 0.44999999999999996); testScore('two requested one offered', requestFour, offerOne, 0.3); testScore('one requested two offered', requestOne, offerFour, 0.3); // testScore('one requested two offered - different state', requestOne, offerFive, 0.495); @@ -221,13 +221,13 @@ describe('Matching Score Waiting Bonus', () => { 'pupil waiting for less than 14 days', { ...requestOne, requestAt: moment(TODAY_TEST_DATE).subtract(10, 'days').toDate() }, offerOne, - 0.4499863806393892 + 0.5999863806393892 ); testScore( 'pupil waiting for more than 14 days', { ...requestOne, requestAt: moment(TODAY_TEST_DATE).subtract(20, 'days').toDate() }, offerOne, - 0.7499999987633078 + 0.8999999987633078 ); }); diff --git a/common/match/matching.ts b/common/match/matching.ts index 9300680ee..6f2e36417 100644 --- a/common/match/matching.ts +++ b/common/match/matching.ts @@ -179,7 +179,7 @@ export function matchScore(request: MatchRequest, offer: MatchOffer, currentDate return NO_MATCH; } - const subjectBonus = sigmoid((matchingSubjects - 1) * 2); + const subjectBonus = matchingSubjects / Math.max(request.subjects.length, offer.subjects.length); // Add a small bonus if the state matches // As the probability of a state match is relatively high (about 1/16),