/home/alixis5/integrate.sellofinance.com/src/Services
Edit: /home/alixis5/integrate.sellofinance.com/src/Services/MeritRanker.php (6279B)
invNormalize($minSalary, $salaryMin, $salaryMax);
$scoreBreadth = $this->invNormalize($minScore, $scoreMin, $scoreMax);
return 0.6 * $salaryBreadth + 0.4 * $scoreBreadth;
}
/**
* Linearly map x to [0,1], then invert. Returns 0.5 when range is flat.
*/
private function invNormalize(int $x, int $min, int $max): float
{
if ($max === $min) return 0.5;
$t = ($x - $min) / ($max - $min);
return max(0.0, min(1.0, 1.0 - $t));
}
/**
* Freshness score: exp(-ageDays / TAU).
*/
public function freshness(int $ageDays): float
{
return exp(-$ageDays / self::FRESHNESS_TAU_DAYS);
}
/**
* Combined score.
*/
public function score(float $rate, float $eligibility, float $freshness): float
{
return self::WEIGHT_RATE * $rate
+ self::WEIGHT_ELIGIBILITY * $eligibility
+ self::WEIGHT_FRESHNESS * $freshness;
}
/**
* Apply per-institution diversity cap. Products must already be sorted
* by _score desc.
*
* @param array
> $sortedProducts
* @return array>
*/
public function applyDiversityCap(array $sortedProducts, int $limitPerInstitution = 3, int $finalLimit = 5): array
{
$picked = [];
$perInstitution = [];
foreach ($sortedProducts as $p) {
$acc = $p['accountId'] ?? '';
if (($perInstitution[$acc] ?? 0) >= $limitPerInstitution) {
continue;
}
$picked[] = $p;
$perInstitution[$acc] = ($perInstitution[$acc] ?? 0) + 1;
if (count($picked) >= $finalLimit) break;
}
return $picked;
}
/**
* Full pipeline: score every product, sort desc by score (ties by
* modifiedAt desc, then id asc), apply diversity cap, return top-K.
*
* @param array> $products
* @return array>
*/
public function rank(array $products, \DateTimeImmutable $now, int $limit = 5): array
{
// Category averages for rate competitiveness
$byCategory = [];
foreach ($products as $p) {
$cid = $p['categoryId'] ?? '';
$mid = (((float)($p['profitRateMin'] ?? 0)) + ((float)($p['profitRateMax'] ?? 0))) / 2;
$byCategory[$cid][] = $mid;
}
$categoryAvg = [];
foreach ($byCategory as $cid => $rates) {
$categoryAvg[$cid] = count($rates) ? array_sum($rates) / count($rates) : 0.0;
}
// ProductType ranges for eligibility breadth
$byType = [];
foreach ($products as $p) {
$t = $p['productType'] ?? '';
$byType[$t]['salaries'][] = (int)($p['minSalary'] ?? 0);
$byType[$t]['scores'][] = (int)($p['minCreditScore'] ?? 0);
}
$typeRange = [];
foreach ($byType as $t => $vals) {
$typeRange[$t] = [
'salaryMin' => min($vals['salaries']),
'salaryMax' => max($vals['salaries']),
'scoreMin' => min($vals['scores']),
'scoreMax' => max($vals['scores']),
];
}
// Score each
$scored = [];
foreach ($products as $p) {
$cid = $p['categoryId'] ?? '';
$t = $p['productType'] ?? '';
$mid = (((float)($p['profitRateMin'] ?? 0)) + ((float)($p['profitRateMax'] ?? 0))) / 2;
$rateC = $this->rateCompetitiveness($mid, $categoryAvg[$cid] ?? 0);
$range = $typeRange[$t] ?? ['salaryMin' => 0, 'salaryMax' => 0, 'scoreMin' => 0, 'scoreMax' => 0];
$elig = $this->eligibilityBreadth(
(int)($p['minSalary'] ?? 0), $range['salaryMin'], $range['salaryMax'],
(int)($p['minCreditScore'] ?? 0), $range['scoreMin'], $range['scoreMax'],
);
$created = $p['createdAt'] ?? '';
$ageDays = 0;
if ($created !== '') {
$createdDt = new \DateTimeImmutable($created);
$ageDays = max(0, (int) floor(($now->getTimestamp() - $createdDt->getTimestamp()) / 86400));
}
$fresh = $this->freshness($ageDays);
$scoreV = $this->score($rateC, $elig, $fresh);
$p['_score'] = $scoreV;
$scored[] = $p;
}
// Sort: score desc, then modifiedAt desc, then id asc
usort($scored, function ($a, $b) {
if ($b['_score'] !== $a['_score']) return $b['_score'] <=> $a['_score'];
$am = $a['modifiedAt'] ?? '';
$bm = $b['modifiedAt'] ?? '';
if ($bm !== $am) return $bm <=> $am;
return ($a['id'] ?? '') <=> ($b['id'] ?? '');
});
return $this->applyDiversityCap($scored, limitPerInstitution: 3, finalLimit: $limit);
}
}