Appearance
Recommendation matching engine
src/lib/server/recommend.ts is the core matcher of the Recommendation & Right-Sizing capability (#4). Given a buyer's selection answers, it evaluates the admin-authored rules (#59) and returns a deterministically ranked set of candidate systems, each carrying the rule(s) that recommended it and their rationale. The cost-tier right-sizing layer (#61) then steers this toward the most cost-appropriate option; the guided flow / API (#62) exposes it.
The engine is pure and driver-independent (like value typing): it takes already-read rules plus small product-side accessors, so it is unit-tested without a live D1 (recommend.spec.ts).
Inputs
ts
matchRecommendations(context, rules, opts) → { candidates: Candidate[] }| Input | Shape | Source |
|---|---|---|
context | { optionIds: string[] } | The buyer's selected factor options (guided flow). Order does not matter. |
rules | Rule[] | getRules(db) — already enabled and non-retired. |
opts.activeSystemIds | Set<string> | Non-retired system ids (from listSystems). Retired systems are never recommended. |
opts.attr | (systemId, attributeId) => TypedValue | undefined | A system's typed attribute value, for attribute-threshold conditions. |
Because getRules filters out disabled and retired rules, a rule that reaches the engine is fire-eligible by construction — enabling or disabling a rule (in #22) changes the recommendation with no code change.
How a rule contributes
A rule contributes its outcome systems when its conditions hold. Conditions come in two kinds (see the rule representation); all must hold:
option— tests the selection context: the buyer selected that factor option. A rule with no option conditions applies to every context (a global default rule).attribute— tests a candidate system's typed value againstoperator threshold(e.g.stc >= 50). It validates that a recommended system genuinely meets the requirement. A system that lacks the value (untested / N/A / none / empty) fails the condition and is dropped.
So option conditions decide whether a rule engages this buyer, and attribute conditions decide which of the rule's outcome systems actually qualify.
Attribute comparison
evaluateAttributeCondition(value, operator, threshold):
- Numeric (
>=,<=,>,<) — derive a number from the typed value (numericOf). A range uses the bound the operator cares about: the max for lower-bound tests (>=/>, "can it reach at least X?") and the min for upper-bound tests. Booleans → 1/0; cost tiers → an ordinal (Budget 0 … Ultra Premium 3); dimensions/text → their first number. includes— substring/label membership against the value's text or enum labels (case-insensitive).=/!=— numeric when both sides are numbers, else a normalized string compare.
Output & ranking
Each candidate:
ts
type Candidate = {
systemId: string;
role: 'preferred' | 'alternative'; // strongest role when several rules surface it
budgetTier: string | null; // from the rule outcome (systems.budget_tier)
matchedRuleIds: string[]; // best-priority first
rationales: string[]; // aligned to matchedRuleIds
};A system surfaced by multiple rules is merged once: the strongest role wins (preferred over alternative), and the rule ids / rationales are unioned (ordered by rule priority, then id).
Candidates are ordered deterministically by:
- role —
preferredbeforealternative; - best matched-rule
priority(lower first); - best outcome
rankwithin that rule (lower first); systemIdascending (final tie-break).
This ordering is total (the systemId tie-break guarantees no ambiguity) and is asserted directly in recommend.spec.ts.
Edge cases
- No match — a selection that engages no rule (or whose rules yield no qualifying system) returns
{ candidates: [] }; callers render an empty state, never an error. - Retired system in an outcome — silently skipped (not in
activeSystemIds), so a retired system never leaks into a recommendation even if a stale rule still lists it. - Missing attribute value — a thresholded system with no cell for that attribute fails the condition and is dropped, rather than being assumed to pass.
- Global rule — a rule with only attribute conditions engages every context; use it for a default recommendation, gate it with an option condition otherwise.
Worked example
Rule high-stc-prefer-acoustical: condition option: acoustic-priority-high andattribute: acoustic-rating-stc-oitc >= 50; outcome preferred nw-acoustical-645, alternatives nw-aluminum-640 / sl45. A buyer who selected acoustic priority: high gets those three systems (each verified at STC ≥ 50), NW Acoustical 645 ranked first as the preferred; the alternatives carry their budget_tier so #61 can surface the cheapest one that still clears the bar. A buyer who did not select high acoustic priority gets no acoustic recommendation from this rule.
Related
- Right-sizing rule representation — the rules this engine evaluates
- Data access layer —
getRules,listSystems - #61 — cost-tier right-sizing on top of these candidates
- Recommendation API contract (#62) — the consumable surface over these candidates (guided flow +
GET /api/recommendation)