← back to Rentv Adintel

lib/scoring.js

57 lines

'use strict';
/**
 * Advertising Opportunity Score (spec §13).
 * Ranks RENTV *sales opportunity* — NOT personal worth, NOT a secret ad-spend
 * estimate. Every factor is transparent and the weights are overridable by an
 * admin. Do not relabel the output as an ad-spend estimate.
 */

// Default weights — must sum to 1.0. Admin-overridable (§13).
const DEFAULT_WEIGHTS = Object.freeze({
  verifiedAdvertising: 0.22,
  verifiedConferenceSpendSignal: 0.15,
  recency: 0.12,
  repeatActivity: 0.1,
  californiaFit: 0.1,
  arizonaFit: 0.05,
  categoryFit: 0.08,
  rentvAudienceFit: 0.08,
  contactCompleteness: 0.04,
  evidenceQuality: 0.06,
});

const FACTOR_KEYS = Object.freeze(Object.keys(DEFAULT_WEIGHTS));

const clampScore = (value) => Math.max(0, Math.min(100, Number(value) || 0));

/**
 * @param {Object} input  each factor 0-100
 * @param {Object} [weights]  optional weight overrides (partial ok)
 * @returns {number} integer 0-100
 */
function calculateAdvertiserOpportunityScore(input, weights) {
  const w = { ...DEFAULT_WEIGHTS, ...(weights || {}) };
  let total = 0;
  for (const k of FACTOR_KEYS) total += clampScore(input[k]) * w[k];
  return Math.round(total);
}

/** Returns per-factor contribution so the UI can "Show Me Why" (§25). */
function explainScore(input, weights) {
  const w = { ...DEFAULT_WEIGHTS, ...(weights || {}) };
  const parts = FACTOR_KEYS.map((k) => {
    const value = clampScore(input[k]);
    const weight = w[k];
    return { factor: k, value, weight, contribution: Math.round(value * weight * 100) / 100 };
  });
  return { score: calculateAdvertiserOpportunityScore(input, weights), factors: parts, weights: w };
}

module.exports = {
  DEFAULT_WEIGHTS,
  FACTOR_KEYS,
  clampScore,
  calculateAdvertiserOpportunityScore,
  explainScore,
};