§3 · Offer scoring engine

Every offer scored 0-100.

Scoring turns an unbounded stream of vendor offers into a ranked queue — and, at the extremes, decides without a human. This page is the full formula, with worked examples on the pink-sink running case.

For: anyone evaluating the quality flywheel or thinking about federated scoring.

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.ts
function 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.ts
function 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.ts
function 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.ts
function 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.ts
function 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.ts
function 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:

Pink sink · $280 · 2 day · 4.5★ · in stock
Price fitratio 0.70 → sweet spot+30
Attribute match3/3 matched+25
Delivery speedready_to_buy · ≤ 3d+15
Vendor rating4.5/5+14
In stocktrue+10
Alternativeon-spec+0
Total (clamped)30 + 25 + 15 + 14 + 10 + 094

Auto-accept

Three conditions, all required:

  1. The intent has a non-null autoApproveBelow.
  2. offer.price ≤ autoApproveBelow.
  3. score ≥ 70.
scoring/decide.ts
function 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