Skip to content

feat(matching): age filtering, algorithm consolidation, mutual identity reveal, orphan status cleanup#10

Open
GravityDarkLab wants to merge 96 commits into
mainfrom
feat/matching-overhaul
Open

feat(matching): age filtering, algorithm consolidation, mutual identity reveal, orphan status cleanup#10
GravityDarkLab wants to merge 96 commits into
mainfrom
feat/matching-overhaul

Conversation

@GravityDarkLab

@GravityDarkLab GravityDarkLab commented Jun 17, 2026

Copy link
Copy Markdown
Owner

Summary

This PR is a comprehensive overhaul of the matching engine, addressing six concrete gaps and adding two new hard filters:

Original four gaps

  • Age preferences: Added max_age_gap, open_to_older, open_to_younger fields to questionnaire (v1.1.0). Age filter is a hard pre-filter with a soft cosine decay modifier applied to scores.
  • Algorithm consolidation: Removed dead baseline and cosine algorithms. Engine is now a single linear pipeline; embedding-cosine is the only scorer. Weights moved to matching/scoring/weights.ts.
  • Privacy gap — mutual identity reveal: requestContact() no longer reveals any Instagram. Both handles are decrypted and returned only when respondToContact(accept=true) — simultaneous reveal, both audit-logged with reason: "mutual_accept".
  • Orphan status cleanup: deleteMyAccountNow() now calls recalcOrphanedStatuses() after removing a user's matches, recalculating every partner's status so they re-enter the pool correctly.

Two new hard filters

  • Religion deal-breaker (filters/religion.filter.ts): If either applicant has religion_deal_breaker = true and their religions differ → pair excluded. Previously this was only a soft score penalty via the numeric vector.
  • Long-distance deal-breaker (filters/location.filter.ts): If either applicant has open_to_long_distance = false and 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)

  • Bumped info.version to 1.1.0
  • Added missing Admin – Matches tag
  • FormAnswers: added max_age_gap, open_to_older, open_to_younger
  • ContactResult: removed targetInstagram (no longer returned at contact stage)
  • MatchScoreBreakdown: replaced baseline/cosine descriptions with age_modifier and its formula
  • algorithm enum: 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 stale algorithm query param
  • Contact endpoint: updated summary/description/example to reflect no-Instagram response
  • Respond endpoint: documented mutual identity reveal behavior
  • Added 2 new paths: GET /api/v1/admin/matches, PATCH /api/v1/admin/matches/{id}, DELETE /api/v1/admin/matches/{id}
  • Total paths: 30 → 32

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 fail
  • Full integration test (13 sections) passed end-to-end against live server + LM Studio embeddings:
    • Matching run: 125 applicants, 216 proposals generated
    • Orientation filter verified (excluded correctly)
    • Age filter verified (open_to_older=false correctly blocks older candidates)
    • age_modifier present in score breakdowns
    • requestContact returns only {iceBreakers, dateIdeas} — no Instagram
    • respondToContact(accept) returns both Instagrams; 2 audit log entries with reason: "mutual_accept"
    • Account deletion recalculates partner status (datingapplied)
    • Rate limiting fires at correct thresholds (429)
    • Frontend serving questionnaire v1.1.0 with all 3 new age fields

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

  • MatchDoc gains datingStartedAt (anchor) and outcomeFeedback. Outcome reporting is now time-gated once dating: failed unlocks 3 days in, success after 7 (enforced server-side in reportOutcome, not just hidden client-side).
  • Optional structured feedback (tags + note) on a failed outcome, audit-logged as APPLICANT_REPORT_OUTCOME; a continuation choice (continue vs break) controls whether both applicants re-enter the pool or pause with the existing deletion-grace flow.
  • A one-time, dismissible distance-preference nudge surfaces via ApplicantProfileView.distanceNudge when too_far was tagged and the applicant isn't already open to long distance (POST /matches/:id/nudge-ack to acknowledge/opt in).
  • MatchCard now 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

  • Questionnaire bumped to v1.2.0: adds sensitive first_name/last_name questions, same encrypted-separately/audit-logged-reveal pattern as the Instagram handle (own fresh IV per field — never reused).
  • revealIdentityById/resolveIdentityById return shape changed to { instagram, fullName }, threaded through every call site (getMyMatches, respondToContact, the admin identity endpoint, both controllers).
  • Applicants now also see their own decrypted name on their own profile (GET /profile/mefullName) — not audit-logged, since viewing your own data isn't a privacy-sensitive reveal.
  • Admin's identity-reveal card and the partner-facing match card both show the name alongside the handle.

Review-feedback fixes (Copilot pass on this update)

  • first_name/last_name validators now .trim() before .min(1) so whitespace-only input is rejected.
  • max_age_gap's number-input handler guards NaN from an intermediate invalid value.
  • match-summary.service.ts filters parsed LLM pros/cons arrays to non-empty strings before persisting.
  • Renaming Jerusalem/Tel Aviv/Haifa's country label (Israel → Palestine) in the city list briefly used a LOCATION_ALIASES canonicalization 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 workspaces
  • bun run test — API 518 pass / 0 fail, frontend 267 pass / 0 fail
  • CI (Type-check, Test, Build, CodeQL, Trivy, Gitleaks, GitGuardian) all green on the latest push

Update: 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

  • The existing embedding+cosine pipeline (scorer.ts) is unchanged and still does the cheap O(N) shortlisting across the whole applicant pool — it's good at that.
  • New 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.
  • Cached per applicant (Mongo match_reranks collection, 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 through proposals.tsmatch.service.tsMatchDoc.score → the frontend) keeps its name, shape, and 0–1 scale — only what computes it changed. Zero frontend changes were needed.
  • runFullMatchingPass batches rerank calls at a concurrency of 5 (was previously calling cheap embedding math only, now makes real LLM calls) and the underlying fetch has 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)

  • generateChatCompletion gained { temperature, responseSchema } options (Structured Outputs, honored by both OpenAI and LM Studio) instead of a maxTokens override that risked truncating output mid-JSON — length is now steered via prompt instructions instead.
  • truncateForPrompt bounds free-text answer fields before they reach any prompt; match-summary/icebreaker now use low temperature + structured output for more grounded, reliably-parseable results.
  • updateMyAnswers skips re-embedding when a profile edit doesn't touch an embedding-relevant field (most edits don't).
  • Shared buildProfileSnippet helper extracted out of match-summary.service.ts once a third near-identical copy was about to be needed in match-rerank.service.ts.

Test plan (this update)

  • bun run typecheck — exit 0, both workspaces
  • bun run test:api — 540 pass, 0 fail
  • Verified in a fresh clean-clone simulation (git clone to a scratch dir, bun install, typecheck + full suite) — every commit in the range individually builds and tests green, not just the final HEAD

…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).
- 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
@GravityDarkLab GravityDarkLab self-assigned this Jun 18, 2026
@GravityDarkLab GravityDarkLab added the enhancement New feature or request label Jun 18, 2026
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.
@GravityDarkLab GravityDarkLab marked this pull request as ready for review June 19, 2026 07:58
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).
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants