Docs / Admin

Matching and scoring configuration

Summary — Admins define a set of matching dimensions — each pairing an opportunity field with an investor-profile field, a match mode, a weight, and an optional threshold — that the scoring engine uses whenever staff click "Find Investors." This is the only place scoring rules are configured; nothing about scoring is hardcoded in the matching UI.

Purpose — Understand each field on a matching dimension, the four match modes and exactly how each scores a candidate, how weights and thresholds combine into a composite score, and how to manage dimensions from the admin config page.

Audience — Admin role only (route requires admin via the (authenticated)/admin layout).

Prerequisites — None to configure; investor profiles and opportunities should exist before dimensions are meaningfully exercised — see Investor-opportunity matching and B2B meetings.


Overview

Path: Admin → Matching → Scoring Configuration (/admin/matching/config).

Key concepts:

  • Matching dimension — One row: dimension (display name), sourceField (dot-path into the opportunity/organization object), targetField (dot-path into the investor profile object), matchMode, weight (0.0–1.0), enabled, threshold (optional 0–100), isArchived.
  • Match mode — The comparison function applied to the source/target field pair: exact_in_array, array_overlap, range, exact_equal.
  • Weight — How much this dimension contributes to the composite score, relative to other enabled dimensions.
  • Threshold — An optional per-dimension minimum raw score (0–100); if a candidate scores below it on this dimension, the candidate is disqualified entirely (not just penalized).

Screenshot: Scoring Configuration page showing the dimensions table (Dimension, Source Field, Target Field, Mode, Weight, Threshold, Enabled, Actions) and the "Add Dimension" form below it.


Match modes — exact behavior

Implemented in src/lib/modules/matching/scoring.ts. All modes return a raw score from 0–100, or -1 to mean "skip this dimension" (missing/invalid data — its weight is excluded from the composite rather than penalizing the candidate).

matchModeReadsScoring logic
exact_in_arraysource = scalar value, target = array100 if the (lowercased) source value is present in the (lowercased) target array; else 0. -1 if source is null or target isn't an array.
array_overlapsource = array, target = arrayJaccard-style overlap: round(intersection / union * 100). -1 if either isn't an array, or if both arrays are empty.
rangesource = scalar number, target = the profile's min_<targetField> / max_<targetField> fields100 if the value falls within [min, max]; 50 if within a 2x-expanded band (min/2 to max*2); 0 otherwise. -1 if the source isn't numeric or both min/max are absent.
exact_equalsource = scalar, target = scalar100 if the lowercased string values are equal; else 0. -1 if either side is null.

Important on range mode: the targetField you configure is a base name, not the literal profile field — the engine looks up min_{targetField} and max_{targetField} on the investor profile object. For example, targetField: "investment_usd" reads min_investment_usd and max_investment_usd from the profile.

Field paths use dot notation and are resolved with a small path-walker (resolveField) — e.g. organization.cluster reads the cluster key nested under an organization object attached to the scoring source (populated from the opportunity's linked organization's country, cluster, and name). The investor-profile side is a flat object built by profileToScoringTarget() with snake_case keys: sectors_of_interest, investment_types, min_investment_usd, max_investment_usd, target_geographies, target_clusters.


How the composite score is calculated

scoreAllCandidates(dimensions, source, targets, globalMinScore?):

  1. Filters to dimensions where enabled = true and isArchived = false. If none remain, returns an empty list (no matches are created).
  2. For each candidate (investor profile), scores every enabled dimension in turn:
    • If the raw score is -1 (missing data), the dimension is skipped — its weight does not count toward the denominator.
    • If the dimension has a threshold and the raw score is below it, the candidate is disqualified entirely and dropped from results (not just this dimension).
    • Otherwise, the raw score is added to a running weighted sum (raw * weight) and the weight is added to a running total.
  3. If no dimensions contributed (totalActiveWeight === 0), the candidate is dropped.
  4. Composite score = round(totalWeightedScore / totalActiveWeight).
  5. If globalMinScore is passed and the composite is below it, the candidate is dropped. (Not currently passed by any caller reviewed — generateMatches/generateMatchesForInvestor call scoreAllCandidates without a globalMinScore.)
  6. Remaining candidates are sorted by score, highest first.

Practical implication of weight not summing to 1.0: because the composite divides by totalActiveWeight (not by 1.0), weights that don't sum to 1.0 are effectively normalized per-candidate among the dimensions that actually produced a score for that candidate. The config page still nudges admins toward a sum of 1.0 for predictability — see below.


Managing dimensions on the config page

The page shows current total weight of enabled dimensions, colored green if it equals exactly 1 and orange otherwise: "Weights should sum to 1.0. Current total: {total}".

Add Dimension form fields:

FieldNotes
Name (dimension)Free text, e.g. esg_score. Required, 1–100 chars.
Match ModeOne of the four modes above.
Source Field (opportunity)e.g. sector. Required, 1–200 chars.
Target Field (investor)e.g. sectors_of_interest. Required, 1–200 chars.
Weight (0–1)Required, defaults to 0.2 in the form.
Threshold (optional)0–100. Leave blank for no disqualification floor.

Submitting calls createMatchingDimensionActionservices.createMatchingDimension, validated by createMatchingDimensionSchema (Zod), and logs matching_config.created to the audit log with { dimension, weight, matchMode }.

Editing an existing dimension: inline forms per row.

  • Weight — a number input + Save button, submits just that field.
  • Enabled toggle — a button that flips enabled (labeled "On"/"Off", styled green/gray).
  • Both go through updateMatchingDimensionActionservices.updateMatchingDimension, which logs matching_config.updated with before/after { weight, enabled }.

Deleting a dimension: the "Delete" button calls archiveMatchingDimensionActionservices.archiveMatchingDimension, which sets isArchived = true (soft delete, consistent with the project's soft-delete rule) and logs matching_config.archived. Archived dimensions are excluded from scoring (!d.isArchived check in scoreAllCandidates) but the code reviewed does not show them being filtered out of the config table listing itself — getMatchingConfig() is called directly with no archived filter on this page.

Note: sourceField, targetField, and matchMode are not editable inline on this page — only weight and enabled have dedicated update forms. Changing the field mapping or match mode of an existing dimension would need direct use of updateMatchingDimensionAction with those fields, which the schema (updateMatchingDimensionSchema, a .partial() of the create schema) does support.


Roles and permissions

ActionMinimum role
View scoring configuration pageadmin (the entire /admin/* tree requires admin via requireRole("admin") in the admin layout)
Create / update / archive a matching dimensionadmin (requireRole("admin", { module: "matching" }) in each action)

There is no reviewer/lead/staff access to this page or its actions — scoring configuration is admin-only.


Best practices

  1. Configure dimensions before staff start clicking Find Investors. With zero enabled dimensions, every generation run returns no matches.
  2. Aim for weights summing to 1.0 for predictable, comparable composite scores across opportunities — the page's total-weight indicator is there specifically to catch drift.
  3. Use threshold sparingly and deliberately. A threshold is a hard disqualifier, not a soft penalty — a candidate that would otherwise average out to a decent composite score is dropped entirely if it fails one thresholded dimension.
  4. Double-check range mode's target field naming. The engine expects min_<targetField> / max_<targetField> on the investor profile — a mismatched base name silently returns -1 (dimension skipped) rather than an error.
  5. Disable, don't archive, a dimension you may reuse. Archiving is intended as the durable "no longer valid" state; disabling is the reversible on/off toggle for temporary exclusion.

Warnings

Missing data silently skips a dimension rather than scoring it 0. A dimension whose source or target field resolves to null/undefined (or the wrong type for its mode) does not penalize the candidate — it is excluded from both the numerator and denominator of the composite calculation.

A threshold miss drops the whole candidate, not just the dimension. This is a common source of "why did this obviously good investor not show up in matches" — check whether a thresholded dimension disqualified them.

Editing sourceField/targetField/matchMode has no dedicated UI control on the reviewed config page — only weight and enabled/disabled have inline forms.


Troubleshooting

No matches appear after clicking "Find Investors"

Symptom: generateMatches returns zero matches, or the matching page shows no rows for a freshly generated opportunity.

Cause: Either no dimensions are enabled (and not archived), or every investor profile is being disqualified by a threshold, or the composite active weight is zero for every candidate (all dimensions returned -1 for missing data).

Fix: Check the Scoring Configuration page — confirm at least one dimension is enabled, and temporarily raise or remove thresholds to see if candidates reappear.


Weight total shows orange instead of green

Symptom: The "Current total" indicator under the page title is orange, not green.

Cause: The sum of weight across all enabled dimensions does not equal exactly 1.

Fix: Adjust individual dimension weights (inline Save per row) until the total reads 1.00. This is advisory only — scoring still runs and normalizes by active weight regardless.


FAQ

What happens if I set a dimension's weight to 0? It contributes nothing to the composite score even if enabled — effectively inert but still evaluated (and still subject to its threshold, if any).

Can I have two dimensions with the same sourceField? Yes — nothing in the code prevents duplicate field mappings across dimensions with different dimension names, modes, or weights.

Does disabling a dimension delete its historical score data? No. Historical Match.scoreBreakdown values are stored at generation time and are not recalculated retroactively when a dimension's config changes.

Is there a way to test a dimension before enabling it broadly? Not in the code reviewed — there is no scoring "preview" or dry-run mode. Testing means enabling the dimension and reviewing the resulting scores/breakdowns on /matching.


Related articles