feat(matching): age filtering, algorithm consolidation, mutual identity reveal, orphan status cleanup#10
Open
GravityDarkLab wants to merge 96 commits into
Open
feat(matching): age filtering, algorithm consolidation, mutual identity reveal, orphan status cleanup#10GravityDarkLab wants to merge 96 commits into
GravityDarkLab wants to merge 96 commits into
Conversation
…onsolidation, mutual identity reveal, orphan status cleanup - 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).
…from contact controller
- applicants.seed.ts: bump QUESTIONNAIRE_VERSION to 1.1.0, add max_age_gap / open_to_older / open_to_younger to generated answer sets (~70% of applicants get realistic age preferences, 30% get null = no preference) - matching/README.md: full rewrite — reflects new directory layout (scorer.ts, filters/, scoring/weights.ts), removed algorithm sections (baseline, cosine), documents age filter hard rules + soft-decay modifier math, textVersion stale detection, updated embedding text composition (v2), extension guide - README.md: fix architecture diagram (removed baseline/cosine references), fix "how it works" step 6 (mutual-only reveal), fix privacy table, update test count 199→359 - api/README.md: remove algorithm param mention from matching section, fix contact endpoint description, fix mutual identity reveal description in privacy - frontend/README.md: update Step 4 row with age preference fields
- Bump spec version to 1.1.0
- Add max_age_gap, open_to_older, open_to_younger to FormAnswers schema
- Remove baseline/cosine algorithm references throughout; algorithm is now
embedding-cosine only
- Fix ContactResult: remove targetInstagram (no identity revealed at contact
stage); update contact endpoint summary and description accordingly
- Fix partnerInstagram description: visible only on dating status, not in_progress
- Add mutual identity reveal description to respond endpoint (both Instagrams
decrypted atomically on accept=true, both audit-logged)
- Fix withdraw endpoint description: no identity reveal has happened at
in_progress stage
- Add age_modifier to MatchScoreBreakdown; replace baseline/cosine field
descriptions with single embedding-cosine field list
- Remove algorithm query param from /matching/candidates
- Add missing admin matches routes: GET/PATCH/DELETE /api/v1/admin/matches
and /api/v1/admin/matches/{id}
- Add Admin – Matches tag
Two new pre-filters run before scoring — pairs that fail are never scored: - religion.filter.ts: if either applicant has religion_deal_breaker=true and their religions differ, the pair is excluded entirely (not just penalised) - location.filter.ts: if either applicant has open_to_long_distance=false and their locations differ, the pair is excluded entirely Both are bidirectional vetoes: one person saying "this is a deal breaker" is sufficient to reject the pair. Missing or blank values skip the filter. Previously these were only soft signals encoded in the numeric cosine vector, which means a Muslim with religion_deal_breaker=true could still be proposed to someone of a different faith — just at a lower score. This fixes the gap. 14 new unit tests cover pass/fail cases and edge cases (case-insensitivity, missing values). Total API tests: 373.
…limit Add `_verify` honeypot to the form schema; the service rejects any submission where the field is non-empty (bots that fill every field trigger this silently). Extract the submitter's IP (X-Forwarded-For → X-Real-IP → "unknown") and store it as `submissionIp` on the new applicant document for admin visibility. Tighten the form rate-limiter from 100/10 min to 3/hour — allows genuine retries while stopping automated loops.
Add `MatchSummary` type and `summary` field to `MatchDoc`. Add `match-summary.service.ts` which either returns the cached summary or generates it via the LLM and writes it back before returning. Expose the result at `GET /api/v1/profile/matches/:id/summary` behind applicant auth. Subsequent loads are instant (cache hit); model upgrade clears the cache automatically by comparing `summary.model` against `COMPLETION_MODEL`.
Surface the `submissionIp` field (collected at form submission) in the applicant metadata section so admins can spot duplicate IPs and flag suspicious batches. Hidden when absent (localhost shows "unknown").
The API's Zod validator only accepts `embedding-cosine` (literal, not enum). Remove the two deprecated radio cards from the frontend ALGORITHMS array, drop the `isNonEmbedding` flag and the multilingual warning block that was tied to them, and center the single remaining card.
Replace the raw key-value dump with `PartnerProfileView` — a purpose-built
component that renders: (A) Snapshot chips, (B) Vibe section, (C) Looking-For
preferences, (D) free-text "own words" cards, and (E) AI-generated compatibility
summary ("Why this match?"). The summary is fetched lazily from the new
`GET /matches/:id/summary` endpoint; subsequent views are instant cache hits.
Instagram handles are now rendered as clickable `<a>` links to instagram.com.
…ields Show a live word count below each textarea in Steps 3–5. When the user has typed but stays under 5 words, a soft inline hint appears on blur suggesting more detail. Richer answers improve embedding quality; no blocking validation.
Keys added: - `admin.detail.submissionIp` — submission IP label in applicant detail - `admin.matching.recommended` — badge on single algorithm card - `portal.matches.viewOnInstagram` — accessible label for Instagram links - `portal.profile.partner.*` — all section labels for PartnerProfileView - `steps.wordCount` / `steps.writeMoreHint` — form encouragement strings
form.routes: verify that a submission with a non-empty `_verify` field returns 400; verify that the rate-limiter now enforces 3/hour. profile.routes: test GET /matches/:id/summary returns the cached summary on hit and generates it on miss; verify 403 for non-participant callers.
Matching.vitest: remove stale tests that checked for 3 algorithm radio buttons and the multilingual warning block (both removed from the UI). Replace with a single-algorithm assertion and retain all result-card and last-run-info tests. MatchCard.vitest: expand coverage for PartnerProfileView sections (snapshot chips, vibe, looking-for, own words, AI summary fetch states) and clickable Instagram link rendering.
openapi.yaml: document GET /profile/matches/{id}/summary endpoint and the
updated POST /submit schema (honeypot field, submissionIp in response model).
questionnaire.seed.ts: align seed answers with updated validator constraints.
scripts/seed.ts: refresh interactive seed script to match new schema.
Unused outside its own test — engine.ts already does combined filtering inline via applyFilters(). Delete the function and its 4 tests.
location.filter.ts and religion.filter.ts each defined an identical trim+lowercase string getter under a different name. Extract one normalizeAnswer(answers, key) in answer.util.ts.
Step3Vibe, Step4Preferences, and Step5Final each had an identical countWords() + word-count/hint JSX block pasted in. Extract to components/ui/WordCountHint.tsx, used by all three.
PartnerProfileView defined its own pill-chip component; components/ui/Badge already covers this exact case.
IP and User-Agent header extraction was hand-rolled 6 times across audit.middleware, rateLimit.middleware, form.controller, and 3 sites in profile.controller — with two inconsistent header-casing variants. Extract getClientIp()/getRequestMeta() to utils/request-meta.ts; admin.controller's extractAuditContext already showed the right pattern, this brings the rest in line with it.
…dings Was calling buildTexts(a.answers) three times per stale applicant (once per field array) instead of once.
Three API client modules each independently read import.meta.env.VITE_API_URL with the same fallback. Extract to config/api.ts.
Applicants.tsx and Matches.tsx each hand-rolled identical search-debounce and URL-driven page+status-filter logic. Extract to admin/hooks/, following the existing useStatusLabels.ts convention. AuditLogs.tsx and Dashboard.tsx are intentionally left alone — their fetch/pagination shapes diverge enough that forcing them into the same hooks would be a worse fit than the duplication it removes.
Its only caller (the pre-flight check in requestContact) was removed when identity reveal moved to mutual-accept-only. Nothing else referenced it.
…ovider new ObjectId(applicantId) was unguarded unlike matchId's try/catch — both now share one. Cache invalidation also only compared the chat model name, so switching EMBEDDING_PROVIDER while keeping the same model string could serve a stale summary generated by a different backend; key on provider:model instead.
Was awaiting one find+update pair per affected applicant sequentially in a for loop; switch to Promise.all.
…provider Match summaries and ice-breakers send free-text profile answers to the configured chat provider — call this out next to the existing local/openai choice so operators running openai know what leaves the system.
cities.ts relabeled Jerusalem/Tel Aviv/Haifa from "Israel" to "Palestine" — without aliasing, applicants who submitted under the old label and those submitting under the new one would be treated as different cities, wrongly tripping the long-distance hard exclusion for same-city pairs. LOCATION_ALIASES maps both labels to one canonical key for comparison only; stored/displayed values are unaffected.
… forward Old "Israel"-labeled location data will be migrated directly instead of aliased at comparison time — no need for the canonicalization map once there's only one valid label per city.
Diagnoses why match scores never exceed ~80% (embedding anisotropy + non-negative-orthant bias compounding through an inverted deal-breaker term), surveys six approaches with citations, and lands on a hybrid bi-encoder-shortlist + LLM-listwise-rerank design (RankGPT/PRP-style) to replace the displayed score without discarding the cheap embedding pipeline.
…ry re-embeds - generateChatCompletion gains temperature/responseSchema options (OpenAI Structured Outputs, also honored by LM Studio) instead of a maxTokens override that risked truncating output mid-JSON; length is steered via prompt instructions instead. - truncateForPrompt bounds free-text answer fields before they reach a prompt, independent of the output-side change above. - match-summary/icebreaker use low temperature + structured output for more grounded, reliably-parseable results; icebreaker also gained the cleanStrings validation match-summary already had. - updateMyAnswers skips re-embedding when an edit doesn't touch an embedding-relevant field (most edits don't).
… returning candidates
runFullMatchingPass awaited one LLM rerank call per applicant fully sequentially — for N applicants at the 15s fetch timeout, worst case was N x 15s (e.g. ~37.5 min at N=150). Batching at a concurrency of 5 cuts that to ceil(N/5) x 15s (~7.5 min) without opening N simultaneous requests against the LLM provider.
Root README, api/README, and api/src/matching/README all still described the matching pipeline as embedding-cosine-only. Added the rerank stage to the pipeline diagrams, the new match_reranks collection, the RankedCandidate shape change, and fixed the root README's architecture diagram (which still referenced the long-removed baseline/cosine algorithms).
docs/superpowers/{specs,plans} are working artifacts from the design/
planning process, not documentation meant to ship in the repo. Stop
tracking them and gitignore the directory going forward; the actual
reference docs (READMEs) carry the durable content and have been
updated to inline what was previously only in the now-untracked specs
rather than link out to them.
…ped doc A proper technical writeup of why cosine-based scores were structurally capped under ~80% (anisotropy + an inverted deal-breaker term, with citations), the six alternatives considered and why each was accepted or rejected, the chosen two-stage design, and — explicitly — what has and hasn't been empirically validated (unit/integration coverage and clean-clone builds yes; a live before/after score-distribution comparison on real data, no — with the methodology to run one). Linked from the root README and api/src/matching/README.md. Lives at docs/ (not docs/superpowers/, which stays local-only/gitignored).
Runs one real full matching pass and prints distribution stats (min/p50/mean/p90/max, % clearing the 0.8 threshold) for both embeddingScore and score side by side, plus sample LLM reasoning and the fallback rate. No need to run the pass twice — RankedCandidate already carries both numbers per candidate. Implements the evaluation methodology described in docs/llm-listwise-rerank-matching-score.md §7.
Ran bun run eval:rerank against 100 seeded applicants (209 candidate pairs, local embedding + chat models). Old score never exceeded 0.69; new score spans 0.0-0.9 with 27.8% clearing the 0.8 threshold, matching the §2 diagnosis. Flagged a 43.1% LLM-fallback rate on this run as a separate reliability finding worth following up (likely the local model struggling with a 15-candidate listwise prompt), not a defect in the fallback mechanism itself.
…quote llmReasoning previously only existed on RankedCandidate (admin candidate preview) and was discarded once a couple proposal was persisted. Thread it through the existing breakdown-propagation path instead — proposals.ts (CoupleProposal.llmReasoning, picked from whichever direction was scored, same pattern as breakdown) -> match.service.ts (persisted on MatchDoc) -> match-state.service.ts (ApplicantMatchView.llmReasoning) -> MatchCard.tsx, rendered as an always-visible (not behind the expand toggle) italic pull-quote at the top of the card, labeled "Why this match" in all 4 locales. embeddingScore stays admin/debug-only, as designed — it never flows past RankedCandidate.
…silently Investigating a 98% fallback rate with a local reasoning model (gpt-oss-20b) and had zero visibility into which of HTTP error / fetch timeout / truncated-output / malformed-JSON was the actual cause. generateChatCompletion now logs the HTTP status+body on a non-OK response, the raw error on a fetch/timeout failure, and an abnormal finish_reason (e.g. "length" — output truncated before completion, plausible for a reasoning model with output tokens spent on chain-of-thought). match-rerank logs a raw-content snippet whenever the response can't be parsed into the expected shape. No behavior change — same return values, just visible reasons now.
The previous eval run was reusing a cached match_reranks result from
before the model switch (cache is keyed by applicant + shortlist hash,
not by whether the model changed mid-investigation) -- no LLM call,
no new diagnostic logs.
- eval:rerank gains --clear-cache to force a real LLM call per
applicant instead of serving stale cached rankings.
- generateChatCompletion gains maxTokens (override the 800-token
default ceiling) and reasoningEffort ("low") options -- reasoning
models like gpt-oss spend real output tokens on chain-of-thought
before their final answer, which can exhaust a tight token budget
before any JSON is ever emitted (finish_reason: "length").
- match-rerank now requests reasoningEffort: "low", a 4000-token
ceiling, and an explicit "no chain-of-thought, JSON only" prompt
instruction as a provider-agnostic backstop for servers that don't
honor reasoning_effort.
The --clear-cache run still showed 80% fallback with zero output from the HTTP-error/fetch-failure/JSON-parse-failure logging added earlier -- because none of those were firing. The real failure mode is a fourth, previously silent path: the response parses as valid JSON with a real rankings array, but individual candidates are missing from it or carry an unparseable score, and the per-candidate fallback for that case logged nothing. Now warns with how many entries the model actually returned vs how many candidates were asked for, which ids were missing/invalid, and a raw snippet -- the next run should finally show what's actually wrong (model returning fewer entries than asked, duplicate/mismatched ids, scores in the wrong shape, etc).
… ids
Root cause found via the new diagnostics: gpt-oss-20b locally returns
1-2 rankings regardless of shortlist size (1 to 15 tested), and even
single-candidate shortlists fail because the returned candidateId is
garbled/truncated rather than matching what was actually sent -- not a
token-budget, timeout, or JSON-shape problem (all ruled out by the
earlier instrumentation), but an instruction-following limitation: the
prompt never stated the expected count or demanded exact id copying.
Now states the candidate count explicitly ("exactly N entries") and
instructs verbatim id copying. This is a prompt fix for a model
behavior gap, not a workaround -- no fuzzy/prefix id matching was
added on the parsing side, since that would mask unreliable output
rather than address why it's unreliable.
The explicit "exactly N entries" prompt instruction (previous commit) had zero effect -- still 1 entry per call regardless of shortlist size 1-15. That rules out plain-text instruction-following as the lever: the array was never length-constrained in the JSON schema itself, so a schema-to-grammar translator (the usual mechanism on local OpenAI-compatible servers) treats a 1-item array as already valid and "close the array now" is a legal next token no prompt wording can override, since the constraint is enforced at the decoding/grammar level, not the model's language understanding. Added minItems/maxItems = candidates.length to the rankings array schema, gated to the local provider only -- OpenAI's hosted strict mode doesn't document support for these array keywords and may reject the request outright; local is where the evidence actually points and where this can be verified via the existing HTTP-error logging if it turns out to be unsupported there too.
EMBEDDING_PROVIDER drove both embeddings and chat completions, so you couldn't keep already-cached local embeddings while testing a different chat model (or vice versa). Added CHAT_PROVIDER/CHAT_BASE_URL, both defaulting to the embedding equivalents when unset -- existing single-provider configs (including the test suite's) keep working unchanged. ai.service.ts's getChatEndpoint(), and the RERANK_MODEL/SUMMARY_MODEL cache-key labels in match-rerank.service.ts/match-summary.service.ts, now read chatProvider instead of embeddingProvider -- the local-only gate on the rankings array's minItems/maxItems (added last commit) is about the chat server's grammar translation, not the embedding provider, so this was also a correctness fix for that gate.
OpenAI's newer model families (o-series, gpt-5.x — including gpt-5.4-mini) reject max_tokens outright with HTTP 400 "Unsupported parameter: 'max_tokens'... Use 'max_completion_tokens' instead." Older OpenAI models and local OpenAI-compatible servers still expect the legacy max_tokens field. Branch on chatProvider so both paths work without per-model special-casing.
…e rejects it Same class of issue as the max_tokens fix: OpenAI's reasoning models (o-series, and the entire gpt-5.x family, not just gpt-5.4-mini) fix temperature/top_p/penalties at their defaults and return HTTP 400 for any other explicit value. No reliable way to tell from a model name alone which OpenAI models are in the restricted tier as the lineup changes, so temperature is omitted entirely for chatProvider=openai (falls back to the API's own default) rather than guessed per model. Local providers are unaffected and keep the per-call tuning.
15s (sized for fast local inference) was too tight for a real reasoning model doing full chain-of-thought across a 15-candidate listwise prompt -- confirmed by an actual TimeoutError on gpt-5.4-mini, on a call that otherwise would have returned a well-grounded 16-entry response (vs. 1-2 entries from the local models earlier in this investigation). generateChatCompletion gains timeoutMs (default raised 15s -> 30s, same override pattern as maxTokens); match-rerank requests 45s given its prompt is the heaviest of the three call sites.
Adds §5.7 documenting the five provider-specific request-shaping fixes made during this investigation (decoupled chat/embedding providers, max_completion_tokens vs max_tokens, omitting temperature for OpenAI's reasoning-model tier, minItems/maxItems array enforcement, raised timeouts) -- these existed only in commit messages before this, not in the design doc. Records the actual second measurement: same 100-applicant pool, same embeddings, gpt-5.4-mini for the rerank stage. Fallback rate dropped from 43.1-98.1% (three local-model attempts) to 4.3%. More importantly than the aggregate stats: the sampled reasoning shows the LLM score correcting in both directions relative to the old embedding score (up for genuinely under-rated pairs, down for pairs with a real stated dealbreaker the embedding geometry couldn't represent as one) -- the actual point of replacing the measurement rather than just rescaling it, per §4's reasoning for choosing F over A/B. Also fixed two now-stale claims elsewhere in the doc: the latency section still cited the old 15s timeout, and the non-determinism limitation still claimed temperature bounds output on every provider.
…unctions Every provider-specific fix from this session (max_completion_tokens vs max_tokens, temperature omission, endpoint selection) lived only inside generateChatCompletion, which reads the env singleton directly -- making it untestable without mock.module()-ing config/env.js, which would replace env for every other test file in a full-suite run (the same collision class documented elsewhere in this codebase). buildChatEndpoint/buildChatRequestBody take provider as a parameter instead, so they're directly unit-testable. 14 new tests cover every branch discovered this session. generateChatCompletion's behavior is unchanged -- it just calls through to these now.
…aker too Both call sites had no reasoningEffort and only the 800-token default ceiling -- the exact gap match-rerank had before this session's investigation, and they run against the same OpenAI reasoning-model production target. Added reasoningEffort: "low" + a 1500-token ceiling (less headroom than rerank's 4000 since these are single-pair prompts, not a 15-candidate listwise one) to both, to avoid the same finish_reason: "length" truncation risk before it's ever hit in practice rather than after.
embedBatch() had no timeout at all, for either provider -- a hung request here blocks prepare(), which blocks the entire matching pass before any per-applicant rerank logic (and its own timeout/fallback protections) even runs. Same class of fix already applied to the chat completion path this session.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
This PR is a comprehensive overhaul of the matching engine, addressing six concrete gaps and adding two new hard filters:
Original four gaps
max_age_gap,open_to_older,open_to_youngerfields to questionnaire (v1.1.0). Age filter is a hard pre-filter with a soft cosine decay modifier applied to scores.baselineandcosinealgorithms. Engine is now a single linear pipeline;embedding-cosineis the only scorer. Weights moved tomatching/scoring/weights.ts.requestContact()no longer reveals any Instagram. Both handles are decrypted and returned only whenrespondToContact(accept=true)— simultaneous reveal, both audit-logged withreason: "mutual_accept".deleteMyAccountNow()now callsrecalcOrphanedStatuses()after removing a user's matches, recalculating every partner's status so they re-enter the pool correctly.Two new hard filters
filters/religion.filter.ts): If either applicant hasreligion_deal_breaker = trueand their religions differ → pair excluded. Previously this was only a soft score penalty via the numeric vector.filters/location.filter.ts): If either applicant hasopen_to_long_distance = falseand they are in different cities → pair excluded. Previously only a soft penalty. Both filters are case-insensitive and skip when the field is missing (pass-through). Location comparison is an exact string match — old "Israel"-labeled location data needs a direct data migration to the current "Palestine" labels rather than a code-level alias (see Review-feedback section below for history).OpenAPI spec audit (15 gaps fixed)
info.versionto1.1.0Admin – MatchestagFormAnswers: addedmax_age_gap,open_to_older,open_to_youngerContactResult: removedtargetInstagram(no longer returned at contact stage)MatchScoreBreakdown: replaced baseline/cosine descriptions withage_modifierand its formulaalgorithmenum: collapsed to[embedding-cosine]on both request and response schemas/matching/run: updated description to list all 4 hard filters; fixed breakdown example/matching/candidates: removed stalealgorithmquery paramGET /api/v1/admin/matches,PATCH /api/v1/admin/matches/{id},DELETE /api/v1/admin/matches/{id}Test plan
bun run test:api→ 373 pass, 0 fail (14 new tests: 7 religion filter + 7 location filter)bun run test:frontend→ 235 pass, 0 failopen_to_older=falsecorrectly blocks older candidates)age_modifierpresent in score breakdownsrequestContactreturns only{iceBreakers, dateIdeas}— no InstagramrespondToContact(accept)returns both Instagrams; 2 audit log entries withreason: "mutual_accept"dating→applied)Update: warm dating experience + name reveal (questionnaire bumped to v1.2.0)
A second pass on top of the above, addressing the cold, ungated dating-outcome flow and adding mutual name reveal:
Warm, time-gated dating outcome flow
MatchDocgainsdatingStartedAt(anchor) andoutcomeFeedback. Outcome reporting is now time-gated oncedating:failedunlocks 3 days in,successafter 7 (enforced server-side inreportOutcome, not just hidden client-side).tags+note) on afailedoutcome, audit-logged asAPPLICANT_REPORT_OUTCOME; acontinuationchoice (continuevsbreak) controls whether both applicants re-enter the pool or pause with the existing deletion-grace flow.ApplicantProfileView.distanceNudgewhentoo_farwas tagged and the applicant isn't already open to long distance (POST /matches/:id/nudge-ackto acknowledge/opt in).MatchCardnow walks through check-in copy → quiet cancel link → full outcome buttons → optional feedback → continue/break choice, with new CSS-only confetti/heart-pulse animations. Account-deletion copy rewritten to a warmer tone in all 4 locales (en/fr/de/ar).Mutual first/last name reveal
first_name/last_namequestions, same encrypted-separately/audit-logged-reveal pattern as the Instagram handle (own fresh IV per field — never reused).revealIdentityById/resolveIdentityByIdreturn shape changed to{ instagram, fullName }, threaded through every call site (getMyMatches,respondToContact, the admin identity endpoint, both controllers).GET /profile/me→fullName) — not audit-logged, since viewing your own data isn't a privacy-sensitive reveal.Review-feedback fixes (Copilot pass on this update)
first_name/last_namevalidators now.trim()before.min(1)so whitespace-only input is rejected.max_age_gap's number-input handler guardsNaNfrom an intermediate invalid value.match-summary.service.tsfilters parsed LLM pros/cons arrays to non-empty strings before persisting.LOCATION_ALIASEScanonicalization so both old- and new-labeled data would compare as the same city for the long-distance filter — this was reverted: old "Israel"-labeled data will be migrated directly instead, so going forward only the "Palestine" label is recognized (no code-level aliasing).Test plan (this update)
bun run typecheck— exit 0, both workspacesbun run test— API 518 pass / 0 fail, frontend 267 pass / 0 failUpdate: LLM-judged compatibility scoring (replaces the embedding-only score)
A third pass investigating why no match had ever scored above ~80%, and fixing the root cause rather than the symptom.
Why scores were capped well under 100%
The matching score is a weighted sum of
cosine(...)terms over text embeddings and a small hand-built numeric vector. Two well-documented geometric effects push every one of those terms toward a high floor regardless of actual compatibility: embedding anisotropy (learned embeddings cluster in a narrow cone, so even unrelated texts produce a baseline cosine of ~0.6–0.75 — Ethayarajh 2019; Gao et al. 2019 ICLR; Steck, Ekanadham & Kallus 2024, arXiv:2403.05440) and non-negative-orthant bias in the numeric vector (cosine between non-negative vectors can't go negative, biasing it upward by construction). The deal-breaker term compounds this worst —1 - cosine(dealBreakers, profile)caps near 0.25–0.4 even for a genuinely perfect non-overlap, since it's an inversion stacked on an already-inflated floor.Five other approaches were considered and rejected before landing on the hybrid below: (A) min-max rescaling the existing cosine scores per pool — cheap but only fixes the displayed number, not what's actually measured; (B) global anisotropy correction ("All-but-the-Top," Mu & Viswanath 2018) — fixes the root cause but heavier to implement/validate; (C) pointwise LLM-judge scoring — O(N²) calls and LLMs have their own central-tendency bias when scoring in isolation (G-Eval, Liu et al. 2023); (D) a dedicated cross-encoder — no such model exists for romantic compatibility, converges into an LLM call anyway; (E) dropping embeddings entirely for structured-only scoring (OkCupid's published approach) — would lose the signal in free-text answers and is really a questionnaire redesign, not a scoring fix.
The fix: keep the cheap embedding shortlist, replace the displayed score
scorer.ts) is unchanged and still does the cheap O(N) shortlisting across the whole applicant pool — it's good at that.match-rerank.service.ts: one LLM call per applicant, covering that applicant's entire shortlist at once (listwise, not pairwise/pointwise — RankGPT/Pairwise-Ranking-Prompting-style: Sun et al. 2023, EMNLP Outstanding Paper; Qin et al. 2023/2024, arXiv:2306.17563), scored against an explicit anchored 0–100 rubric via OpenAI Structured Outputs. Listwise framing + anchored extremes specifically counters the LLM's own central-tendency scoring bias (G-Eval, Liu et al. 2023) rather than just relocating the original problem.match_rerankscollection, keyed by a hash of the shortlist's composition + chat model) so repeated admin views don't repeat the LLM call. Falls back to each candidate's embedding score individually on any failure (empty/malformed LLM response, missing candidate, invalid score, cache I/O error) — never blocks the matching pipeline.RankedCandidate.score(the field that already flows throughproposals.ts→match.service.ts→MatchDoc.score→ the frontend) keeps its name, shape, and 0–1 scale — only what computes it changed. Zero frontend changes were needed.runFullMatchingPassbatches rerank calls at a concurrency of 5 (was previously calling cheap embedding math only, now makes real LLM calls) and the underlyingfetchhas a 15s timeout, so one slow/hung LLM request can't stall an entire admin-triggered matching run.LLM prompt cost & reliability (prerequisite work, same pass)
generateChatCompletiongained{ temperature, responseSchema }options (Structured Outputs, honored by both OpenAI and LM Studio) instead of amaxTokensoverride that risked truncating output mid-JSON — length is now steered via prompt instructions instead.truncateForPromptbounds free-text answer fields before they reach any prompt;match-summary/icebreakernow use low temperature + structured output for more grounded, reliably-parseable results.updateMyAnswersskips re-embedding when a profile edit doesn't touch an embedding-relevant field (most edits don't).buildProfileSnippethelper extracted out ofmatch-summary.service.tsonce a third near-identical copy was about to be needed inmatch-rerank.service.ts.Test plan (this update)
bun run typecheck— exit 0, both workspacesbun run test:api— 540 pass, 0 failgit cloneto a scratch dir,bun install, typecheck + full suite) — every commit in the range individually builds and tests green, not just the final HEAD