Why score at all
Without scoring, the buyer drowns. A popular intent gets twenty offers within a day; most of them will be noise — wrong product, price-fishing, unreliable vendors. Reading each one is a cost that scales with vendor count, not with answer quality.
With scoring, a ranked queue concentrates the buyer's attention at the top. Spam gets filtered before the human sees it. Vendors who consistently score low get auto-rejected, which is an incentive to submit better offers. Over time the average offer quality climbs — that's the flywheel.
The six factors
Five positive factors sum to 100. A single negative penalty applies to counter-offers. The final score is clamped to [0, 100]. Weights are opinions — a federated registry could tune them for a category (delivery_weight = 25 for perishables) without breaking the protocol.
Price fit · +30 pts
A band function over the price-to-budget-max ratio. The sweet spot (50-100% of budget max) gets the full 30. A small overage is still tolerated; large overages go to zero. Suspiciously cheap offers are capped — under half the budget may indicate a different product.
scoring/price.tsfunction pricePoints(offer, intent) {
if (!intent.budget?.max) return 15; // no budget → neutral
const r = offer.price / intent.budget.max;
if (r >= 0.5 && r <= 1.0) return 30; // sweet spot
if (r < 0.5) return 20; // suspiciously cheap
if (r <= 1.10) return 20; // 10% over
if (r <= 1.25) return 10; // 25% over
return 0; // too expensive
}Worked example. Pink-sink intent has budget.max = 400. A $280 offer → ratio 0.70 → 30 points.
Attribute match · +25 pts
Two modes. If the vendor self-reports matchPercentage, trust it (scaled to 25). Otherwise compute exact-value overlap of intent attribute keys vs offer attribute keys. No intent attributes → neutral 12.
scoring/attributes.tsfunction attributePoints(offer, intent) {
if (offer.matchPercentage != null) {
return Math.round((offer.matchPercentage / 100) * 25);
}
const intentKeys = Object.keys(intent.attributes ?? {});
if (intentKeys.length === 0) return 12;
const matches = intentKeys.filter(
(k) => offer.attributes?.[k] === intent.attributes[k]
).length;
return Math.round((matches / intentKeys.length) * 25);
}Worked example. Intent requires {material: porcelain, color: pink, size_inches: 30}. Offer matches material + color but not size → 2/3 → 17 points.
Delivery speed · +15 pts
ready_to_buy buyers reward fast delivery aggressively. planning buyers care less about days. browsing buyers barely care — a flat 12.
scoring/delivery.tsfunction deliveryPoints(offer, intent) {
const d = offer.deliveryDays ?? 99;
switch (intent.urgency) {
case "ready_to_buy":
return d <= 3 ? 15 : d <= 7 ? 10 : d <= 14 ? 5 : 0;
case "planning":
return d <= 14 ? 15 : 10;
case "browsing":
return 12;
default:
return 7;
}
}Worked example. Pink-sink intent is ready_to_buy. A 2-day delivery offer → 15 points.
Vendor rating · +15 pts
Trailing 15 points. Unknown vendors default to a neutral 5 — not penalized, not trusted.
scoring/rating.tsfunction ratingPoints(vendor) {
if (vendor.rating == null) return 5;
return Math.round((vendor.rating / 5) * 15);
}Worked example. Vendor has rating 4.5/5 → round((4.5/5) * 15) = 14 points.
In stock · +10 pts
A boolean. Out-of-stock offers are still allowed into the pool but drop 10 points for being effectively later in the queue.
scoring/stock.tsfunction stockPoints(offer) {
return offer.inStock ? 10 : 0;
}Worked example. Offer marks inStock: true → 10 points.
Alternative (counter-offer) · −5 pts
Counter-offers are legal and useful, but shouldn't outrank on-spec offers. A small fixed penalty reflects the extra buyer cost of evaluating a substitute.
scoring/alternative.tsfunction alternativePenalty(offer) {
return offer.isAlternative ? -5 : 0;
}Worked example. A vendor proposes a white porcelain sink instead of pink. isAlternative: true → -5.
Total
Final score is the sum of the six outputs, clamped: clamp(price + attributes + delivery + rating + stock + alternative, 0, 100). A reference worked example on the pink-sink intent at $280 with all attributes matched, 2-day delivery, 4.5-star vendor, in stock, on-spec:
| Price fit | ratio 0.70 → sweet spot | +30 |
| Attribute match | 3/3 matched | +25 |
| Delivery speed | ready_to_buy · ≤ 3d | +15 |
| Vendor rating | 4.5/5 | +14 |
| In stock | true | +10 |
| Alternative | on-spec | +0 |
| Total (clamped) | 30 + 25 + 15 + 14 + 10 + 0 | 94 |
Auto-accept
Three conditions, all required:
- The intent has a non-null
autoApproveBelow. offer.price ≤ autoApproveBelow.score ≥ 70.
scoring/decide.tsfunction decide(offer: Offer, intent: Intent, score: number): Decision {
if (
intent.autoApproveBelow != null &&
offer.price <= intent.autoApproveBelow &&
score >= 70
) {
return { status: "accepted", score };
}
if (score < 20) {
return { status: "rejected", score, reason: "low_score" };
}
if (offer.price > intent.budget.max * 1.5) {
return { status: "rejected", score, reason: "price_too_high" };
}
if (violatesMaterialExclude(offer, intent)) {
return { status: "rejected", score, reason: "material_exclude" };
}
return { status: "pending", score };
}Auto-reject
Any one of three conditions triggers a 422 — the offer is never stored:
score < 20— the offer is too far off-spec to be worth the buyer's time.price > budget.max × 1.5— well out of any reasonable range.material_exclude— a vendor proposed a material the buyer explicitly excluded. The offer's description or attributes contain that value.
The vendor gets a structured 422 response with the reason, so they can fix the offer. Rejection is feedback, not silence.
Where this goes next
Rule-based scoring is the floor. The roadmap describes the learned model — a classifier trained on acceptance outcomes that subsumes the six factors. The free tier stays rule-based and fully inspectable; premium uses the learned model for the same reason you sort Amazon by relevance instead of price.
scoring.ts on GitHub