From 424bf761a449a587e892b68c5684a3d8fa01d3b6 Mon Sep 17 00:00:00 2001 From: GravityDarkLab Date: Thu, 18 Jun 2026 00:49:15 +0200 Subject: [PATCH 001/103] =?UTF-8?q?feat(matching):=20overhaul=20matching?= =?UTF-8?q?=20engine=20=E2=80=94=20age=20filtering,=20algorithm=20consolid?= =?UTF-8?q?ation,=20mutual=20identity=20reveal,=20orphan=20status=20cleanu?= =?UTF-8?q?p?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace algorithm registry with a single linear pipeline: orientation filter → age filter → embedding-cosine scorer. Deleted baseline.ts, cosine.ts, and trait.scorer.ts (dead code); renamed embedding-cosine.ts → scorer.ts. All weights centralised in matching/scoring/weights.ts. - Add age preference fields (max_age_gap, open_to_older, open_to_younger) to questionnaire v1.1.0, form validator, frontend Step4, and all four i18n locales. Checkboxes are conditionally rendered when max_age_gap > 0. - Implement isAgeCompatible() hard filter and ageModifier() soft-decay multiplier (cosine curve between max_gap and 2× max_gap); both are bidirectional (min of both parties' modifiers applied to final score). - Fix identity reveal privacy gap: requestContact() no longer exposes Instagram handles; respondToContact(accept=true) now reveals both Instagrams mutually, audit-logs both, and marks identityViewLoggedFor for both parties. - Add recalcOrphanedStatuses() in match-state.service.ts; called from deleteMyAccountNow() after match deletion so partners in dating/matched state are correctly recalculated instead of being stuck. - Enrich embedding text: profile includes work, preference includes dream_first_date (textVersion bumped to 2 for stale-detection; existing embeddings are automatically recomputed on next matching run). --- .../__tests__/routes/matching.routes.test.ts | 73 +++--- api/src/__tests__/unit/matching/_fixtures.ts | 4 +- .../unit/matching/age.filter.test.ts | 126 +++++++++++ .../__tests__/unit/matching/baseline.test.ts | 163 -------------- .../__tests__/unit/matching/cosine.test.ts | 142 ------------ .../unit/matching/embedding-cosine.test.ts | 42 ++-- .../__tests__/unit/matching/filters.test.ts | 2 +- .../unit/matching/trait.scorer.test.ts | 98 --------- .../__tests__/unit/profile/match-view.test.ts | 6 +- api/src/controllers/matching.controller.ts | 22 +- api/src/controllers/profile.controller.ts | 4 +- api/src/matching/algorithms/baseline.ts | 174 --------------- api/src/matching/algorithms/cosine.ts | 208 ------------------ .../matching/algorithms/embedding-cosine.ts | 178 --------------- api/src/matching/engine.ts | 111 +++------- api/src/matching/filters/age.filter.ts | 101 +++++++++ .../orientation.filter.ts} | 14 +- api/src/matching/scorer.ts | 163 ++++++++++++++ api/src/matching/scorers/trait.scorer.ts | 64 ------ api/src/matching/scoring/weights.ts | 10 + api/src/models/embedding.model.ts | 13 +- api/src/seeds/questionnaire.seed.ts | 43 +++- api/src/services/embedding.service.ts | 50 +++-- api/src/services/match-state.service.ts | 48 +++- api/src/services/profile.service.ts | 141 ++++++------ api/src/validators/admin.validator.ts | 2 +- api/src/validators/form.validator.ts | 6 + frontend/src/i18n/locales/ar.json | 4 + frontend/src/i18n/locales/de.json | 4 + frontend/src/i18n/locales/en.json | 4 + frontend/src/i18n/locales/fr.json | 4 + frontend/src/pages/Apply.tsx | 7 +- frontend/src/steps/Step4Preferences.tsx | 55 ++++- frontend/src/types/form.ts | 8 +- 34 files changed, 786 insertions(+), 1308 deletions(-) create mode 100644 api/src/__tests__/unit/matching/age.filter.test.ts delete mode 100644 api/src/__tests__/unit/matching/baseline.test.ts delete mode 100644 api/src/__tests__/unit/matching/cosine.test.ts delete mode 100644 api/src/__tests__/unit/matching/trait.scorer.test.ts delete mode 100644 api/src/matching/algorithms/baseline.ts delete mode 100644 api/src/matching/algorithms/cosine.ts delete mode 100644 api/src/matching/algorithms/embedding-cosine.ts create mode 100644 api/src/matching/filters/age.filter.ts rename api/src/matching/{filters.ts => filters/orientation.filter.ts} (80%) create mode 100644 api/src/matching/scorer.ts delete mode 100644 api/src/matching/scorers/trait.scorer.ts create mode 100644 api/src/matching/scoring/weights.ts diff --git a/api/src/__tests__/routes/matching.routes.test.ts b/api/src/__tests__/routes/matching.routes.test.ts index ec678d3..fef0e4f 100644 --- a/api/src/__tests__/routes/matching.routes.test.ts +++ b/api/src/__tests__/routes/matching.routes.test.ts @@ -20,24 +20,16 @@ const mockRunFullMatchingPass = mock(async () => ({} as Record) const mockSaveMatchProposals = mock(async (..._: any[]) => 0); const mockLoadActiveApplicants = mock(async () => [] as any[]); -// generateCoupleProposals (matching/proposals.js) is pure and DB-free, so it -// stays unmocked. Bun's mock.module is process-global: mocking it here would -// replace the shared export binding and poison the unit tests in full runs. mock.module("../../matching/engine.js", () => ({ - getCandidates: mockGetCandidates, - runFullMatchingPass: mockRunFullMatchingPass, - ALGORITHM_REGISTRY: {}, + getCandidates: mockGetCandidates, + runFullMatchingPass: mockRunFullMatchingPass, })); -// match.service is used by the matching controller to persist couple proposals. -// Mock it so tests never attempt a real MongoDB connection. mock.module("../../services/match.service.js", () => ({ - saveMatchProposals: mockSaveMatchProposals, - loadActiveApplicants: mockLoadActiveApplicants, + saveMatchProposals: mockSaveMatchProposals, + loadActiveApplicants: mockLoadActiveApplicants, })); -// match-state.service is used by the matching controller to promote applicants -// after a matching pass. Mock it so tests never attempt a real MongoDB connection. mock.module("../../services/match-state.service.js", () => ({ promoteAppliedToMatched: mock(async () => 0), })); @@ -115,23 +107,16 @@ describe("GET /matching/candidates/:applicantId", () => { expect(body.applicantId).toBe("64b1234567890abcdef01234"); }); - it("passes algorithm query param to getCandidates", async () => { - await get("/matching/candidates/abc123?algorithm=cosine&top=5", await adminToken()); - const [id, topN, algo] = mockGetCandidates.mock.calls[0] as unknown as [string, number, string]; + it("passes applicantId and topN to getCandidates", async () => { + await get("/matching/candidates/abc123?top=5", await adminToken()); + const [id, topN] = mockGetCandidates.mock.calls[0] as unknown as [string, number]; expect(id).toBe("abc123"); expect(topN).toBe(5); - expect(algo).toBe("cosine"); - }); - - it("uses baseline algorithm by default", async () => { - await get("/matching/candidates/abc123", await adminToken()); - const [, , algo] = mockGetCandidates.mock.calls[0] as unknown as [string, number, string]; - expect(algo).toBe("baseline"); }); it("caps top at 50 regardless of query param", async () => { await get("/matching/candidates/abc123?top=999", await adminToken()); - const [, topN] = mockGetCandidates.mock.calls[0] as unknown as [string, number, string]; + const [, topN] = mockGetCandidates.mock.calls[0] as unknown as [string, number]; expect(topN).toBe(50); }); @@ -155,8 +140,6 @@ describe("GET /matching/candidates/:applicantId", () => { expect(res.status).toBe(500); }); - // tested: candidates endpoint requires admin auth — compatibility data and - // paid embedding calls must not be reachable anonymously it("returns 401 without a token", async () => { const res = await get("/matching/candidates/64b1234567890abcdef01234"); expect(res.status).toBe(401); @@ -168,33 +151,37 @@ describe("GET /matching/candidates/:applicantId", () => { describe("POST /matching/run", () => { it("returns 401 without a token", async () => { - const res = await post("/matching/run", { algorithm: "baseline" }); + const res = await post("/matching/run", {}); expect(res.status).toBe(401); }); - it("returns 200 with results on a valid admin run (baseline)", async () => { + it("returns 200 with embedding-cosine algorithm", async () => { const token = await adminToken(); - const res = await post("/matching/run", { algorithm: "baseline" }, token); + const res = await post("/matching/run", {}, token); expect(res.status).toBe(200); const body = await res.json() as any; expect(body.success).toBe(true); - expect(body.algorithm).toBe("baseline"); + expect(body.algorithm).toBe("embedding-cosine"); expect(typeof body.totalApplicants).toBe("number"); expect(typeof body.durationMs).toBe("number"); }); - it("passes the algorithm to runFullMatchingPass", async () => { + it("accepts explicit embedding-cosine algorithm", async () => { const token = await adminToken(); - await post("/matching/run", { algorithm: "cosine" }, token); - const [algo] = mockRunFullMatchingPass.mock.calls[0] as unknown as [string]; - expect(algo).toBe("cosine"); + const res = await post("/matching/run", { algorithm: "embedding-cosine" }, token); + expect(res.status).toBe(200); }); - it("defaults to embedding-cosine when algorithm is omitted", async () => { + it("returns 422 for deprecated algorithm values", async () => { const token = await adminToken(); - await post("/matching/run", {}, token); - const [algo] = mockRunFullMatchingPass.mock.calls[0] as unknown as [string]; - expect(algo).toBe("embedding-cosine"); + const res = await post("/matching/run", { algorithm: "baseline" }, token); + expect(res.status).toBe(422); + }); + + it("returns 422 for cosine algorithm (removed)", async () => { + const token = await adminToken(); + const res = await post("/matching/run", { algorithm: "cosine" }, token); + expect(res.status).toBe(422); }); it("returns 422 for an unknown algorithm value", async () => { @@ -206,7 +193,7 @@ describe("POST /matching/run", () => { it("returns 404 when the engine throws", async () => { mockRunFullMatchingPass.mockRejectedValue(new AppError("No active questionnaire found", 404)); const token = await adminToken(); - const res = await post("/matching/run", { algorithm: "baseline" }, token); + const res = await post("/matching/run", {}, token); expect(res.status).toBe(404); const body = await res.json() as any; expect(body.success).toBe(false); @@ -220,7 +207,7 @@ describe("POST /matching/run", () => { id3: makeCandidates(1), }); const token = await adminToken(); - const res = await post("/matching/run", { algorithm: "baseline" }, token); + const res = await post("/matching/run", {}, token); const body = await res.json() as any; expect(body.totalApplicants).toBe(3); }); @@ -228,8 +215,6 @@ describe("POST /matching/run", () => { // ── GET /matching/last-run ──────────────────────────────────────────────────── -// tested: persisted last-run summary — auth gate, null when never run, -// stored value passthrough, and write-through on POST /matching/run describe("GET /matching/last-run", () => { it("returns 401 without a token", async () => { const res = await get("/matching/last-run"); @@ -260,13 +245,13 @@ describe("GET /matching/last-run", () => { expect(body.data).toEqual(stored); }); - it("POST /matching/run persists the last-run summary", async () => { + it("POST /matching/run persists the last-run summary with embedding-cosine", async () => { const token = await adminToken(); - await post("/matching/run", { algorithm: "baseline" }, token); + await post("/matching/run", {}, token); expect(mockSetConfig).toHaveBeenCalledTimes(1); const [key, value] = mockSetConfig.mock.calls[0] as unknown as [string, any]; expect(key).toBe("matching.lastRun"); - expect(value.algorithm).toBe("baseline"); + expect(value.algorithm).toBe("embedding-cosine"); expect(value.triggeredBy).toBe("admin"); expect(typeof value.durationMs).toBe("number"); }); diff --git a/api/src/__tests__/unit/matching/_fixtures.ts b/api/src/__tests__/unit/matching/_fixtures.ts index 010f1a5..bc47756 100644 --- a/api/src/__tests__/unit/matching/_fixtures.ts +++ b/api/src/__tests__/unit/matching/_fixtures.ts @@ -12,7 +12,7 @@ export function makeApplicant( return { _id: new ObjectId(), alias: "Test Alias", - questionnaireVersion: "1.0.0", + questionnaireVersion: "1.1.0", answers, status: "applied", magicToken: "a".repeat(64), @@ -27,7 +27,7 @@ export function makeApplicant( export function makeQuestionnaire(): QuestionnaireDoc { return { _id: new ObjectId(), - version: "1.0.0", + version: "1.1.0", isActive: true, questions: [], createdAt: new Date(), diff --git a/api/src/__tests__/unit/matching/age.filter.test.ts b/api/src/__tests__/unit/matching/age.filter.test.ts new file mode 100644 index 0000000..abd0949 --- /dev/null +++ b/api/src/__tests__/unit/matching/age.filter.test.ts @@ -0,0 +1,126 @@ +import { describe, it, expect } from "bun:test"; +import { isAgeCompatible, ageModifier } from "../../../matching/filters/age.filter.js"; +import type { ApplicantDoc } from "../../../models/applicant.model.js"; +import { ObjectId } from "mongodb"; + +function makeApplicant( + birthDate: string | undefined, + maxAgeGap: number | null | undefined, + openToOlder: boolean | null | undefined, + openToYounger: boolean | null | undefined +): ApplicantDoc { + return { + _id: new ObjectId(), + alias: "Test", + questionnaireVersion: "1.1.0", + answers: { + birth_date: birthDate, + max_age_gap: maxAgeGap, + open_to_older: openToOlder, + open_to_younger: openToYounger, + }, + status: "applied", + magicToken: "a".repeat(64), + passwordHash: "hash", + scoreThreshold: 0.8, + createdAt: new Date(), + updatedAt: new Date(), + }; +} + +// 30-year-old and 25-year-old: gap = 5 +const older = makeApplicant("1994-01-01", 5, true, true); +const younger = makeApplicant("1999-01-01", 5, true, true); + +describe("isAgeCompatible", () => { + it("passes when gap is within both parties' max_age_gap", () => { + expect(isAgeCompatible(older, younger)).toBe(true); + }); + + it("passes when both parties have max_age_gap = null (no preference)", () => { + const noPreferenceA = makeApplicant("1980-01-01", null, null, null); + const noPreferenceB = makeApplicant("1999-01-01", null, null, null); + expect(isAgeCompatible(noPreferenceA, noPreferenceB)).toBe(true); + }); + + it("passes when one party has max_age_gap = null (gap checked against the other's preference only)", () => { + const noPreference = makeApplicant("1994-01-01", null, null, null); + // younger has max_age_gap 5; gap = 0 (same year) → passes + const sameYear = makeApplicant("1994-06-01", 5, true, true); + expect(isAgeCompatible(noPreference, sameYear)).toBe(true); + }); + + it("fails when gap exceeds 2× max_age_gap (hard limit)", () => { + const strictOlder = makeApplicant("1994-01-01", 2, true, true); // gap 5 > 2×2=4 + expect(isAgeCompatible(strictOlder, younger)).toBe(false); + }); + + it("fails when open_to_older is false and partner is older", () => { + const noOlderPlease = makeApplicant("1999-01-01", 10, false, true); + expect(isAgeCompatible(noOlderPlease, older)).toBe(false); + }); + + it("fails when open_to_younger is false and partner is younger", () => { + const noYoungerPlease = makeApplicant("1994-01-01", 10, true, false); + expect(isAgeCompatible(noYoungerPlease, younger)).toBe(false); + }); + + it("passes when birth_date is missing on either side (skip filter)", () => { + const noBirthDate = makeApplicant(undefined, 5, true, true); + expect(isAgeCompatible(noBirthDate, younger)).toBe(true); + expect(isAgeCompatible(older, noBirthDate)).toBe(true); + }); + + it("passes exact boundary: gap equals max_age_gap", () => { + const exactFit = makeApplicant("1994-01-01", 5, true, true); // gap = 5 = max_gap + expect(isAgeCompatible(exactFit, younger)).toBe(true); + }); + + it("fails when gap > max_age_gap but <= 2× (in decay zone, but hard filter still passes)", () => { + const moderateGap = makeApplicant("1994-01-01", 3, true, true); // gap 5, max 3 → 5 <= 6 → should pass hard filter + expect(isAgeCompatible(moderateGap, younger)).toBe(true); // still within 2× limit + }); + + it("bidirectional: fails if EITHER party's constraints reject the pair", () => { + const strictYounger = makeApplicant("1999-01-01", 5, false, true); // open_to_older=false, partner IS older → fail + expect(isAgeCompatible(older, strictYounger)).toBe(false); + expect(isAgeCompatible(strictYounger, older)).toBe(false); + }); +}); + +describe("ageModifier", () => { + it("returns 1.0 when gap is within max_age_gap", () => { + expect(ageModifier(older, younger)).toBe(1.0); // gap 5, max 5 + }); + + it("returns 1.0 when max_age_gap is null (no preference)", () => { + const noPreference = makeApplicant("1994-01-01", null, null, null); + expect(ageModifier(noPreference, younger)).toBe(1.0); + }); + + it("returns 1.0 when birth_date is missing", () => { + const noBirthDate = makeApplicant(undefined, 5, true, true); + expect(ageModifier(noBirthDate, younger)).toBe(1.0); + }); + + it("returns value in (0, 1) for gap in soft-decay zone", () => { + // gap = 5, max = 3 → t = (5-3)/3 = 0.667 → cos(0.667 × π/2) ≈ 0.5 + const shortMax = makeApplicant("1994-01-01", 3, true, true); + const mod = ageModifier(shortMax, younger); + expect(mod).toBeGreaterThan(0); + expect(mod).toBeLessThan(1); + }); + + it("returns 0.0 when gap > 2× max_age_gap (hard outer limit)", () => { + const tooStrict = makeApplicant("1994-01-01", 2, true, true); // gap 5 > 2×2=4 + expect(ageModifier(tooStrict, younger)).toBe(0.0); + }); + + it("takes the minimum (stricter) of both parties' modifiers", () => { + const strictA = makeApplicant("1994-01-01", 3, true, true); // decay zone + const lenientB = makeApplicant("1999-01-01", null, true, true); // no preference = 1.0 + const mod = ageModifier(strictA, lenientB); + // strictA's modifier should be < 1; lenientB's is 1.0; result = min + expect(mod).toBeLessThan(1.0); + }); +}); diff --git a/api/src/__tests__/unit/matching/baseline.test.ts b/api/src/__tests__/unit/matching/baseline.test.ts deleted file mode 100644 index 660e8a7..0000000 --- a/api/src/__tests__/unit/matching/baseline.test.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { describe, it, expect } from "bun:test"; -import { baselineAlgorithm } from "../../../matching/algorithms/baseline.js"; -import { makeApplicant, makeQuestionnaire, FULL_ANSWERS } from "./_fixtures.js"; - -const q = makeQuestionnaire(); - -describe("baseline — relationship_type (30%)", () => { - it("exact match → 1.0", () => { - const a = makeApplicant({ relationship_type: "Long Term" }); - const b = makeApplicant({ relationship_type: "Long Term" }); - const { breakdown } = baselineAlgorithm.score(a, b, q); - expect(breakdown.relationship_type).toBe(1.0); - }); - - it("'Open to Both' on one side → 0.7", () => { - const a = makeApplicant({ relationship_type: "Long Term" }); - const b = makeApplicant({ relationship_type: "Open to Both" }); - const { breakdown } = baselineAlgorithm.score(a, b, q); - expect(breakdown.relationship_type).toBe(0.7); - }); - - it("mismatch (Long Term vs Short Term) → 0.0", () => { - const a = makeApplicant({ relationship_type: "Long Term" }); - const b = makeApplicant({ relationship_type: "Short Term" }); - const { breakdown } = baselineAlgorithm.score(a, b, q); - expect(breakdown.relationship_type).toBe(0.0); - }); - - it("missing relationship_type → 0.0", () => { - const a = makeApplicant({}); - const b = makeApplicant({}); - const { breakdown } = baselineAlgorithm.score(a, b, q); - expect(breakdown.relationship_type).toBe(0.0); - }); -}); - -describe("baseline — religion_compatibility (15%)", () => { - it("same religion → 1.0", () => { - const a = makeApplicant({ religion: "Islam", religion_deal_breaker: true }); - const b = makeApplicant({ religion: "Islam", religion_deal_breaker: true }); - expect(baselineAlgorithm.score(a, b, q).breakdown.religion_compatibility).toBe(1.0); - }); - - it("different religions, one flexible → 0.5", () => { - const a = makeApplicant({ religion: "Islam", religion_deal_breaker: false }); - const b = makeApplicant({ religion: "Christianity", religion_deal_breaker: true }); - expect(baselineAlgorithm.score(a, b, q).breakdown.religion_compatibility).toBe(0.5); - }); - - it("different religions, both deal breakers → 0.0", () => { - const a = makeApplicant({ religion: "Islam", religion_deal_breaker: true }); - const b = makeApplicant({ religion: "Christianity", religion_deal_breaker: true }); - expect(baselineAlgorithm.score(a, b, q).breakdown.religion_compatibility).toBe(0.0); - }); -}); - -describe("baseline — affection_importance (15%)", () => { - it("same value → 1.0", () => { - const a = makeApplicant({ physical_affection_importance: 7 }); - const b = makeApplicant({ physical_affection_importance: 7 }); - expect(baselineAlgorithm.score(a, b, q).breakdown.affection_importance).toBe(1.0); - }); - - it("max distance (1 vs 10) → score close to 0", () => { - const a = makeApplicant({ physical_affection_importance: 1 }); - const b = makeApplicant({ physical_affection_importance: 10 }); - const score = baselineAlgorithm.score(a, b, q).breakdown.affection_importance; - expect(score).toBeCloseTo(0, 1); - }); - - it("missing values → 0.5 (neutral)", () => { - const a = makeApplicant({}); - const b = makeApplicant({}); - expect(baselineAlgorithm.score(a, b, q).breakdown.affection_importance).toBe(0.5); - }); -}); - -describe("baseline — long_distance (10%)", () => { - it("both open → 1.0", () => { - const a = makeApplicant({ open_to_long_distance: true }); - const b = makeApplicant({ open_to_long_distance: true }); - expect(baselineAlgorithm.score(a, b, q).breakdown.long_distance).toBe(1.0); - }); - - it("one open → 0.5", () => { - const a = makeApplicant({ open_to_long_distance: true }); - const b = makeApplicant({ open_to_long_distance: false }); - expect(baselineAlgorithm.score(a, b, q).breakdown.long_distance).toBe(0.5); - }); - - it("both closed → 0.0", () => { - const a = makeApplicant({ open_to_long_distance: false }); - const b = makeApplicant({ open_to_long_distance: false }); - expect(baselineAlgorithm.score(a, b, q).breakdown.long_distance).toBe(0.0); - }); -}); - -describe("baseline — deal_breakers (20%)", () => { - it("deal breakers match the other's lifestyle → penalty applied (score < 1)", () => { - const a = makeApplicant({ deal_breakers: "smoking drugs", lifestyle: "hiking" }); - const b = makeApplicant({ deal_breakers: "", lifestyle: "smoking parties drugs" }); - const { breakdown } = baselineAlgorithm.score(a, b, q); - // A's deal breakers overlap with B's lifestyle → should penalise - expect(breakdown.deal_breakers).toBeLessThan(1.0); - }); - - it("no overlap between deal breakers and lifestyle → 1.0", () => { - const a = makeApplicant({ deal_breakers: "smoking", lifestyle: "hiking gym" }); - const b = makeApplicant({ deal_breakers: "gambling", lifestyle: "reading coffee" }); - expect(baselineAlgorithm.score(a, b, q).breakdown.deal_breakers).toBe(1.0); - }); -}); - -describe("baseline — composite score", () => { - it("identical applicants with all fields filled → score = 1.0", () => { - const a = makeApplicant(FULL_ANSWERS); - const b = makeApplicant(FULL_ANSWERS); - const { score } = baselineAlgorithm.score(a, b, q); - // Deal breakers overlap with own lifestyle might reduce slightly, but should be high - expect(score).toBeGreaterThan(0.8); - }); - - it("score is always in [0, 1]", () => { - const a = makeApplicant(FULL_ANSWERS); - const b = makeApplicant({ - relationship_type: "Short Term", - open_to_long_distance: false, - physical_affection_importance: 1, - religion: "Other", - religion_deal_breaker: true, - lifestyle: "parties clubbing", - deal_breakers: "gym fitness", - }); - const { score } = baselineAlgorithm.score(a, b, q); - expect(score).toBeGreaterThanOrEqual(0); - expect(score).toBeLessThanOrEqual(1); - }); - - it("score is rounded to 2 decimal places", () => { - const a = makeApplicant(FULL_ANSWERS); - const b = makeApplicant({ ...FULL_ANSWERS, physical_affection_importance: 5 }); - const { score } = baselineAlgorithm.score(a, b, q); - expect(score).toBe(Math.round(score * 100) / 100); - }); - - it("breakdown keys match the 6 expected dimensions", () => { - const { breakdown } = baselineAlgorithm.score( - makeApplicant(FULL_ANSWERS), - makeApplicant(FULL_ANSWERS), - q - ); - expect(Object.keys(breakdown)).toEqual( - expect.arrayContaining([ - "relationship_type", - "deal_breakers", - "religion_compatibility", - "affection_importance", - "long_distance", - "lifestyle", - ]) - ); - }); -}); diff --git a/api/src/__tests__/unit/matching/cosine.test.ts b/api/src/__tests__/unit/matching/cosine.test.ts deleted file mode 100644 index ebd25ff..0000000 --- a/api/src/__tests__/unit/matching/cosine.test.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { describe, it, expect } from "bun:test"; -import { cosineAlgorithm } from "../../../matching/algorithms/cosine.js"; -import { makeApplicant, makeQuestionnaire, FULL_ANSWERS } from "./_fixtures.js"; - -const q = makeQuestionnaire(); - -describe("cosine — identical applicants", () => { - it("returns score = 1.0 when both applicants have identical answers", () => { - const a = makeApplicant(FULL_ANSWERS); - const b = makeApplicant(FULL_ANSWERS); - expect(cosineAlgorithm.score(a, b, q).score).toBe(1.0); - }); - - it("numeric_compatibility = 1.0 for identical numeric fields", () => { - const a = makeApplicant(FULL_ANSWERS); - const b = makeApplicant(FULL_ANSWERS); - expect(cosineAlgorithm.score(a, b, q).breakdown.numeric_compatibility).toBe(1.0); - }); - - it("lifestyle_similarity = 1.0 for identical lifestyle", () => { - const a = makeApplicant(FULL_ANSWERS); - const b = makeApplicant(FULL_ANSWERS); - expect(cosineAlgorithm.score(a, b, q).breakdown.lifestyle_similarity).toBe(1.0); - }); - - it("character_cross_match = 1.0 when vibe perfectly matches preferences", () => { - const a = makeApplicant(FULL_ANSWERS); - const b = makeApplicant(FULL_ANSWERS); - expect(cosineAlgorithm.score(a, b, q).breakdown.character_cross_match).toBe(1.0); - }); -}); - -describe("cosine — numeric vector", () => { - it("'Long Term' + 'Long Term' → higher numeric score than 'Long Term' + 'Short Term'", () => { - const lt1 = makeApplicant({ relationship_type: "Long Term", open_to_long_distance: true, physical_affection_importance: 7, religion_deal_breaker: false }); - const lt2 = makeApplicant({ relationship_type: "Long Term", open_to_long_distance: true, physical_affection_importance: 7, religion_deal_breaker: false }); - const st = makeApplicant({ relationship_type: "Short Term", open_to_long_distance: false, physical_affection_importance: 3, religion_deal_breaker: true }); - - const sameScore = cosineAlgorithm.score(lt1, lt2, q).breakdown.numeric_compatibility; - const diffScore = cosineAlgorithm.score(lt1, st, q).breakdown.numeric_compatibility; - - expect(sameScore).toBeGreaterThan(diffScore); - }); - - it("'Open to Both' relationship type yields intermediate numeric similarity", () => { - const lt = makeApplicant({ relationship_type: "Long Term" }); - const both = makeApplicant({ relationship_type: "Open to Both" }); - const st = makeApplicant({ relationship_type: "Short Term" }); - - const ltVsBoth = cosineAlgorithm.score(lt, both, q).breakdown.numeric_compatibility; - const ltVsSt = cosineAlgorithm.score(lt, st, q).breakdown.numeric_compatibility; - const ltVsLt = cosineAlgorithm.score(lt, lt, q).breakdown.numeric_compatibility; - - expect(ltVsLt).toBeGreaterThanOrEqual(ltVsBoth); - expect(ltVsBoth).toBeGreaterThan(ltVsSt); - }); -}); - -describe("cosine — deal breaker penalty", () => { - it("when A's deal breakers appear in B's lifestyle → penalty reduces score below 1", () => { - const a = makeApplicant({ - ...FULL_ANSWERS, - deal_breakers: "smoking parties", - lifestyle: "gym hiking", - }); - const b = makeApplicant({ - ...FULL_ANSWERS, - lifestyle: "smoking parties clubbing", - deal_breakers: "", - }); - const { breakdown } = cosineAlgorithm.score(a, b, q); - expect(breakdown.deal_breaker_penalty).toBeLessThan(1.0); - }); - - it("no overlap between deal breakers and lifestyle → deal_breaker_penalty = 1.0", () => { - const a = makeApplicant({ deal_breakers: "smoking", lifestyle: "gym" }); - const b = makeApplicant({ deal_breakers: "parties", lifestyle: "reading" }); - expect(cosineAlgorithm.score(a, b, q).breakdown.deal_breaker_penalty).toBe(1.0); - }); -}); - -describe("cosine — edge cases", () => { - it("empty text fields → text components = 0", () => { - const a = makeApplicant({}); - const b = makeApplicant({}); - const { breakdown } = cosineAlgorithm.score(a, b, q); - expect(breakdown.lifestyle_similarity).toBe(0); - expect(breakdown.character_cross_match).toBe(0); - }); - - it("score is always in [0, 1]", () => { - const scenarios: [Record, Record][] = [ - [FULL_ANSWERS, {}], - [{}, {}], - [FULL_ANSWERS, { ...FULL_ANSWERS, relationship_type: "Short Term", lifestyle: "parties clubbing" }], - ]; - for (const [answersA, answersB] of scenarios) { - const { score } = cosineAlgorithm.score(makeApplicant(answersA), makeApplicant(answersB), q); - expect(score).toBeGreaterThanOrEqual(0); - expect(score).toBeLessThanOrEqual(1); - } - }); - - it("score is rounded to 2 decimal places", () => { - const { score } = cosineAlgorithm.score( - makeApplicant(FULL_ANSWERS), - makeApplicant({ ...FULL_ANSWERS, physical_affection_importance: 3 }), - q - ); - expect(score).toBe(Math.round(score * 100) / 100); - }); - - it("is symmetric — score(A, B) ≈ score(B, A)", () => { - const a = makeApplicant(FULL_ANSWERS); - const b = makeApplicant({ - relationship_type: "Short Term", - open_to_long_distance: false, - physical_affection_importance: 3, - lifestyle: "parties clubbing", - vibe_words: "spontaneous wild", - preferred_character_traits: "funny bold", - deal_breakers: "boring", - }); - const ab = cosineAlgorithm.score(a, b, q).score; - const ba = cosineAlgorithm.score(b, a, q).score; - expect(ab).toBeCloseTo(ba, 1); - }); - - it("breakdown contains expected keys", () => { - const { breakdown } = cosineAlgorithm.score(makeApplicant(FULL_ANSWERS), makeApplicant(FULL_ANSWERS), q); - expect(Object.keys(breakdown)).toEqual( - expect.arrayContaining([ - "numeric_compatibility", - "lifestyle_similarity", - "character_cross_match", - "character_a_wants_b", - "character_b_wants_a", - "deal_breaker_penalty", - ]) - ); - }); -}); diff --git a/api/src/__tests__/unit/matching/embedding-cosine.test.ts b/api/src/__tests__/unit/matching/embedding-cosine.test.ts index c830cf4..79dd6da 100644 --- a/api/src/__tests__/unit/matching/embedding-cosine.test.ts +++ b/api/src/__tests__/unit/matching/embedding-cosine.test.ts @@ -8,7 +8,7 @@ mock.module("../../../services/embedding.service.js", () => ({ getOrComputeEmbeddings: mock(async () => new Map()), })); -import { embeddingCosineAlgorithm } from "../../../matching/algorithms/embedding-cosine.js"; +import { prepare, score } from "../../../matching/scorer.js"; import { getOrComputeEmbeddings } from "../../../services/embedding.service.js"; import { makeApplicant, makeQuestionnaire, FULL_ANSWERS } from "./_fixtures.js"; import { ObjectId } from "mongodb"; @@ -37,6 +37,7 @@ async function prepareWithVecs( applicantId: a._id, provider: "test", model: "test-model", + textVersion: 2, createdAt: new Date(), ...vecFn(id), }, @@ -45,19 +46,19 @@ async function prepareWithVecs( ); (getOrComputeEmbeddings as ReturnType).mockImplementation(async () => embMap); - await embeddingCosineAlgorithm.prepare!(applicants, q); + await prepare(applicants, q); } -describe("embedding-cosine — score() without prepare()", () => { +describe("scorer — score() without prepare()", () => { it("throws when embeddings are not in cache", () => { const a = makeApplicant(FULL_ANSWERS); const b = makeApplicant(FULL_ANSWERS); // Don't call prepare() — cache should be empty after module load - expect(() => embeddingCosineAlgorithm.score(a, b, q)).toThrow(/prepare/i); + expect(() => score(a, b, q)).toThrow(/prepare/i); }); }); -describe("embedding-cosine — prepare() + score() pipeline", () => { +describe("scorer — prepare() + score() pipeline", () => { it("does not throw after prepare() is called", async () => { const a = makeApplicant(FULL_ANSWERS); const b = makeApplicant(FULL_ANSWERS); @@ -68,7 +69,7 @@ describe("embedding-cosine — prepare() + score() pipeline", () => { dealBreakers: [0.0, 0.0, 1.0], })); - expect(() => embeddingCosineAlgorithm.score(a, b, q)).not.toThrow(); + expect(() => score(a, b, q)).not.toThrow(); }); it("identical embedding vectors → score = 1.0", async () => { @@ -78,8 +79,8 @@ describe("embedding-cosine — prepare() + score() pipeline", () => { const sameVec = { profile: [1.0, 0.0], preference: [1.0, 0.0], dealBreakers: [0.0, 1.0] }; await prepareWithVecs([a, b], () => sameVec); - const { score } = embeddingCosineAlgorithm.score(a, b, q); - expect(score).toBe(1.0); + const { score: s } = score(a, b, q); + expect(s).toBe(1.0); }); it("orthogonal profile vectors → lifestyle_similarity = 0", async () => { @@ -93,7 +94,7 @@ describe("embedding-cosine — prepare() + score() pipeline", () => { dealBreakers: [0.0, 1.0], })); - const { breakdown } = embeddingCosineAlgorithm.score(a, b, q); + const { breakdown } = score(a, b, q); expect(breakdown.lifestyle_similarity).toBe(0); }); @@ -109,7 +110,7 @@ describe("embedding-cosine — prepare() + score() pipeline", () => { dealBreakers: id === ids[0] ? [1.0, 0.0] : [0.0, 1.0], // A's breaks = B's profile })); - const { breakdown } = embeddingCosineAlgorithm.score(a, b, q); + const { breakdown } = score(a, b, q); expect(breakdown.deal_breaker_penalty).toBeLessThan(1.0); }); @@ -126,9 +127,9 @@ describe("embedding-cosine — prepare() + score() pipeline", () => { }; }); - const { score } = embeddingCosineAlgorithm.score(a, b, q); - expect(score).toBeGreaterThanOrEqual(0); - expect(score).toBeLessThanOrEqual(1); + const { score: s } = score(a, b, q); + expect(s).toBeGreaterThanOrEqual(0); + expect(s).toBeLessThanOrEqual(1); }); it("score is rounded to 2 decimal places", async () => { @@ -140,11 +141,11 @@ describe("embedding-cosine — prepare() + score() pipeline", () => { dealBreakers: [0.0, 1.0], })); - const { score } = embeddingCosineAlgorithm.score(a, b, q); - expect(score).toBe(Math.round(score * 100) / 100); + const { score: s } = score(a, b, q); + expect(s).toBe(Math.round(s * 100) / 100); }); - it("breakdown contains the expected 6 keys", async () => { + it("breakdown contains the expected keys including age_modifier", async () => { const a = makeApplicant(FULL_ANSWERS); const b = makeApplicant(FULL_ANSWERS); await prepareWithVecs([a, b], () => ({ @@ -153,7 +154,7 @@ describe("embedding-cosine — prepare() + score() pipeline", () => { dealBreakers: [0.0, 1.0], })); - const { breakdown } = embeddingCosineAlgorithm.score(a, b, q); + const { breakdown } = score(a, b, q); expect(Object.keys(breakdown)).toEqual( expect.arrayContaining([ "numeric_compatibility", @@ -162,12 +163,13 @@ describe("embedding-cosine — prepare() + score() pipeline", () => { "character_a_wants_b", "character_b_wants_a", "deal_breaker_penalty", + "age_modifier", ]) ); }); }); -describe("embedding-cosine — prepare() internals", () => { +describe("scorer — prepare() internals", () => { it("calls getOrComputeEmbeddings with the given applicants", async () => { const a = makeApplicant(FULL_ANSWERS); const b = makeApplicant(FULL_ANSWERS); @@ -191,9 +193,9 @@ describe("embedding-cosine — prepare() internals", () => { // Return an empty map → no embeddings stored (getOrComputeEmbeddings as ReturnType).mockResolvedValueOnce(new Map()); - await embeddingCosineAlgorithm.prepare!([a, b], q); + await prepare([a, b], q); // Both applicants missing from cache → score() should throw - expect(() => embeddingCosineAlgorithm.score(a, b, q)).toThrow(); + expect(() => score(a, b, q)).toThrow(); }); }); diff --git a/api/src/__tests__/unit/matching/filters.test.ts b/api/src/__tests__/unit/matching/filters.test.ts index 4961552..b123091 100644 --- a/api/src/__tests__/unit/matching/filters.test.ts +++ b/api/src/__tests__/unit/matching/filters.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "bun:test"; -import { isOrientationCompatible, filterCandidates } from "../../../matching/filters.js"; +import { isOrientationCompatible, filterCandidates } from "../../../matching/filters/orientation.filter.js"; import type { ApplicantDoc } from "../../../models/applicant.model.js"; import { ObjectId } from "mongodb"; diff --git a/api/src/__tests__/unit/matching/trait.scorer.test.ts b/api/src/__tests__/unit/matching/trait.scorer.test.ts deleted file mode 100644 index 9c8300e..0000000 --- a/api/src/__tests__/unit/matching/trait.scorer.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { describe, it, expect } from "bun:test"; -import { scoreTraitOverlap, scorePreferenceMatch } from "../../../matching/scorers/trait.scorer.js"; - -describe("scoreTraitOverlap", () => { - it("returns 1.0 for identical strings", () => { - expect(scoreTraitOverlap("gym fitness coffee", "gym fitness coffee")).toBe(1.0); - }); - - it("returns 0.0 for completely disjoint strings", () => { - expect(scoreTraitOverlap("gym running", "reading books")).toBe(0.0); - }); - - it("returns 0.0 when either string is empty", () => { - expect(scoreTraitOverlap("", "gym")).toBe(0); - expect(scoreTraitOverlap("gym", "")).toBe(0); - expect(scoreTraitOverlap("", "")).toBe(0); - }); - - it("computes correct Jaccard: |intersection| / |union|", () => { - // A = {gym, coffee}, B = {gym, running} → intersection=1, union=3 - const score = scoreTraitOverlap("gym coffee", "gym running"); - expect(score).toBeCloseTo(1 / 3, 5); - }); - - it("is case-insensitive", () => { - expect(scoreTraitOverlap("Gym Coffee", "gym coffee")).toBe(1.0); - }); - - it("handles comma and semicolon delimiters", () => { - // "gym,coffee" and "gym coffee" should tokenize identically - expect(scoreTraitOverlap("gym,coffee", "gym coffee")).toBe(1.0); - }); - - it("ignores single-character tokens (noise)", () => { - // tokenizer filters tokens with length <= 1 - expect(scoreTraitOverlap("a b c gym", "a b c running")).toBeCloseTo(0.0, 5); - }); - - it("is symmetric", () => { - const a = "gym fitness hiking"; - const b = "fitness reading yoga"; - expect(scoreTraitOverlap(a, b)).toBeCloseTo(scoreTraitOverlap(b, a), 10); - }); - - it("returns a value in [0, 1] for any inputs", () => { - const cases = [ - ["", ""], - ["xyz", "xyz"], - ["abc def", "ghi jkl"], - ["shared token extra", "shared other"], - ]; - for (const [a, b] of cases) { - const score = scoreTraitOverlap(a, b); - expect(score).toBeGreaterThanOrEqual(0); - expect(score).toBeLessThanOrEqual(1); - } - }); -}); - -describe("scorePreferenceMatch", () => { - it("returns 1.0 when all preferences are found in traits", () => { - expect(scorePreferenceMatch("funny kind", "funny kind smart")).toBe(1.0); - }); - - it("returns 0.0 when no preferences are found in traits", () => { - expect(scorePreferenceMatch("funny kind", "smart driven ambitious")).toBe(0.0); - }); - - it("returns correct partial ratio — matched / total_prefs", () => { - // 1 out of 2 preferences matched - const score = scorePreferenceMatch("funny kind", "funny smart"); - expect(score).toBeCloseTo(0.5, 5); - }); - - it("returns 0 when preferences string is empty (early-exit guard)", () => { - // The function returns 0 on empty input via the !preferencesA guard. - // The "no prefs = satisfied" branch only fires when tokenization yields an empty set. - expect(scorePreferenceMatch("", "funny kind smart")).toBe(0); - }); - - it("returns 0.0 when traits string is empty", () => { - expect(scorePreferenceMatch("funny kind", "")).toBe(0.0); - }); - - it("returns 0.0 when both strings are empty", () => { - expect(scorePreferenceMatch("", "")).toBe(0.0); - }); - - it("is case-insensitive", () => { - expect(scorePreferenceMatch("Funny Kind", "funny kind")).toBe(1.0); - }); - - it("returns a value in [0, 1] for any inputs", () => { - const score = scorePreferenceMatch("ambitious driven", "funny relaxed"); - expect(score).toBeGreaterThanOrEqual(0); - expect(score).toBeLessThanOrEqual(1); - }); -}); diff --git a/api/src/__tests__/unit/profile/match-view.test.ts b/api/src/__tests__/unit/profile/match-view.test.ts index caa6b57..bbfcfcf 100644 --- a/api/src/__tests__/unit/profile/match-view.test.ts +++ b/api/src/__tests__/unit/profile/match-view.test.ts @@ -144,14 +144,14 @@ describe("toMatchView – partner profile", () => { }); describe("toMatchView – partner instagram", () => { - it("includes partnerInstagram for in_progress matches", () => { + it("never includes partnerInstagram for in_progress matches (mutual consent not yet given)", () => { const match = makeMatch({ status: "in_progress" }); match.initiatorId = match.applicantAId; const view = toMatchView(match, match.applicantBId, undefined, "horizon.swift"); - expect(view.partnerInstagram).toBe("horizon.swift"); + expect(view.partnerInstagram).toBeUndefined(); }); - it("includes partnerInstagram for dating matches", () => { + it("includes partnerInstagram for dating matches (mutual acceptance = both consented)", () => { const match = makeMatch({ status: "dating" }); const view = toMatchView(match, match.applicantAId, undefined, "horizon.swift"); expect(view.partnerInstagram).toBe("horizon.swift"); diff --git a/api/src/controllers/matching.controller.ts b/api/src/controllers/matching.controller.ts index 796f607..2e92dc3 100644 --- a/api/src/controllers/matching.controller.ts +++ b/api/src/controllers/matching.controller.ts @@ -6,8 +6,10 @@ import { promoteAppliedToMatched } from "../services/match-state.service.js"; import { getConfig, setConfig } from "../services/appConfig.service.js"; import { APP_CONFIG_KEYS, type MatchingLastRun } from "../models/appConfig.model.js"; import { errorResponse } from "../utils/error-response.js"; -import type { MatchingRunInput } from "../validators/admin.validator.js"; import type { ValidatedContext } from "../utils/validated-context.js"; +import type { MatchingRunInput } from "../validators/admin.validator.js"; + +const ALGORITHM = "embedding-cosine"; /** * GET /api/v1/matching/candidates/:applicantId @@ -17,10 +19,9 @@ export async function getMatchCandidates(c: Context): Promise { const applicantId = c.req.param("applicantId") ?? ""; const query = c.req.query(); const topN = Math.min(50, Math.max(1, parseInt(query.top ?? "10", 10))); - const algorithm = query.algorithm ?? "baseline"; try { - const candidates = await getCandidates(applicantId, topN, algorithm); + const candidates = await getCandidates(applicantId, topN); return c.json({ success: true, @@ -37,36 +38,29 @@ export async function getMatchCandidates(c: Context): Promise { * Admin triggers a full matching pass. */ export async function runMatching(c: ValidatedContext<{ json: MatchingRunInput }>): Promise { - const body = c.req.valid("json"); - const algorithm = body.algorithm ?? "baseline"; - try { const startTime = Date.now(); - const results = await runFullMatchingPass(algorithm); + const results = await runFullMatchingPass(); const durationMs = Date.now() - startTime; const totalApplicants = Object.keys(results).length; - // Generate couple proposals and persist them. - // Non-fatal: matching scores are still returned even if persistence fails. let couplesProposed = 0; try { if (totalApplicants >= 2) { const applicants = await loadActiveApplicants(); const proposals = generateCoupleProposals(applicants, results); - couplesProposed = await saveMatchProposals(proposals, algorithm); - // Applicants with portal-visible proposals can now see their matches + couplesProposed = await saveMatchProposals(proposals, ALGORITHM); await promoteAppliedToMatched(); } } catch (coupleErr) { console.error("[matching] Couple generation/save failed:", coupleErr); } - // Non-fatal: the run result is returned even if the timestamp write fails try { const lastRun: MatchingLastRun = { at: new Date(), - algorithm, + algorithm: ALGORITHM, totalApplicants, couplesProposed, durationMs, @@ -79,7 +73,7 @@ export async function runMatching(c: ValidatedContext<{ json: MatchingRunInput } return c.json({ success: true, - algorithm, + algorithm: ALGORITHM, totalApplicants, durationMs, couplesProposed, diff --git a/api/src/controllers/profile.controller.ts b/api/src/controllers/profile.controller.ts index dfc1338..0afb9a4 100644 --- a/api/src/controllers/profile.controller.ts +++ b/api/src/controllers/profile.controller.ts @@ -152,9 +152,11 @@ export async function respond(c: ValidatedContext<{ json: RespondInput }>): Prom const applicantId = c.get("applicantId") as string; const matchId = c.req.param("id") as string; const { accept } = c.req.valid("json"); + const ipAddress = c.req.header("X-Forwarded-For") ?? c.req.header("X-Real-IP") ?? "unknown"; + const userAgent = c.req.header("User-Agent") ?? "unknown"; try { - await respondToContact(applicantId, matchId, accept); + await respondToContact(applicantId, matchId, accept, { ipAddress, userAgent }); return c.json({ success: true }); } catch (err: unknown) { return errorResponse(c, err); diff --git a/api/src/matching/algorithms/baseline.ts b/api/src/matching/algorithms/baseline.ts deleted file mode 100644 index ebfc583..0000000 --- a/api/src/matching/algorithms/baseline.ts +++ /dev/null @@ -1,174 +0,0 @@ -import type { ApplicantDoc } from "../../models/applicant.model.js"; -import type { QuestionnaireDoc } from "../../models/questionnaire.model.js"; -import type { Algorithm, MatchScore } from "../engine.js"; -import { scoreTraitOverlap, scorePreferenceMatch } from "../scorers/trait.scorer.js"; - -/** - * Dimension weights — must sum to 1.0. - */ -const WEIGHTS = { - relationship_type: 0.30, - deal_breakers: 0.20, - religion_compatibility: 0.15, - affection_importance: 0.15, - long_distance: 0.10, - lifestyle: 0.10, -} as const; - -function getStr(answers: Record, key: string): string { - const val = answers[key]; - return typeof val === "string" ? val : ""; -} - -function getBool(answers: Record, key: string): boolean | null { - const val = answers[key]; - return typeof val === "boolean" ? val : null; -} - -function getNum(answers: Record, key: string): number | null { - const val = answers[key]; - return typeof val === "number" ? val : null; -} - -/** - * Scores relationship_type compatibility. - * Exact match = 1.0; "Open to Both" on either side = 0.7; mismatch = 0. - */ -function scoreRelationshipType( - answersA: Record, - answersB: Record -): number { - const typeA = getStr(answersA, "relationship_type"); - const typeB = getStr(answersB, "relationship_type"); - - if (!typeA || !typeB) return 0; - if (typeA === typeB) return 1.0; - if (typeA === "Open to Both" || typeB === "Open to Both") return 0.7; - return 0.0; -} - -/** - * Scores religion compatibility. - * Exact match = 1.0; if either has religion_deal_breaker = false, mismatch is 0.5. - * Otherwise mismatch = 0. - */ -function scoreReligionCompatibility( - answersA: Record, - answersB: Record -): number { - const religionA = getStr(answersA, "religion").toLowerCase(); - const religionB = getStr(answersB, "religion").toLowerCase(); - - if (religionA === religionB) return 1.0; - - const dealBreakerA = getBool(answersA, "religion_deal_breaker"); - const dealBreakerB = getBool(answersB, "religion_deal_breaker"); - - if (dealBreakerA === false || dealBreakerB === false) return 0.5; - if (dealBreakerA === true || dealBreakerB === true) return 0.0; - - return 0.3; // Unknown — small penalty -} - -/** - * Long distance compatibility. - * Both willing = 1.0; one willing = 0.5; neither = 0. - */ -function scoreLongDistance( - answersA: Record, - answersB: Record -): number { - const a = getBool(answersA, "open_to_long_distance"); - const b = getBool(answersB, "open_to_long_distance"); - - if (a === true && b === true) return 1.0; - if (a === true || b === true) return 0.5; - return 0.0; -} - -/** - * Deal breaker cross-check. - * Checks if either person's deal breakers overlap with the other's lifestyle/traits. - * Score = 1 - (overlap penalty). - */ -function scoreDealBreakers( - answersA: Record, - answersB: Record -): number { - const dealBreakersA = getStr(answersA, "deal_breakers"); - const lifestyleB = getStr(answersB, "lifestyle"); - const dealBreakersB = getStr(answersB, "deal_breakers"); - const lifestyleA = getStr(answersA, "lifestyle"); - - const overlapAB = scoreTraitOverlap(dealBreakersA, lifestyleB); - const overlapBA = scoreTraitOverlap(dealBreakersB, lifestyleA); - - // High overlap = bad (their deal breakers are present in the other's lifestyle) - const avgOverlap = (overlapAB + overlapBA) / 2; - return 1.0 - avgOverlap; -} - -/** - * Lifestyle compatibility via keyword overlap. - */ -function scoreLifestyle( - answersA: Record, - answersB: Record -): number { - const lifestyleA = getStr(answersA, "lifestyle"); - const lifestyleB = getStr(answersB, "lifestyle"); - return scoreTraitOverlap(lifestyleA, lifestyleB); -} - -/** - * Physical affection importance proximity. - * Score = 1 - |a - b| / 9 (max distance is 9 for scale 1-10). - */ -function scoreAffectionImportance( - answersA: Record, - answersB: Record -): number { - const a = getNum(answersA, "physical_affection_importance"); - const b = getNum(answersB, "physical_affection_importance"); - - if (a === null || b === null) return 0.5; // neutral if missing - return 1.0 - Math.abs(a - b) / 9; -} - -/** - * Baseline algorithm: weighted multi-dimensional compatibility score. - */ -export const baselineAlgorithm: Algorithm = { - name: "baseline", - - score( - a: ApplicantDoc, - b: ApplicantDoc, - _questionnaire: QuestionnaireDoc - ): MatchScore { - const answersA = a.answers; - const answersB = b.answers; - - const breakdown: Record = { - relationship_type: scoreRelationshipType(answersA, answersB), - deal_breakers: scoreDealBreakers(answersA, answersB), - religion_compatibility: scoreReligionCompatibility(answersA, answersB), - affection_importance: scoreAffectionImportance(answersA, answersB), - long_distance: scoreLongDistance(answersA, answersB), - lifestyle: scoreLifestyle(answersA, answersB), - }; - - const score = - breakdown.relationship_type * WEIGHTS.relationship_type + - breakdown.deal_breakers * WEIGHTS.deal_breakers + - breakdown.religion_compatibility * WEIGHTS.religion_compatibility + - breakdown.affection_importance * WEIGHTS.affection_importance + - breakdown.long_distance * WEIGHTS.long_distance + - breakdown.lifestyle * WEIGHTS.lifestyle; - - return { - score: Math.round(score * 100) / 100, - breakdown, - }; - }, -}; diff --git a/api/src/matching/algorithms/cosine.ts b/api/src/matching/algorithms/cosine.ts deleted file mode 100644 index 1f07c7b..0000000 --- a/api/src/matching/algorithms/cosine.ts +++ /dev/null @@ -1,208 +0,0 @@ -/** - * Cosine Similarity Matching Algorithm - * ===================================== - * - * ## Why cosine similarity? - * - * The baseline algorithm uses hand-coded thresholds and rules - * (e.g. "exact match = 1.0, partial = 0.7"). This works but is brittle — - * every new rule is a human judgement call, and dimensions are scored - * independently with no interaction between them. - * - * Cosine similarity treats each applicant as a point in a high-dimensional - * space and measures the *angle* between two points. A small angle means the - * two people are pointing in the same direction — structurally compatible. - * - * ## The math - * - * cos(A, B) = (A · B) / (‖A‖ · ‖B‖) - * - * Where: - * A · B = dot product (sum of element-wise products) - * ‖A‖ = L2 norm of A (sqrt of sum of squares) - * - * Result is in [0, 1] because all our feature values are non-negative. - * - * **Why cosine over Euclidean distance?** - * Cosine is magnitude-invariant. A person who wrote a long lifestyle - * description and one who wrote a short one can still score 1.0 if they - * mention the same proportional mix of keywords. Euclidean distance would - * penalise the length difference unfairly. - * - * ## Feature decomposition - * - * Each applicant is described by two vectors: - * - * profile_vector — who the person IS - * preference_vector — who the person WANTS - * - * Compatibility between A and B is broken into four components: - * - * 1. NUMERIC (25%) - * cosine(A.numeric_profile, B.numeric_profile) - * Encodes relationship type, long-distance, affection importance, - * and religion openness as a single numeric vector. - * Answers: "are they looking for structurally the same thing?" - * - * 2. LIFESTYLE (20%) - * cosine(bag_of_words(A.lifestyle), bag_of_words(B.lifestyle)) - * Answers: "do they live in a compatible way?" - * - * 3. CHARACTER CROSS-MATCH (35%, split 17.5% each direction) - * cosine(bag_of_words(A.preferred_character_traits), bag_of_words(B.vibe_words)) - * cosine(bag_of_words(B.preferred_character_traits), bag_of_words(A.vibe_words)) - * Answers: "does B's self-description match what A explicitly said they want?" - * This is asymmetric and bidirectional — scored in both directions. - * - * 4. DEAL BREAKER PENALTY (20%) - * 1 - Jaccard(A.deal_breakers, B.lifestyle) (bidirectional average) - * Answers: "does B's lifestyle contain things A listed as deal breakers?" - * Still uses Jaccard keyword overlap — compatible with future NLP upgrade. - * - * ## Known limitation — keyword matching - * - * Text fields use bag-of-words (binary term frequency). "driven" and "ambitious" - * score 0 even though they're synonyms. To fix this without changing the algorithm - * structure, replace `textCosine()` with an embedding-based cosine: - * - * // Instead of bag-of-words: - * const vecA = await getEmbedding(textA); // float[1536] from OpenAI/Claude - * const vecB = await getEmbedding(textB); - * return cosine(vecA, vecB); - * - * The rest of the algorithm is identical — only the text component changes. - * See the `ai` algorithm (if implemented) for the embedding-based version. - * - * ## Weights - * - * numeric 0.25 - * lifestyle 0.20 - * character_cross_match 0.35 (0.175 A→B + 0.175 B→A) - * deal_breakers 0.20 - * ───────────────────────── - * total 1.00 - */ - -import type { ApplicantDoc } from "../../models/applicant.model.js"; -import type { QuestionnaireDoc } from "../../models/questionnaire.model.js"; -import type { Algorithm, MatchScore } from "../engine.js"; -import { scoreTraitOverlap } from "../scorers/trait.scorer.js"; -import { str, buildNumericVector, cosine, round } from "../scorers/numeric.scorer.js"; - -// ─── Weights ────────────────────────────────────────────────────────────────── - -const WEIGHTS = { - numeric: 0.25, - lifestyle: 0.20, - character_cross_match: 0.35, // applied as 0.175 per direction - deal_breakers: 0.20, -} as const; - -// ─── Bag-of-words cosine ────────────────────────────────────────────────────── -// -// Builds a shared vocabulary from both strings (their union), then represents -// each as a binary vector and computes cosine similarity. -// -// Binary (0/1) rather than raw term frequency because most fields are short -// free-text answers where repetition doesn't carry extra signal. - -function tokenize(text: string): string[] { - return text - .toLowerCase() - .split(/[\s,;/|]+/) - .map((t) => t.trim()) - .filter((t) => t.length > 1); -} - -function textCosine(textA: string, textB: string): number { - if (!textA || !textB) return 0; - - const tokensA = tokenize(textA); - const tokensB = tokenize(textB); - if (tokensA.length === 0 || tokensB.length === 0) return 0; - - // Union vocabulary — the shared dimensional space for this pair - const vocab = Array.from(new Set([...tokensA, ...tokensB])); - - const setA = new Set(tokensA); - const setB = new Set(tokensB); - - // Binary term vectors - const vecA = vocab.map((t) => (setA.has(t) ? 1 : 0)); - const vecB = vocab.map((t) => (setB.has(t) ? 1 : 0)); - - return cosine(vecA, vecB); -} - -// ─── Algorithm ──────────────────────────────────────────────────────────────── - -export const cosineAlgorithm: Algorithm = { - name: "cosine", - - score( - a: ApplicantDoc, - b: ApplicantDoc, - _questionnaire: QuestionnaireDoc - ): MatchScore { - const aa = a.answers; - const ba = b.answers; - - // 1. Numeric compatibility - // Compares structural preferences (relationship type, long distance, etc.) - const numericScore = cosine( - buildNumericVector(aa), - buildNumericVector(ba) - ); - - // 2. Lifestyle similarity - // "Non-smoker, gym, coffee" vs "Non-smoker, hiking, coffee" → shares 2/4 tokens - const lifestyleScore = textCosine( - str(aa, "lifestyle"), - str(ba, "lifestyle") - ); - - // 3. Character cross-match (bidirectional) - // Does B's self-described vibe match what A is explicitly looking for? - // And vice versa. Scored in both directions and averaged. - const crossAtoB = textCosine( - str(aa, "preferred_character_traits"), // what A wants - str(ba, "vibe_words") // who B is - ); - const crossBtoA = textCosine( - str(ba, "preferred_character_traits"), // what B wants - str(aa, "vibe_words") // who A is - ); - const characterScore = (crossAtoB + crossBtoA) / 2; - - // 4. Deal breaker penalty - // High overlap between A's deal breakers and B's lifestyle = bad. - // Kept as Jaccard (not cosine) because deal breakers are inherently - // asymmetric — they describe what to avoid, not what to seek. - const dealBreakerOverlapAB = scoreTraitOverlap( - str(aa, "deal_breakers"), - str(ba, "lifestyle") - ); - const dealBreakerOverlapBA = scoreTraitOverlap( - str(ba, "deal_breakers"), - str(aa, "lifestyle") - ); - const dealBreakerScore = 1 - (dealBreakerOverlapAB + dealBreakerOverlapBA) / 2; - - const breakdown: Record = { - numeric_compatibility: round(numericScore), - lifestyle_similarity: round(lifestyleScore), - character_cross_match: round(characterScore), - character_a_wants_b: round(crossAtoB), - character_b_wants_a: round(crossBtoA), - deal_breaker_penalty: round(dealBreakerScore), - }; - - const score = - WEIGHTS.numeric * numericScore + - WEIGHTS.lifestyle * lifestyleScore + - WEIGHTS.character_cross_match * characterScore + - WEIGHTS.deal_breakers * dealBreakerScore; - - return { score: round(score), breakdown }; - }, -}; diff --git a/api/src/matching/algorithms/embedding-cosine.ts b/api/src/matching/algorithms/embedding-cosine.ts deleted file mode 100644 index 61ea1dd..0000000 --- a/api/src/matching/algorithms/embedding-cosine.ts +++ /dev/null @@ -1,178 +0,0 @@ -/** - * Embedding-based Cosine Similarity Algorithm - * ============================================= - * - * Upgrade over the `cosine` algorithm: replaces bag-of-words text matching - * with dense vector embeddings, so "driven" and "ambitious" score high - * because they're semantically close in embedding space — not just lexically. - * - * ## How it differs from `cosine` - * - * cosine (bag-of-words) embedding-cosine - * ───────────────────── ──────────────── - * "driven" vs "ambitious" → 0 → ~0.85 (semantically close) - * "funny" vs "humorous" → 0 → ~0.91 - * "gym" vs "fitness" → 0 → ~0.87 - * keyword overlap only true semantic similarity - * - * ## Scoring components (same structure as `cosine`, better text) - * - * 1. NUMERIC (25%) - * cosine(A.numeric_vec, B.numeric_vec) - * Same as `cosine` algorithm — structural preferences as a fixed vector. - * No embeddings here; this is exact numeric compatibility. - * - * 2. LIFESTYLE SIMILARITY (20%) - * cosine(embed(A.lifestyle), embed(B.lifestyle)) - * Semantic similarity of how they live. - * - * 3. CHARACTER CROSS-MATCH (35%, 17.5% per direction) - * cosine(embed(A.preferred_character_traits), embed(B.vibe_words)) - * cosine(embed(B.preferred_character_traits), embed(A.vibe_words)) - * "Does B's self-described vibe match what A is looking for?" - * Bidirectional — scored both ways and averaged. - * - * 4. DEAL BREAKER PENALTY (20%) - * 1 - cosine(embed(A.deal_breakers), embed(B.lifestyle)) (averaged both ways) - * High similarity = B's lifestyle matches A's deal breakers = bad. - * - * ## Prepare step (critical for performance) - * - * Embeddings are expensive (API call per text). The engine calls `prepare()` - * once before any scoring begins. This step batch-embeds all applicants' - * text fields in O(N) API calls, caching the results by applicant ID. - * - * Without caching: 50 applicants × 49 pairs × 4 text fields = 9800 API calls. - * With prepare(): 50 applicants × 4 text fields = 200 embeddings (3 batches). - * - * ## Text fields embedded - * - * profile_text = lifestyle + " " + vibe_words - * preference_text = preferred_character_traits + " " + preferred_physical_traits - * deal_breakers_text = deal_breakers - */ - -import type { ApplicantDoc } from "../../models/applicant.model.js"; -import type { QuestionnaireDoc } from "../../models/questionnaire.model.js"; -import type { Algorithm, MatchScore } from "../engine.js"; -import { getEmbeddingProvider } from "../embeddings/provider.js"; -import { getOrComputeEmbeddings } from "../../services/embedding.service.js"; -import { buildNumericVector, cosine, round } from "../scorers/numeric.scorer.js"; - -// ─── Weights ────────────────────────────────────────────────────────────────── - -const WEIGHTS = { - numeric: 0.25, - lifestyle: 0.20, - character_cross_match: 0.35, // 0.175 per direction - deal_breakers: 0.20, -} as const; - -// ─── Per-applicant embedding cache ─────────────────────────────────────────── -// -// Populated by prepare() before scoring begins. -// Keyed by applicant ObjectId hex string. - -interface CachedEmbeddings { - profile: number[]; // lifestyle + vibe_words - preference: number[]; // preferred_character + preferred_physical - dealBreakers: number[]; // deal_breakers -} - -const cache = new Map(); - -// ─── Algorithm ──────────────────────────────────────────────────────────────── - -export const embeddingCosineAlgorithm: Algorithm = { - name: "embedding-cosine", - - /** - * Called once by the engine before any pairwise scoring. - * - * Loads pre-computed embeddings from the DB (written at form submission time). - * Only calls the embedding API for applicants whose embeddings are missing - * or stale (model changed). In steady state this makes zero API calls. - */ - async prepare( - applicants: ApplicantDoc[], - _questionnaire: QuestionnaireDoc - ): Promise { - cache.clear(); - - const provider = getEmbeddingProvider(); - console.log( - `[embedding-cosine] Loading embeddings for ${applicants.length} applicants ` + - `(provider: ${provider.name}, model: ${provider.model})...` - ); - - const stored = await getOrComputeEmbeddings(applicants); - - for (const applicant of applicants) { - const emb = stored.get(applicant._id.toHexString()); - if (emb) { - cache.set(applicant._id.toHexString(), { - profile: emb.profile, - preference: emb.preference, - dealBreakers: emb.dealBreakers, - }); - } - } - - console.log(`[embedding-cosine] Ready — ${cache.size}/${applicants.length} embeddings loaded.`); - }, - - score( - a: ApplicantDoc, - b: ApplicantDoc, - _questionnaire: QuestionnaireDoc - ): MatchScore { - const embA = cache.get(a._id.toHexString()); - const embB = cache.get(b._id.toHexString()); - - if (!embA || !embB) { - throw new Error( - "[embedding-cosine] Embeddings not found in cache. " + - "The engine must call prepare() before score()." - ); - } - - // 1. Numeric compatibility (no embeddings — exact structural match) - const numericScore = cosine( - buildNumericVector(a.answers), - buildNumericVector(b.answers) - ); - - // 2. Lifestyle semantic similarity - const lifestyleScore = cosine(embA.profile, embB.profile); - - // 3. Character cross-match (bidirectional) - // A wants B: does B's vibe match what A explicitly said they want? - // B wants A: does A's vibe match what B explicitly said they want? - const crossAtoB = cosine(embA.preference, embB.profile); - const crossBtoA = cosine(embB.preference, embA.profile); - const characterScore = (crossAtoB + crossBtoA) / 2; - - // 4. Deal breaker semantic penalty - // High similarity between A's deal breakers and B's lifestyle = bad. - const penaltyAtoB = cosine(embA.dealBreakers, embB.profile); - const penaltyBtoA = cosine(embB.dealBreakers, embA.profile); - const dealBreakerScore = 1 - (penaltyAtoB + penaltyBtoA) / 2; - - const breakdown: Record = { - numeric_compatibility: round(numericScore), - lifestyle_similarity: round(lifestyleScore), - character_cross_match: round(characterScore), - character_a_wants_b: round(crossAtoB), - character_b_wants_a: round(crossBtoA), - deal_breaker_penalty: round(dealBreakerScore), - }; - - const score = - WEIGHTS.numeric * numericScore + - WEIGHTS.lifestyle * lifestyleScore + - WEIGHTS.character_cross_match * characterScore + - WEIGHTS.deal_breakers * dealBreakerScore; - - return { score: round(score), breakdown }; - }, -}; diff --git a/api/src/matching/engine.ts b/api/src/matching/engine.ts index 520d8e0..5824052 100644 --- a/api/src/matching/engine.ts +++ b/api/src/matching/engine.ts @@ -5,10 +5,9 @@ import type { QuestionnaireDoc } from "../models/questionnaire.model.js"; import { getDb } from "../db/connection.js"; import { getApplicantsCollection, getMatchesCollection } from "../db/collections.js"; import { getActiveQuestionnaire } from "../services/questionnaire.service.js"; -import { baselineAlgorithm } from "./algorithms/baseline.js"; -import { cosineAlgorithm } from "./algorithms/cosine.js"; -import { embeddingCosineAlgorithm } from "./algorithms/embedding-cosine.js"; -import { filterCandidates } from "./filters.js"; +import { isOrientationCompatible } from "./filters/orientation.filter.js"; +import { isAgeCompatible } from "./filters/age.filter.js"; +import { prepare, score } from "./scorer.js"; export interface MatchScore { score: number; @@ -22,34 +21,6 @@ export interface RankedCandidate { breakdown: Record; } -/** - * Plugin interface that all matching algorithms must implement. - * - * Optional prepare() hook: - * Called once by the engine before any pairwise scoring begins. - * Use it for expensive one-time setup — e.g. batch-embedding all applicants - * so that score() can run synchronously from a warm cache. - * If prepare() is absent the engine skips it (baseline/cosine don't need it). - */ -export interface Algorithm { - name: string; - prepare?: ( - applicants: ApplicantDoc[], - questionnaire: QuestionnaireDoc - ) => Promise; - score( - a: ApplicantDoc, - b: ApplicantDoc, - questionnaire: QuestionnaireDoc - ): MatchScore; -} - -const ALGORITHM_REGISTRY: Record = { - "baseline": baselineAlgorithm, - "cosine": cosineAlgorithm, - "embedding-cosine": embeddingCosineAlgorithm, -}; - /** * Returns the hex IDs of every applicant currently in an `in_progress` match * (an exclusive contact awaiting a response). They're mid-conversation and @@ -71,19 +42,19 @@ export async function getActiveContactApplicantIds(): Promise> { return ids; } +function applyFilters(target: ApplicantDoc, candidates: ApplicantDoc[]): ApplicantDoc[] { + return candidates.filter( + (c) => isOrientationCompatible(target, c) && isAgeCompatible(target, c) + ); +} + /** * Returns the top N candidates scored against the given applicant. */ export async function getCandidates( applicantId: string, topN = 10, - algorithmName = "embedding-cosine" ): Promise { - const algorithm = ALGORITHM_REGISTRY[algorithmName]; - if (!algorithm) { - throw new AppError(`Unknown algorithm: ${algorithmName}`, 400); - } - const questionnaire = await getActiveQuestionnaire(); if (!questionnaire) { throw new AppError("No active questionnaire found", 404); @@ -104,54 +75,38 @@ export async function getCandidates( throw new AppError(`Active applicant not found: ${applicantId}`, 404); } - // Applicants mid-contact (in_progress) are not eligible for new suggestions const activeContactIds = await getActiveContactApplicantIds(); if (activeContactIds.has(targetId.toHexString())) { return []; } - // Load all other active applicants const others = await col .find({ _id: { $ne: targetId }, status: { $in: ["applied", "matched"] } }) .toArray(); - const eligibleOthers = others.filter((o) => !activeContactIds.has(o._id.toHexString())); + const eligible = others.filter((o) => !activeContactIds.has(o._id.toHexString())); + const compatible = applyFilters(target, eligible); - // Hard filters — remove incompatible candidates before scoring - const compatible = filterCandidates(target, eligibleOthers); - - // Allow the algorithm to pre-compute anything it needs (e.g. embeddings) - if (algorithm.prepare) { - await algorithm.prepare([target, ...compatible], questionnaire); - } + await prepare([target, ...compatible], questionnaire); - // Score pairwise const scored: RankedCandidate[] = compatible.map((other) => { - const result = algorithm.score(target, other, questionnaire); + const result = score(target, other, questionnaire); return { - alias: other.alias, + alias: other.alias, applicantId: other._id.toHexString(), - score: result.score, - breakdown: result.breakdown, + score: result.score, + breakdown: result.breakdown, }; }); - // Sort descending by score, return top N return scored.sort((a, b) => b.score - a.score).slice(0, topN); } /** * Runs a full pairwise matching pass over all active applicants. - * Returns a map of applicantId -> ranked candidates. + * Returns a map of applicantId → ranked candidates. */ -export async function runFullMatchingPass( - algorithmName = "embedding-cosine" -): Promise> { - const algorithm = ALGORITHM_REGISTRY[algorithmName]; - if (!algorithm) { - throw new AppError(`Unknown algorithm: ${algorithmName}`, 400); - } - +export async function runFullMatchingPass(): Promise> { const questionnaire = await getActiveQuestionnaire(); if (!questionnaire) { throw new AppError("No active questionnaire found", 404); @@ -165,8 +120,6 @@ export async function runFullMatchingPass( return {}; } - // Applicants mid-contact (in_progress) sit out this pass entirely — they're - // not offered as candidates and don't receive new proposals themselves. const activeContactIds = await getActiveContactApplicantIds(); const eligible = applicants.filter((a) => !activeContactIds.has(a._id.toHexString())); @@ -174,26 +127,23 @@ export async function runFullMatchingPass( return {}; } - // Allow the algorithm to pre-compute anything it needs (e.g. embeddings) - if (algorithm.prepare) { - await algorithm.prepare(eligible, questionnaire); - } + await prepare(eligible, questionnaire); const results: Record = {}; for (const applicant of eligible) { - const scored: RankedCandidate[] = []; - const compatible = filterCandidates(applicant, eligible.filter((o) => !o._id.equals(applicant._id))); + const others = eligible.filter((o) => !o._id.equals(applicant._id)); + const compatible = applyFilters(applicant, others); - for (const other of compatible) { - const result = algorithm.score(applicant, other, questionnaire); - scored.push({ - alias: other.alias, + const scored: RankedCandidate[] = compatible.map((other) => { + const result = score(applicant, other, questionnaire); + return { + alias: other.alias, applicantId: other._id.toHexString(), - score: result.score, - breakdown: result.breakdown, - }); - } + score: result.score, + breakdown: result.breakdown, + }; + }); results[applicant._id.toHexString()] = scored .sort((a, b) => b.score - a.score) @@ -203,4 +153,5 @@ export async function runFullMatchingPass( return results; } -export { ALGORITHM_REGISTRY }; +// Re-export types needed by other modules +export type { ApplicantDoc, QuestionnaireDoc }; diff --git a/api/src/matching/filters/age.filter.ts b/api/src/matching/filters/age.filter.ts new file mode 100644 index 0000000..c422157 --- /dev/null +++ b/api/src/matching/filters/age.filter.ts @@ -0,0 +1,101 @@ +/** + * Age preference filter and scoring modifier. + * + * Filter: isAgeCompatible() — hard binary check, called before scoring. + * - Directional checks: open_to_older / open_to_younger flags + * - Hard outer limit: gap > 2 × max_age_gap → reject + * - max_age_gap = null → skip filter entirely (no preference) + * - Missing birth_date → skip filter for that pair + * + * Modifier: ageModifier() — multiplied onto the final compatibility score. + * - Within max_age_gap: modifier = 1.0 (no penalty) + * - Between max_gap and 2× max_gap: cosine decay toward 0 + * - Beyond 2× max_gap: should be filtered out before reaching here + * - Bidirectional: min(A's modifier, B's modifier) + */ + +import type { ApplicantDoc } from "../../models/applicant.model.js"; +import { ageFromBirthDate } from "../../utils/age.js"; + +function num(answers: Record, key: string): number | null { + const v = answers[key]; + return typeof v === "number" ? v : null; +} + +function bool(answers: Record, key: string): boolean | null { + const v = answers[key]; + return typeof v === "boolean" ? v : null; +} + +/** + * Returns true if the pair satisfies both applicants' age preferences. + * A return of false means the pair should be discarded before scoring. + */ +export function isAgeCompatible(a: ApplicantDoc, b: ApplicantDoc): boolean { + const ageA = ageFromBirthDate(a.answers["birth_date"]); + const ageB = ageFromBirthDate(b.answers["birth_date"]); + + // Missing birth_date on either side → skip filter + if (ageA === null || ageB === null) return true; + + const gap = Math.abs(ageA - ageB); + + return ( + _passesConstraints(a.answers, ageA, ageB, gap) && + _passesConstraints(b.answers, ageB, ageA, gap) + ); +} + +function _passesConstraints( + answers: Record, + ownAge: number, + partnerAge: number, + gap: number +): boolean { + const maxGap = num(answers, "max_age_gap"); + + // null = no preference → always passes + if (maxGap === null) return true; + + // Directional hard blocks + if (partnerAge > ownAge && bool(answers, "open_to_older") === false) return false; + if (partnerAge < ownAge && bool(answers, "open_to_younger") === false) return false; + + // Hard outer limit: gap > 2× max_gap → reject + if (gap > 2 * maxGap) return false; + + return true; +} + +/** + * Returns a multiplier in [0, 1] representing how well the age gap fits + * both applicants' preferences. Applied after the weighted compatibility score: + * final_score = compatibility_score × ageModifier(a, b) + * + * Takes the stricter (min) of both directions. + */ +export function ageModifier(a: ApplicantDoc, b: ApplicantDoc): number { + const ageA = ageFromBirthDate(a.answers["birth_date"]); + const ageB = ageFromBirthDate(b.answers["birth_date"]); + + // Missing birth_date → no modification + if (ageA === null || ageB === null) return 1.0; + + const gap = Math.abs(ageA - ageB); + + return Math.min( + _computeModifier(num(a.answers, "max_age_gap"), gap), + _computeModifier(num(b.answers, "max_age_gap"), gap) + ); +} + +function _computeModifier(maxGap: number | null, gap: number): number { + if (maxGap === null) return 1.0; + if (gap <= maxGap) return 1.0; + if (gap <= 2 * maxGap) { + const t = (gap - maxGap) / maxGap; + return Math.cos((t * Math.PI) / 2); + } + // Beyond hard outer limit — should have been filtered; return 0 defensively + return 0.0; +} diff --git a/api/src/matching/filters.ts b/api/src/matching/filters/orientation.filter.ts similarity index 80% rename from api/src/matching/filters.ts rename to api/src/matching/filters/orientation.filter.ts index cb233a1..403696d 100644 --- a/api/src/matching/filters.ts +++ b/api/src/matching/filters/orientation.filter.ts @@ -6,7 +6,7 @@ * Filtering them out entirely is the correct behaviour. */ -import type { ApplicantDoc } from "../models/applicant.model.js"; +import type { ApplicantDoc } from "../../models/applicant.model.js"; function str(answers: Record, key: string): string { const v = answers[key]; @@ -41,10 +41,6 @@ export function isOrientationCompatible( ); } -/** - * Returns true if someone with `orientation` and `ownGender` would want - * a partner of `partnerGender`. - */ function _wantsGender( orientation: string, ownGender: string, @@ -52,15 +48,13 @@ function _wantsGender( ): boolean { switch (orientation) { case "Straight": - // Only opposite binary gender; non-binary/unknown → pass through if (ownGender === "Male") return partnerGender === "Female"; if (ownGender === "Female") return partnerGender === "Male"; return true; case "Gay": - // Men seeking men; for non-binary or unknown own-gender → pass through if (ownGender === "Male") return partnerGender === "Male"; - if (ownGender === "Female") return false; // Female + Gay is unusual data — exclude + if (ownGender === "Female") return false; return true; case "Lesbian": @@ -68,7 +62,6 @@ function _wantsGender( if (ownGender === "Male") return false; return true; - // No gender restriction case "Bisexual": case "Pansexual": case "Asexual": @@ -82,9 +75,6 @@ function _wantsGender( } } -/** - * Applies all hard filters and returns only the candidates compatible with `target`. - */ export function filterCandidates( target: ApplicantDoc, candidates: ApplicantDoc[] diff --git a/api/src/matching/scorer.ts b/api/src/matching/scorer.ts new file mode 100644 index 0000000..d17c4ca --- /dev/null +++ b/api/src/matching/scorer.ts @@ -0,0 +1,163 @@ +/** + * Ons Matching Scorer — Embedding-based Cosine Similarity + * ========================================================= + * + * Scores a pair of applicants using dense text embeddings + cosine similarity. + * Semantic embeddings mean "driven" and "ambitious" score high because they + * occupy nearby positions in vector space, unlike bag-of-words approaches. + * + * ## Scoring components + * + * 1. NUMERIC (22%) + * cosine(A.numeric_vec, B.numeric_vec) + * Structural preferences encoded as a fixed vector: + * relationship type, long-distance willingness, affection level, religion openness. + * + * 2. LIFESTYLE SIMILARITY (22%) + * cosine(embed(A.lifestyle + work), embed(B.lifestyle + work)) + * Semantic similarity of how they live and what they do. + * + * 3. CHARACTER CROSS-MATCH (35%, 17.5% per direction) + * cosine(embed(A.preferred_char + preferred_phys + dream_first_date), embed(B.lifestyle + vibe + work)) + * "Does B's self-described vibe match what A is looking for?" — bidirectional. + * + * 4. DEAL BREAKER PENALTY (21%) + * 1 - cosine(embed(A.deal_breakers), embed(B.lifestyle + work)) (averaged both ways) + * High similarity = B's lifestyle matches A's deal breakers = bad. + * + * 5. AGE MODIFIER (multiplier, not a weight) + * Applied after the weighted sum. 1.0 within preferred gap, cosine decay + * up to 2× gap, then hard-filtered before reaching here. + * + * ## Performance + * + * prepare() must be called once before scoring begins. It batch-embeds all + * applicants in O(N) API calls so score() can run synchronously from cache. + * Without prepare(): N² × 4 text fields = thousands of API calls. + * With prepare(): N × 4 text fields (3 batched requests per field set). + */ + +import type { ApplicantDoc } from "../models/applicant.model.js"; +import type { QuestionnaireDoc } from "../models/questionnaire.model.js"; +import type { MatchScore } from "./engine.js"; +import { getEmbeddingProvider } from "./embeddings/provider.js"; +import { getOrComputeEmbeddings } from "../services/embedding.service.js"; +import { buildNumericVector, cosine, round } from "./scorers/numeric.scorer.js"; +import { WEIGHTS } from "./scoring/weights.js"; +import { ageModifier } from "./filters/age.filter.js"; + +// ─── Per-applicant embedding cache ─────────────────────────────────────────── +// +// Populated by prepare() before scoring begins. +// Keyed by applicant ObjectId hex string. + +interface CachedEmbeddings { + profile: number[]; // lifestyle + vibe_words + work + preference: number[]; // preferred_character + preferred_physical + dream_first_date + dealBreakers: number[]; // deal_breakers +} + +const cache = new Map(); + +// ─── Prepare step ───────────────────────────────────────────────────────────── + +/** + * Called once by the engine before any pairwise scoring. + * + * Loads pre-computed embeddings from the DB (written at form submission time). + * Only calls the embedding API for applicants whose embeddings are missing or + * stale (model or text-version changed). In steady state: zero API calls. + */ +export async function prepare( + applicants: ApplicantDoc[], + _questionnaire: QuestionnaireDoc +): Promise { + cache.clear(); + + const provider = getEmbeddingProvider(); + console.log( + `[scorer] Loading embeddings for ${applicants.length} applicants ` + + `(provider: ${provider.name}, model: ${provider.model})...` + ); + + const stored = await getOrComputeEmbeddings(applicants); + + for (const applicant of applicants) { + const emb = stored.get(applicant._id.toHexString()); + if (emb) { + cache.set(applicant._id.toHexString(), { + profile: emb.profile, + preference: emb.preference, + dealBreakers: emb.dealBreakers, + }); + } + } + + console.log(`[scorer] Ready — ${cache.size}/${applicants.length} embeddings loaded.`); +} + +// ─── Score ──────────────────────────────────────────────────────────────────── + +/** + * Scores applicant pair (a, b). prepare() must have been called first. + */ +export function score( + a: ApplicantDoc, + b: ApplicantDoc, + _questionnaire: QuestionnaireDoc +): MatchScore { + const embA = cache.get(a._id.toHexString()); + const embB = cache.get(b._id.toHexString()); + + if (!embA || !embB) { + throw new Error( + "[scorer] Embeddings not found in cache. " + + "The engine must call prepare() before score()." + ); + } + + // 1. Numeric compatibility — structural preferences as a fixed vector + const numericScore = cosine( + buildNumericVector(a.answers), + buildNumericVector(b.answers) + ); + + // 2. Lifestyle semantic similarity (includes work field) + const lifestyleScore = cosine(embA.profile, embB.profile); + + // 3. Character cross-match (bidirectional) + // A wants B: does B's vibe match what A explicitly said they want? + // B wants A: does A's vibe match what B explicitly said they want? + const crossAtoB = cosine(embA.preference, embB.profile); + const crossBtoA = cosine(embB.preference, embA.profile); + const characterScore = (crossAtoB + crossBtoA) / 2; + + // 4. Deal breaker semantic penalty + // High similarity between A's deal breakers and B's lifestyle = bad. + const penaltyAtoB = cosine(embA.dealBreakers, embB.profile); + const penaltyBtoA = cosine(embB.dealBreakers, embA.profile); + const dealBreakerScore = 1 - (penaltyAtoB + penaltyBtoA) / 2; + + // Weighted compatibility score (sums to 1.0) + const compatibilityScore = + WEIGHTS.numeric * numericScore + + WEIGHTS.lifestyle * lifestyleScore + + WEIGHTS.character_cross_match * characterScore + + WEIGHTS.deal_breakers * dealBreakerScore; + + // 5. Age modifier — multiplied onto the compatibility score + const ageMod = ageModifier(a, b); + const finalScore = compatibilityScore * ageMod; + + const breakdown: Record = { + numeric_compatibility: round(numericScore), + lifestyle_similarity: round(lifestyleScore), + character_cross_match: round(characterScore), + character_a_wants_b: round(crossAtoB), + character_b_wants_a: round(crossBtoA), + deal_breaker_penalty: round(dealBreakerScore), + age_modifier: round(ageMod), + }; + + return { score: round(finalScore), breakdown }; +} diff --git a/api/src/matching/scorers/trait.scorer.ts b/api/src/matching/scorers/trait.scorer.ts deleted file mode 100644 index 81fe190..0000000 --- a/api/src/matching/scorers/trait.scorer.ts +++ /dev/null @@ -1,64 +0,0 @@ -/** - * Generic keyword-overlap scorer for text-based trait fields. - * - * Splits both strings into a set of lowercase tokens and computes - * Jaccard similarity: |intersection| / |union|. - * - * This is a placeholder for future NLP/AI-based scoring. - */ - -function tokenize(text: string): Set { - return new Set( - text - .toLowerCase() - .split(/[\s,;/|]+/) - .map((t) => t.trim()) - .filter((t) => t.length > 1) - ); -} - -/** - * Computes keyword overlap between two trait strings. - * Returns a score in [0, 1]. - */ -export function scoreTraitOverlap(a: string, b: string): number { - if (!a || !b) return 0; - - const tokensA = tokenize(a); - const tokensB = tokenize(b); - - if (tokensA.size === 0 || tokensB.size === 0) return 0; - - let intersection = 0; - for (const token of tokensA) { - if (tokensB.has(token)) intersection++; - } - - const union = tokensA.size + tokensB.size - intersection; - return union === 0 ? 0 : intersection / union; -} - -/** - * Checks if the preferences of applicant A are met by the traits of applicant B. - * "preferencesA" is what A wants, "traitsB" is what B has. - * - * Returns a score in [0, 1]. - */ -export function scorePreferenceMatch( - preferencesA: string, - traitsB: string -): number { - if (!preferencesA || !traitsB) return 0; - - const prefs = tokenize(preferencesA); - const traits = tokenize(traitsB); - - if (prefs.size === 0) return 1; // No preferences = always satisfied - - let matched = 0; - for (const pref of prefs) { - if (traits.has(pref)) matched++; - } - - return matched / prefs.size; -} diff --git a/api/src/matching/scoring/weights.ts b/api/src/matching/scoring/weights.ts new file mode 100644 index 0000000..bc5f7bb --- /dev/null +++ b/api/src/matching/scoring/weights.ts @@ -0,0 +1,10 @@ +/** + * Single source of truth for all scoring weights. + * Weights sum to 1.0; age is applied as a multiplier after the weighted sum. + */ +export const WEIGHTS = { + numeric: 0.22, + lifestyle: 0.22, + character_cross_match: 0.35, // 0.175 per direction + deal_breakers: 0.21, +} as const; diff --git a/api/src/models/embedding.model.ts b/api/src/models/embedding.model.ts index 78c3f4f..d05cf62 100644 --- a/api/src/models/embedding.model.ts +++ b/api/src/models/embedding.model.ts @@ -4,18 +4,23 @@ import { ObjectId } from "mongodb"; * Stores pre-computed embedding vectors for one applicant. * * Three vectors are stored per applicant: - * profile — lifestyle + vibe_words (who they are) - * preference — preferred_character + preferred_physical (who they want) + * profile — lifestyle + vibe_words + work (who they are) + * preference — preferred_character + preferred_physical + dream_first_date (who they want) * dealBreakers — deal_breakers (what they can't tolerate) * * provider + model are stored so the matching engine can detect and * discard stale embeddings when the configured model changes. + * + * textVersion tracks which text composition was used to build the vectors. + * Bumped whenever the set of fields fed into any embedding changes. + * Current: 2 (added work to profile, dream_first_date to preference). */ export interface EmbeddingDoc { _id: ObjectId; applicantId: ObjectId; - provider: string; // "openai" | "local" - model: string; // e.g. "text-embedding-3-small" + provider: string; // "openai" | "local" + model: string; // e.g. "text-embedding-3-small" + textVersion: number; // bumped when embedded text composition changes profile: number[]; preference: number[]; dealBreakers: number[]; diff --git a/api/src/seeds/questionnaire.seed.ts b/api/src/seeds/questionnaire.seed.ts index 8407a98..14308ed 100644 --- a/api/src/seeds/questionnaire.seed.ts +++ b/api/src/seeds/questionnaire.seed.ts @@ -11,7 +11,7 @@ import { getQuestionnairesCollection } from "../db/collections.js"; import type { QuestionnaireDoc } from "../models/questionnaire.model.js"; const questionnaire: Omit = { - version: "1.0.0", + version: "1.1.0", name: "Matching Form v1", isActive: true, sections: [ @@ -164,13 +164,40 @@ const questionnaire: Omit = required: true, order: 2, }, + { + id: "max_age_gap", + label: "Maximum age gap you're comfortable with (years)", + type: "number", + sensitive: false, + required: false, + order: 3, + min: 0, + max: 40, + placeholder: "Leave blank for no preference", + }, + { + id: "open_to_older", + label: "Open to someone older than you?", + type: "boolean", + sensitive: false, + required: false, + order: 4, + }, + { + id: "open_to_younger", + label: "Open to someone younger than you?", + type: "boolean", + sensitive: false, + required: false, + order: 5, + }, { id: "preferred_physical_traits", label: "Preferred physical traits in a partner", type: "textarea", sensitive: false, required: true, - order: 3, + order: 6, placeholder: "e.g. Athletic, tall", }, { @@ -179,7 +206,7 @@ const questionnaire: Omit = type: "textarea", sensitive: false, required: true, - order: 4, + order: 7, placeholder: "e.g. Ambitious, kind, funny", }, { @@ -188,7 +215,7 @@ const questionnaire: Omit = type: "textarea", sensitive: false, required: true, - order: 5, + order: 8, placeholder: "e.g. Dishonesty, smoking", }, { @@ -197,7 +224,7 @@ const questionnaire: Omit = type: "boolean", sensitive: false, required: true, - order: 6, + order: 9, }, { id: "religion_deal_breaker", @@ -205,7 +232,7 @@ const questionnaire: Omit = type: "boolean", sensitive: false, required: true, - order: 7, + order: 10, }, { id: "physical_affection_importance", @@ -213,7 +240,7 @@ const questionnaire: Omit = type: "range", sensitive: false, required: true, - order: 8, + order: 11, min: 1, max: 10, }, @@ -223,7 +250,7 @@ const questionnaire: Omit = type: "textarea", sensitive: false, required: true, - order: 9, + order: 12, placeholder: "e.g. Coffee at a bookstore, then a walk by the river", }, ], diff --git a/api/src/services/embedding.service.ts b/api/src/services/embedding.service.ts index 17a1f5a..c7e5794 100644 --- a/api/src/services/embedding.service.ts +++ b/api/src/services/embedding.service.ts @@ -10,15 +10,22 @@ * * 2. Matching run starts → prepare() calls getOrComputeEmbeddings(). * - Applicants whose embeddings are already stored → loaded from DB (no API call). - * - Applicants with missing or stale embeddings (model changed) → computed - * in a single batch request and saved to DB. + * - Applicants with missing or stale embeddings → computed in a single batch + * request and saved to DB. * * ## Stale detection * - * The `model` field in EmbeddingDoc is compared against the currently - * configured EMBEDDING_MODEL. If they differ, the stored vectors are in a - * different space and cannot be compared with new ones — they are re-computed - * and overwritten automatically. + * Embeddings are stale when: + * - The configured EMBEDDING_MODEL has changed (different vector space). + * - CURRENT_TEXT_VERSION doesn't match the stored textVersion (text composition changed). + * In either case vectors are re-computed and overwritten automatically. + * + * ## Text composition (v2) + * + * profile = lifestyle + " — " + vibe_words + " — " + work + * preference = preferred_character_traits + " — " + preferred_physical_traits + * + " — " + dream_first_date + * dealBreakers = deal_breakers */ import { ObjectId } from "mongodb"; @@ -27,6 +34,9 @@ import { getEmbeddingsCollection } from "../db/collections.js"; import { getEmbeddingProvider } from "../matching/embeddings/provider.js"; import type { EmbeddingDoc } from "../models/embedding.model.js"; +/** Bump this whenever the set of fields fed into any embedding text changes. */ +const CURRENT_TEXT_VERSION = 2; + // ─── Text field extraction ──────────────────────────────────────────────────── function str(answers: Record, key: string): string { @@ -40,12 +50,17 @@ function buildTexts(answers: Record): { dealBreakers: string; } { return { - profile: [str(answers, "lifestyle"), str(answers, "vibe_words")] + profile: [ + str(answers, "lifestyle"), + str(answers, "vibe_words"), + str(answers, "work"), + ] .filter(Boolean) .join(" — "), preference: [ str(answers, "preferred_character_traits"), str(answers, "preferred_physical_traits"), + str(answers, "dream_first_date"), ] .filter(Boolean) .join(" — "), @@ -71,6 +86,7 @@ async function saveEmbedding( applicantId, provider, model, + textVersion: CURRENT_TEXT_VERSION, profile: vectors.profile, preference: vectors.preference, dealBreakers: vectors.dealBreakers, @@ -110,10 +126,10 @@ export async function embedApplicant( /** * Loads stored embeddings for the given applicants. - * Computes and saves any that are missing or stale (different model). + * Computes and saves any that are missing or stale (different model or text version). * Returns a map of applicantId hex → EmbeddingDoc. * - * Used by the embedding-cosine algorithm's prepare() step. + * Used by the scorer's prepare() step. */ export async function getOrComputeEmbeddings( applicants: { _id: ObjectId; answers: Record }[] @@ -124,7 +140,6 @@ export async function getOrComputeEmbeddings( const db = await getDb(); const col = getEmbeddingsCollection(db); - // Load whatever is already stored const ids = applicants.map((a) => a._id); const stored = await col.find({ applicantId: { $in: ids } }).toArray(); @@ -132,20 +147,23 @@ export async function getOrComputeEmbeddings( stored.map((d) => [d.applicantId.toHexString(), d]) ); - // Identify applicants that need (re-)embedding const stale = applicants.filter((a) => { const existing = storedByApplicant.get(a._id.toHexString()); - return !existing || existing.model !== provider.model; + return ( + !existing || + existing.model !== provider.model || + (existing.textVersion ?? 1) !== CURRENT_TEXT_VERSION + ); }); if (stale.length > 0) { console.log( `[embedding] Computing ${stale.length} missing/stale embeddings ` + - `(model: ${provider.model})...` + `(model: ${provider.model}, textVersion: ${CURRENT_TEXT_VERSION})...` ); - const profileTexts = stale.map((a) => buildTexts(a.answers).profile); - const preferenceTexts = stale.map((a) => buildTexts(a.answers).preference); + const profileTexts = stale.map((a) => buildTexts(a.answers).profile); + const preferenceTexts = stale.map((a) => buildTexts(a.answers).preference); const dealBreakerTexts = stale.map((a) => buildTexts(a.answers).dealBreakers); const [profileEmbs, preferenceEmbs, dealBreakerEmbs] = await Promise.all([ @@ -154,7 +172,6 @@ export async function getOrComputeEmbeddings( provider.embedBatch(dealBreakerTexts), ]); - // Persist and update local map await Promise.all( stale.map(async (applicant, i) => { const vectors = { @@ -175,6 +192,7 @@ export async function getOrComputeEmbeddings( applicantId: applicant._id, provider: provider.name, model: provider.model, + textVersion: CURRENT_TEXT_VERSION, ...vectors, createdAt: new Date(), }; diff --git a/api/src/services/match-state.service.ts b/api/src/services/match-state.service.ts index ff3639f..8028d69 100644 --- a/api/src/services/match-state.service.ts +++ b/api/src/services/match-state.service.ts @@ -86,10 +86,9 @@ export function toMatchView( if (Object.keys(profile).length > 0) view.partnerProfile = profile; } - // Identity is only revealed once contact is committed: the initiator consented - // by initiating, and the target's handle was already revealed to the initiator - // at contact time. Never attached while the match is merely proposed. - if (partnerInstagram && (doc.status === "in_progress" || doc.status === "dating")) { + // Identity is only revealed after mutual acceptance (dating status). + // Never attached while the match is proposed or in_progress. + if (partnerInstagram && doc.status === "dating") { view.partnerInstagram = partnerInstagram; } @@ -255,6 +254,47 @@ export async function transitionApplicantStatus( ); } +/** + * Recalculates the status of applicants whose active match was deleted + * (e.g. because their partner deleted their account). For each affected + * applicant, determines the correct status from their remaining matches: + * - active dating match present → stay "dating" + * - proposed/in_progress matches only → revert to "matched" + * - no remaining matches → revert to "applied" (re-enters pool) + */ +export async function recalcOrphanedStatuses( + affectedIds: ObjectId[] +): Promise { + if (affectedIds.length === 0) return; + + const db = await getDb(); + const matchCol = getMatchesCollection(db); + const appCol = getApplicantsCollection(db); + + for (const id of affectedIds) { + const matches = await matchCol + .find({ + $or: [{ applicantAId: id }, { applicantBId: id }], + status: { $in: ["dating", "proposed", "in_progress"] }, + }) + .toArray(); + + const hasDating = matches.some((m) => m.status === "dating"); + const hasProposed = matches.some((m) => m.status === "proposed" || m.status === "in_progress"); + + const newStatus: ApplicantStatus = hasDating + ? "dating" + : hasProposed + ? "matched" + : "applied"; + + await appCol.updateOne( + { _id: id }, + { $set: { status: newStatus, updatedAt: new Date() } } + ); + } +} + /** * Applies the applicant-status side effects of a match transitioning to * "dating", "success", or "failed" — shared by the admin override diff --git a/api/src/services/profile.service.ts b/api/src/services/profile.service.ts index 026a4f5..f37cc0c 100644 --- a/api/src/services/profile.service.ts +++ b/api/src/services/profile.service.ts @@ -15,6 +15,7 @@ import { expireConflictingMatches, transitionApplicantStatus, applyMatchStatusSideEffects, + recalcOrphanedStatuses, DELETION_GRACE_MS, type ApplicantMatchView, } from "./match-state.service.js"; @@ -238,13 +239,12 @@ export async function getMyMatches( : []; const answersById = new Map(partners.map((p) => [p._id.toHexString(), p.answers])); - // Reveal partner identities for committed matches (in_progress/dating) — - // the contact flow already revealed the target's handle to the initiator, - // and the initiator consented by initiating. The decryption is audit-logged - // once per applicant per match, not on every page load. + // Reveal partner identities for mutually-accepted matches (dating only) — + // both parties consented when the target accepted the contact request. + // The decryption is audit-logged once per applicant per match, not on every page load. const instagramByMatchId = new Map(); for (const d of docs) { - if (d.status !== "in_progress" && d.status !== "dating") continue; + if (d.status !== "dating") continue; const partnerId = d.applicantAId.equals(oid) ? d.applicantBId : d.applicantAId; const alreadyLogged = d.identityViewLoggedFor?.includes(applicantId) ?? false; @@ -285,15 +285,18 @@ export async function getMyMatches( // ── Contact flow ────────────────────────────────────────────────────────────── export interface ContactResult { - targetInstagram: string; iceBreakers: string[]; dateIdeas: string[]; } +export interface AcceptResult { + actorInstagram: string; + targetInstagram: string; +} + export async function requestContact( applicantId: string, matchId: string, - audit: { ipAddress: string; userAgent: string } = { ipAddress: "unknown", userAgent: "unknown" }, ): Promise { const db = await getDb(); const matchCol = getMatchesCollection(db); @@ -306,7 +309,6 @@ export async function requestContact( throw new AppError("Match not found", 404); } - // Load match to authorise the actor before the atomic write const match = await matchCol.findOne({ _id: matchOid }); if (!match) throw new AppError("Match not found", 404); @@ -316,7 +318,6 @@ export async function requestContact( ? match.applicantBId : match.applicantAId; - // Fetch icebreakers before the atomic claim (no side-effects yet) const [actorDoc, targetDoc] = await Promise.all([ appCol.findOne({ _id: actorId }), appCol.findOne({ _id: targetId }), @@ -326,19 +327,10 @@ export async function requestContact( ? await generateIceBreakers(actorDoc, targetDoc) : { questions: [], dateIdeas: [] }; - // Pre-flight identity check before any mutation — avoids an atomic write - // attempt in the common case where the identity is already gone. This is - // existence-only; the decrypt (and its audit log) happens below via - // revealIdentityById, after the atomic claim but before any irreversible - // side effects on the actor's other matches. - if (!(await identityExistsById(targetId))) { - throw new AppError("Target identity not found", 404); - } - const now = new Date(); - // Atomically claim the transition — filter on status:"proposed" prevents double-contact - // from concurrent requests both passing assertMatchTransition above + // Atomically claim the transition — filter on status:"proposed" prevents + // double-contact from concurrent requests both passing assertMatchTransition const claimed = await matchCol.findOneAndUpdate( { _id: matchOid, status: "proposed" }, { @@ -349,9 +341,6 @@ export async function requestContact( dateIdeas, contactRequestedAt: now, updatedAt: now, - // The contact reveal is logged below — repeat views on the matches - // page must not write another entry for the initiator - identityViewLoggedFor: [applicantId], }, }, { returnDocument: "after" }, @@ -361,56 +350,19 @@ export async function requestContact( throw new AppError("Match is no longer available for contact — it may have been claimed concurrently", 409); } - // Decrypt + audit log after winning the race — revealIdentityById logs - // before the plaintext is returned. Done before expireConflictingMatches - // below: if the target's identity vanished between the pre-flight check - // and here (e.g. they self-deleted concurrently), roll the claim back to - // "proposed" instead of leaving it stuck in_progress with the actor's - // other matches already expired. - const targetInstagram = await revealIdentityById(targetId, { - actor: { actorId: applicantId, ipAddress: audit.ipAddress, userAgent: audit.userAgent }, - action: "APPLICANT_REVEAL_IDENTITY", - targetAlias: match.applicantAId.equals(actorId) - ? match.applicantBAlias - : match.applicantAAlias, - metadata: { - actorType: "applicant", - matchId, - reason: "contact_request", - }, - }); - - if (!targetInstagram) { - await matchCol.updateOne( - { _id: matchOid, status: "in_progress", initiatorId: actorId }, - { - $set: { status: "proposed", updatedAt: new Date() }, - $unset: { - initiatorId: "", - iceBreakers: "", - dateIdeas: "", - contactRequestedAt: "", - identityViewLoggedFor: "", - }, - }, - ); - throw new AppError("Target identity not found", 404); - } - // Exclusive contact: committing to one match expires the initiator's other - // proposed/in_progress matches. The target's other matches are untouched — - // they haven't acted yet. (Accept later expires both sides as before.) - // Runs only after the reveal above succeeds, so a failed request never - // costs the actor their other matches. + // proposed/in_progress matches. Identity is NOT revealed here — mutual + // consent happens only when the target accepts (respondToContact). await expireConflictingMatches([actorId], matchOid); - return { targetInstagram, iceBreakers: questions, dateIdeas }; + return { iceBreakers: questions, dateIdeas }; } export async function respondToContact( applicantId: string, matchId: string, - accept: boolean + accept: boolean, + audit: { ipAddress: string; userAgent: string } = { ipAddress: "unknown", userAgent: "unknown" }, ): Promise { const db = await getDb(); const matchCol = getMatchesCollection(db); @@ -427,6 +379,9 @@ export async function respondToContact( assertMatchTransition(match, "respond", actorId); + const initiatorId = match.initiatorId!; + const targetId = actorId; // actor IS the target (the one responding) + const now = new Date(); // Atomic claim — the status filter prevents concurrent accept/decline from @@ -450,6 +405,43 @@ export async function respondToContact( if (accept) { const ids = [match.applicantAId, match.applicantBId]; await applyMatchStatusSideEffects("dating", ids); + + // Mutual identity reveal — both parties consented. + // Reveal initiator's Instagram to the target, and target's Instagram to initiator. + // Audit-log both. Subsequent page loads use resolveIdentityById (no double-log). + const initiatorAlias = match.applicantAId.equals(initiatorId) + ? match.applicantAAlias + : match.applicantBAlias; + const targetAlias = match.applicantAId.equals(targetId) + ? match.applicantAAlias + : match.applicantBAlias; + + await Promise.all([ + revealIdentityById(initiatorId, { + actor: { actorId: applicantId, ipAddress: audit.ipAddress, userAgent: audit.userAgent }, + action: "APPLICANT_REVEAL_IDENTITY", + targetAlias: initiatorAlias, + metadata: { actorType: "applicant", matchId, reason: "mutual_accept" }, + }), + revealIdentityById(targetId, { + actor: { actorId: initiatorId.toHexString(), ipAddress: "system", userAgent: "system" }, + action: "APPLICANT_REVEAL_IDENTITY", + targetAlias: targetAlias, + metadata: { actorType: "applicant", matchId, reason: "mutual_accept" }, + }), + ]); + + // Mark both as having had their identity view logged for this match + await matchCol.updateOne( + { _id: matchOid }, + { + $addToSet: { + identityViewLoggedFor: { + $each: [initiatorId.toHexString(), applicantId], + }, + }, + } + ); } } @@ -601,10 +593,29 @@ export async function deleteMyAccountNow( { targetApplicantId: oid, targetAlias: doc.alias, metadata: { actorType: "applicant" } }, ); + // Collect partner IDs from all active matches before deleting them — + // we need these to recalculate partner statuses after the data is gone. + const activeMatches = await matchCol + .find( + { + $or: [{ applicantAId: oid }, { applicantBId: oid }], + status: { $in: ["proposed", "in_progress", "dating"] }, + }, + { projection: { applicantAId: 1, applicantBId: 1 } }, + ) + .toArray(); + + const partnerIds = activeMatches.map((m) => + m.applicantAId.equals(oid) ? m.applicantBId : m.applicantAId, + ); + await Promise.all([ appCol.deleteOne({ _id: oid }), identitiesCol.deleteOne({ applicantId: oid }), embeddingsCol.deleteOne({ applicantId: oid }), matchCol.deleteMany({ $or: [{ applicantAId: oid }, { applicantBId: oid }] }), ]); + + // Recalculate partner statuses now that their shared matches are gone. + await recalcOrphanedStatuses(partnerIds); } diff --git a/api/src/validators/admin.validator.ts b/api/src/validators/admin.validator.ts index db1b850..61f0c3f 100644 --- a/api/src/validators/admin.validator.ts +++ b/api/src/validators/admin.validator.ts @@ -26,7 +26,7 @@ export const applicantFilterSchema = paginationSchema.extend({ }); export const matchingRunSchema = z.object({ - algorithm: z.enum(["baseline", "cosine", "embedding-cosine"]).default("embedding-cosine"), + algorithm: z.literal("embedding-cosine").default("embedding-cosine"), }); export type MatchingRunInput = z.infer; diff --git a/api/src/validators/form.validator.ts b/api/src/validators/form.validator.ts index 9ed23c4..3e2c826 100644 --- a/api/src/validators/form.validator.ts +++ b/api/src/validators/form.validator.ts @@ -48,6 +48,12 @@ export const formSubmissionSchema = z.object({ "Not Sure", ]), open_to_long_distance: z.boolean(), + + // Age preferences (optional — null means no preference) + max_age_gap: z.number().int().min(0).max(40).nullable().optional(), + open_to_older: z.boolean().nullable().optional(), + open_to_younger: z.boolean().nullable().optional(), + preferred_physical_traits: z.string().min(1).max(1000), preferred_character_traits: z.string().min(1).max(1000), deal_breakers: z.string().min(1).max(1000), diff --git a/frontend/src/i18n/locales/ar.json b/frontend/src/i18n/locales/ar.json index 91e8c74..60fcbee 100644 --- a/frontend/src/i18n/locales/ar.json +++ b/frontend/src/i18n/locales/ar.json @@ -107,6 +107,10 @@ "relationshipType": "نوع العلاقة", "longDistance": "منفتح على العلاقة عن بُعد؟", "longDistanceHint": "هل تفكر في علاقة عبر مدن أو دول مختلفة؟", + "maxAgeGap": "أقصى فارق عمري مقبول (بالسنوات)", + "maxAgeGapPlaceholder": "اتركه فارغاً إذا لم يكن لديك تفضيل", + "openToOlder": "منفتح على شريك أكبر مني سناً", + "openToYounger": "منفتح على شريك أصغر مني سناً", "physicalTraits": "الصفات الجسدية المفضلة", "physicalTraitsPlaceholder": "صف ما يجذبك جسدياً...", "characterTraits": "الصفات الشخصية المفضلة", diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 2a13ba7..5ec270f 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -95,6 +95,10 @@ "relationshipType": "Art der Beziehung", "longDistance": "Offen für Fernbeziehung?", "longDistanceHint": "Würdest du eine Beziehung über Städte oder Länder hinweg in Betracht ziehen?", + "maxAgeGap": "Maximaler Altersunterschied (Jahre)", + "maxAgeGapPlaceholder": "Leer lassen für keine Präferenz", + "openToOlder": "Offen für jemanden, der älter ist als ich", + "openToYounger": "Offen für jemanden, der jünger ist als ich", "physicalTraits": "Bevorzugte körperliche Eigenschaften", "physicalTraitsPlaceholder": "Beschreibe, was du körperlich ansprechend findest...", "characterTraits": "Bevorzugte Charaktereigenschaften", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 0a1b2bb..1a15c52 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -95,6 +95,10 @@ "relationshipType": "Type of relationship", "longDistance": "Open to long distance?", "longDistanceHint": "Would you consider a relationship across cities or countries?", + "maxAgeGap": "Maximum age gap (years)", + "maxAgeGapPlaceholder": "Leave blank for no preference", + "openToOlder": "Open to someone older than me", + "openToYounger": "Open to someone younger than me", "physicalTraits": "Preferred physical traits", "physicalTraitsPlaceholder": "Describe what you're attracted to physically...", "characterTraits": "Preferred character traits", diff --git a/frontend/src/i18n/locales/fr.json b/frontend/src/i18n/locales/fr.json index 96e5516..2bc8a05 100644 --- a/frontend/src/i18n/locales/fr.json +++ b/frontend/src/i18n/locales/fr.json @@ -95,6 +95,10 @@ "relationshipType": "Type de relation", "longDistance": "Ouvert à la longue distance ?", "longDistanceHint": "Envisageriez-vous une relation entre villes ou pays ?", + "maxAgeGap": "Écart d'âge maximal (ans)", + "maxAgeGapPlaceholder": "Laisser vide pour aucune préférence", + "openToOlder": "Ouvert(e) à quelqu'un de plus âgé que moi", + "openToYounger": "Ouvert(e) à quelqu'un de plus jeune que moi", "physicalTraits": "Traits physiques préférés", "physicalTraitsPlaceholder": "Décrivez ce qui vous attire physiquement...", "characterTraits": "Traits de caractère préférés", diff --git a/frontend/src/pages/Apply.tsx b/frontend/src/pages/Apply.tsx index 4d8f894..f413496 100644 --- a/frontend/src/pages/Apply.tsx +++ b/frontend/src/pages/Apply.tsx @@ -27,7 +27,7 @@ export default function Apply() { const [isSubmitting, setIsSubmitting] = useState(false) const [submitError, setSubmitError] = useState(null) const [submissionKey, setSubmissionKey] = useState(null) - const [questionnaireVersion, setQuestionnaireVersion] = useState('1.0.0') + const [questionnaireVersion, setQuestionnaireVersion] = useState('1.1.0') // Fetch the active questionnaire on mount to obtain the HMAC submission key. // Without it the API will reject the submission. @@ -82,7 +82,7 @@ export default function Apply() { try { const values = getValues() const result = await submitForm({ - questionnaireVersion: questionnaireVersion as '1.0.0', + questionnaireVersion: questionnaireVersion as '1.1.0', answers: { instagram_handle: values.instagram_handle, location: values.location, @@ -96,6 +96,9 @@ export default function Apply() { lifestyle: values.lifestyle, relationship_type: values.relationship_type, open_to_long_distance: values.open_to_long_distance, + max_age_gap: values.max_age_gap ?? null, + open_to_older: values.open_to_older ?? null, + open_to_younger: values.open_to_younger ?? null, preferred_physical_traits: values.preferred_physical_traits, preferred_character_traits: values.preferred_character_traits, deal_breakers: values.deal_breakers, diff --git a/frontend/src/steps/Step4Preferences.tsx b/frontend/src/steps/Step4Preferences.tsx index 690aa93..cdbec8b 100644 --- a/frontend/src/steps/Step4Preferences.tsx +++ b/frontend/src/steps/Step4Preferences.tsx @@ -1,15 +1,18 @@ -import { Controller } from 'react-hook-form' +import { Controller, useWatch } from 'react-hook-form' import { useTranslation } from 'react-i18next' import type { Control, FieldErrors } from 'react-hook-form' import type { FormValues } from '../types/form' import Textarea from '../components/ui/Textarea' import RadioCardGroup from '../components/ui/RadioCard' import Toggle from '../components/ui/Toggle' +import Input from '../components/ui/Input' interface Props { control: Control; errors: FieldErrors } export const FIELDS: (keyof FormValues)[] = [ - 'relationship_type', 'open_to_long_distance', 'preferred_physical_traits', - 'preferred_character_traits', 'deal_breakers', 'okay_with_opposite_gender_friends', 'religion_deal_breaker', + 'relationship_type', 'open_to_long_distance', + 'max_age_gap', 'open_to_older', 'open_to_younger', + 'preferred_physical_traits', 'preferred_character_traits', + 'deal_breakers', 'okay_with_opposite_gender_friends', 'religion_deal_breaker', ] // Values stay in English — stored in DB and used by matching engine @@ -23,6 +26,9 @@ const relationshipOptions = [ export default function Step4Preferences({ control, errors }: Props) { const { t } = useTranslation() + const maxAgeGap = useWatch({ control, name: 'max_age_gap' }) + const showDirectional = typeof maxAgeGap === 'number' && maxAgeGap > 0 + return (
@@ -39,6 +45,49 @@ export default function Step4Preferences({ control, errors }: Props) { )} /> + + {/* Age preferences */} +
+ ( + { + const raw = e.target.value + field.onChange(raw === '' ? null : parseInt(raw, 10)) + }} + onBlur={field.onBlur} + /> + )} + /> + {showDirectional && ( +
+ ( + + )} /> + ( + + )} /> +
+ )} +
+ (