llm-checker/tests/scoring-unification.test.js
signerless 55b0c3bf4b fix(#88): unify check/recommend/smart-recommend on one scoring core
The three recommendation surfaces each ran a DIFFERENT scoring engine, so
they returned inconsistent recommendations, and only `recommend` had the
PR #89 high-capacity right-sizing fix. On high-end hardware (e.g. dual
RTX PRO 6000 / ~192GB) `check` and `smart-recommend` still ranked tiny
2B-8B models above large models.

Unification approach (DeterministicModelSelector is the canonical core):

- New src/models/scoring-core.js exposes `rankModels(models, hardware,
  options)` backed by DeterministicModelSelector.selectModels (which already
  carries calculateHighCapacitySizeAdjustment / getHighCapacitySizeTarget).
  It normalizes any model shape (ExpandedModelsDatabase rows, sql.js variant
  rows, Ollama scrape models) into the deterministic model shape and keeps a
  `__source` back-reference so callers can map results to their display shape.

- `check` (MultiObjectiveSelector.selectBestModels) now ranks via the core
  and maps results back into its {compatible, marginal, incompatible} shape
  with totalScore/components/reasoning preserved. The legacy multi-objective
  math is retained as `selectBestModelsLegacy`, used only as a defensive
  fallback if the core throws.

- `smart-recommend` (IntelligentSelector.recommend) keeps ScoringEngine for
  the per-variant score object shape used by display, but re-orders the
  scored list and overwrites each item's `score.final` using the canonical
  core (applyUnifiedRanking). ScoringEngine internals are untouched.

Each command keeps its own model SOURCE; only the ranking/selection is
unified. The high-capacity floor now applies on all three paths, and
identical (model, hardware) inputs yield the same ordering everywhere.

Tests: full suite 36/36 (was 35/35). New tests/scoring-unification.test.js
(registered under Recommendations) drives all three selection entrypoints
directly and asserts (a) cross-path consistency of the top pick's size
class, (b) high-capacity floor everywhere (no sub-30B #1 pick on 192GB for
general/coding/reasoning), and (c) small-hardware sanity (small model on an
8GB CPU-only profile across all three paths). No existing test was modified.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 21:08:56 +02:00

318 lines
13 KiB
JavaScript

/**
* Scoring Unification Integration Test (GitHub issue #88)
* ======================================================
*
* Proves the root-cause fix: the three recommendation surfaces
* (`check`, `recommend`, `smart-recommend`) now derive their ranking from ONE
* canonical scoring core (DeterministicModelSelector via scoring-core), so:
*
* a. CONSISTENCY: all three agree on the top pick's size class for the same
* logical (model pool, hardware) input.
* b. HIGH-CAPACITY FLOOR EVERYWHERE: on a 192GB dual-RTX-PRO-6000 profile none
* of the three paths returns a sub-30B model as its #1 pick for
* general/coding/reasoning.
* c. SMALL-HARDWARE SANITY: on an 8GB CPU-only profile every path still
* prefers a small (<30B, and in practice <=8B) model — i.e. the fix did
* not just force "always big".
*
* Each command consumes a DIFFERENT model shape, so we build the SAME logical
* models in each path's native shape and call each path's selection entrypoint
* directly (no live Ollama / sql.js dependency).
*/
const assert = require('assert');
const MultiObjectiveSelector = require('../src/ai/multi-objective-selector');
const DeterministicModelSelector = require('../src/models/deterministic-selector');
const IntelligentSelector = require('../src/models/intelligent-selector');
// ---------------------------------------------------------------------------
// Hardware profiles
// ---------------------------------------------------------------------------
// The dual RTX PRO 6000 / ~192GB VRAM case from issue #88.
function buildHighCapacityHardware() {
return {
cpu: { brand: 'AMD Threadripper PRO', cores: 32, physicalCores: 32, speed: 4.0, architecture: 'x86_64' },
gpu: {
model: 'NVIDIA RTX PRO 6000 Blackwell Workstation Edition',
vendor: 'NVIDIA',
type: 'nvidia',
vram: 192,
vramPerGPU: 96,
gpuCount: 2,
isMultiGPU: true,
dedicated: true
},
memory: { total: 192, totalGB: 192 },
acceleration: { supports_metal: false, supports_cuda: true, supports_rocm: false },
os: { platform: 'linux' },
summary: {
bestBackend: 'cuda',
gpuModel: 'NVIDIA RTX PRO 6000 Blackwell Workstation Edition',
effectiveMemory: 180,
systemRAM: 192,
totalVRAM: 192,
isMultiGPU: true,
gpuCount: 2,
hasDedicatedGPU: true,
hasIntegratedGPU: false,
hardwareTier: 'very_high',
speedCoefficient: 320
}
};
}
// A small laptop: 8GB RAM, CPU-only.
function buildSmallHardware() {
return {
cpu: { brand: 'Intel Core i5-1135G7', cores: 4, physicalCores: 4, speed: 2.4, architecture: 'x86_64' },
gpu: { model: '', vram: 0, dedicated: false },
memory: { total: 8, totalGB: 8 },
acceleration: { supports_metal: false, supports_cuda: false, supports_rocm: false },
os: { platform: 'linux' },
summary: {
bestBackend: 'cpu',
gpuModel: '',
effectiveMemory: 8,
systemRAM: 8,
totalVRAM: 0,
hasDedicatedGPU: false,
hasIntegratedGPU: false,
hardwareTier: 'low',
speedCoefficient: 40
}
};
}
// ---------------------------------------------------------------------------
// Representative model pool (same logical models, three native shapes).
// Each category has a small (<=8B) and a large (>=30B) member so every path is
// forced to make a real size choice.
// ---------------------------------------------------------------------------
// `check` shape: ExpandedModelsDatabase-style rows.
function buildCheckPool() {
const mk = (name, sizeB, gb, ctx, cat, quant = ['Q4_K_M']) => ({
name,
size: `${sizeB}B`,
type: 'local',
category: cat,
requirements: { ram: Math.ceil(gb + 2), vram: 0, cpu_cores: 2, storage: gb },
frameworks: ['ollama'],
quantization: quant,
performance: { context_length: ctx },
specialization: cat,
installation: { ollama: `ollama pull ${name}` },
// size_gb hint so memory math is realistic regardless of param parsing.
size_gb: gb
});
return [
mk('uni-chat 8B', 8, 5.0, 65536, 'general'),
mk('uni-chat 70B', 70, 42, 65536, 'general'),
mk('uni-coder 8B', 8, 5.0, 32768, 'coding'),
mk('uni-coder 34B', 34, 20.5, 32768, 'coding'),
mk('uni-reason 7B', 7, 4.8, 65536, 'reasoning'),
mk('uni-reason 70B', 70, 43, 65536, 'reasoning')
];
}
// `recommend` shape: Ollama scrape-style models with variants.
function buildRecommendPool() {
return [
{
model_identifier: 'uni-chat',
model_name: 'uni-chat',
primary_category: 'chat',
context_length: '64K',
variants: [
{ tag: 'uni-chat:8b', size: '8b', quantization: 'Q4_0', real_size_gb: 5.0, categories: ['chat'] },
{ tag: 'uni-chat:70b', size: '70b', quantization: 'Q4_0', real_size_gb: 42, categories: ['chat'] }
],
tags: ['uni-chat:8b', 'uni-chat:70b'],
use_cases: ['chat']
},
{
model_identifier: 'uni-coder',
model_name: 'uni-coder',
primary_category: 'coding',
context_length: '32K',
variants: [
{ tag: 'uni-coder:8b', size: '8b', quantization: 'Q4_0', real_size_gb: 5.0, categories: ['coding'] },
{ tag: 'uni-coder:34b', size: '34b', quantization: 'Q4_0', real_size_gb: 20.5, categories: ['coding'] }
],
tags: ['uni-coder:8b', 'uni-coder:34b'],
use_cases: ['coding', 'programming']
},
{
model_identifier: 'uni-reason',
model_name: 'uni-reason',
primary_category: 'reasoning',
context_length: '64K',
variants: [
{ tag: 'uni-reason:7b', size: '7b', quantization: 'Q4_0', real_size_gb: 4.8, categories: ['reasoning'] },
{ tag: 'uni-reason:70b', size: '70b', quantization: 'Q4_0', real_size_gb: 43, categories: ['reasoning'] }
],
tags: ['uni-reason:7b', 'uni-reason:70b'],
use_cases: ['reasoning', 'math']
}
];
}
// `smart-recommend` shape: sql.js variant rows.
function buildSmartVariants() {
return [
{ model_id: 'uni-chat', tag: 'uni-chat:8b', params_b: 8, size_gb: 5.0, quant: 'Q4_K_M', context_length: 65536, capabilities: 'chat', family: 'llama3.2', pulls: 5000000 },
{ model_id: 'uni-chat', tag: 'uni-chat:70b', params_b: 70, size_gb: 42, quant: 'Q4_K_M', context_length: 65536, capabilities: 'chat', family: 'llama3.3', pulls: 5000000 },
{ model_id: 'uni-coder', tag: 'uni-coder:8b', params_b: 8, size_gb: 5.0, quant: 'Q4_K_M', context_length: 32768, capabilities: 'coding', family: 'qwen2.5-coder', pulls: 5000000 },
{ model_id: 'uni-coder', tag: 'uni-coder:34b', params_b: 34, size_gb: 20.5, quant: 'Q4_K_M', context_length: 32768, capabilities: 'coding', family: 'qwen2.5-coder', pulls: 5000000 },
{ model_id: 'uni-reason', tag: 'uni-reason:7b', params_b: 7, size_gb: 4.8, quant: 'Q4_K_M', context_length: 65536, capabilities: 'reasoning', family: 'deepseek-r1', pulls: 5000000 },
{ model_id: 'uni-reason', tag: 'uni-reason:70b', params_b: 70, size_gb: 43, quant: 'Q4_K_M', context_length: 65536, capabilities: 'reasoning', family: 'deepseek-r1', pulls: 5000000 }
];
}
// ---------------------------------------------------------------------------
// Per-path top-pick params extraction
// ---------------------------------------------------------------------------
function parseParamsB(text) {
const match = String(text || '').match(/(\d+(?:\.\d+)?)\s*b\b/i);
return match ? parseFloat(match[1]) : null;
}
async function checkTopParamsB(hardware, category) {
const selector = new MultiObjectiveSelector();
const result = await selector.selectBestModels(hardware, buildCheckPool(), category, 20);
const top = result.compatible[0] || result.marginal[0];
assert.ok(top, `check should return a ranked model for category ${category}`);
// ExpandedModelsDatabase rows keep their name/size; derive params from them.
const params = selector.estimateModelParams(top) || parseParamsB(top.size) || parseParamsB(top.name);
return { params, label: top.name };
}
async function recommendTopParamsB(hardware, category) {
const selector = new DeterministicModelSelector();
selector.getInstalledModels = async () => [];
const recommendations = await selector.getBestModelsForHardware(hardware, buildRecommendPool(), { runtime: 'ollama' });
const top = recommendations[category] && recommendations[category].bestModels[0];
assert.ok(top, `recommend should return a ranked model for category ${category}`);
const params = Number(top.size) || parseParamsB(top.model_identifier);
return { params, label: top.model_identifier };
}
function makeStubDetector(hardware) {
return {
detect: async () => hardware,
getHardwareDescription: () => 'unit-test hardware',
getHardwareTier: () => (hardware.summary && hardware.summary.hardwareTier) || 'medium',
getMaxModelSize: () => (hardware.summary && hardware.summary.effectiveMemory) || 8
};
}
async function smartTopParamsB(hardware, useCase) {
const selector = new IntelligentSelector({ detector: makeStubDetector(hardware) });
const recommendations = await selector.recommend(buildSmartVariants(), {
useCase,
limit: 10,
policyFile: null
});
const top = recommendations.topPicks.best;
assert.ok(top && top.variant, `smart-recommend should return a top pick for use case ${useCase}`);
const params = top.variant.params_b || parseParamsB(top.variant.tag);
return { params, label: top.variant.tag };
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
const HIGH_CAPACITY_CATEGORIES = ['general', 'coding', 'reasoning'];
async function testHighCapacityFloorEverywhere() {
const hardware = buildHighCapacityHardware();
for (const category of HIGH_CAPACITY_CATEGORIES) {
const check = await checkTopParamsB(hardware, category);
const recommend = await recommendTopParamsB(hardware, category);
const smart = await smartTopParamsB(hardware, category);
assert.ok(
check.params >= 30,
`[check] ${category}: top pick should be >=30B on 192GB hardware, got ${check.label} (${check.params}B)`
);
assert.ok(
recommend.params >= 30,
`[recommend] ${category}: top pick should be >=30B on 192GB hardware, got ${recommend.label} (${recommend.params}B)`
);
assert.ok(
smart.params >= 30,
`[smart-recommend] ${category}: top pick should be >=30B on 192GB hardware, got ${smart.label} (${smart.params}B)`
);
}
}
async function testConsistencyAcrossPaths() {
const hardware = buildHighCapacityHardware();
for (const category of HIGH_CAPACITY_CATEGORIES) {
const check = await checkTopParamsB(hardware, category);
const recommend = await recommendTopParamsB(hardware, category);
const smart = await smartTopParamsB(hardware, category);
const sizeClass = (p) => (p >= 30 ? 'large' : 'small');
const checkClass = sizeClass(check.params);
const recommendClass = sizeClass(recommend.params);
const smartClass = sizeClass(smart.params);
assert.strictEqual(
checkClass,
recommendClass,
`[${category}] check (${check.label} ${check.params}B) and recommend (${recommend.label} ${recommend.params}B) should agree on size class`
);
assert.strictEqual(
recommendClass,
smartClass,
`[${category}] recommend (${recommend.label} ${recommend.params}B) and smart-recommend (${smart.label} ${smart.params}B) should agree on size class`
);
}
}
async function testSmallHardwarePrefersSmallEverywhere() {
const hardware = buildSmallHardware();
for (const category of HIGH_CAPACITY_CATEGORIES) {
const check = await checkTopParamsB(hardware, category);
const recommend = await recommendTopParamsB(hardware, category);
const smart = await smartTopParamsB(hardware, category);
assert.ok(
check.params < 30,
`[check] ${category}: small hardware should prefer a small model, got ${check.label} (${check.params}B)`
);
assert.ok(
recommend.params < 30,
`[recommend] ${category}: small hardware should prefer a small model, got ${recommend.label} (${recommend.params}B)`
);
assert.ok(
smart.params < 30,
`[smart-recommend] ${category}: small hardware should prefer a small model, got ${smart.label} (${smart.params}B)`
);
}
}
async function run() {
await testHighCapacityFloorEverywhere();
await testConsistencyAcrossPaths();
await testSmallHardwarePrefersSmallEverywhere();
console.log('scoring-unification.test.js: OK');
}
if (require.main === module) {
run().catch((error) => {
console.error('scoring-unification.test.js: FAILED');
console.error(error);
process.exit(1);
});
}
module.exports = { run };