diff --git a/app/config.py b/app/config.py index 28d4751..331605e 100644 --- a/app/config.py +++ b/app/config.py @@ -32,7 +32,8 @@ class Settings(BaseSettings): # Project metadata version: str = __version__ - scoring_algorithm_version: str = "1.0.0" + scoring_algorithm_version: str = "2.0.0" + scoring_config_path: str = "./config/scoring.yaml" # Security secret_key: str = "change-me" diff --git a/app/dump.py b/app/dump.py index bbf3b1a..9e748e8 100644 --- a/app/dump.py +++ b/app/dump.py @@ -21,6 +21,8 @@ # Collections that expose list + detail endpoints. COLLECTIONS = ["brands", "socs", "smartphones", "tablets", "watches", "pdas", "gpus", "cpus"] +# Collections with a /score sub-resource (§8) and a `scored` manifest count. +SCORED = {"smartphones", "cpus", "gpus", "socs"} PAGE_LIMIT = 100 # API max page size (§7.3) @@ -58,17 +60,23 @@ def generate( output_dir / "v1" / resource / "index.json", {"count": count, "results": items}, ) + scored = 0 for item in items: slug = item["slug"] detail = client.get(f"/v1/{resource}/{slug}").json() _write_json(output_dir / "v1" / resource / slug / "index.json", detail) - if resource == "smartphones": + if resource in SCORED: score = client.get(f"/v1/{resource}/{slug}/score").json() _write_json(output_dir / "v1" / resource / slug / "score" / "index.json", score) + if score.get("overall") is not None: + scored += 1 counts[resource] = len(items) manifest_collections = manifest["collections"] assert isinstance(manifest_collections, dict) - manifest_collections[resource] = {"count": count, "url": f"/v1/{resource}/index.json"} + entry: dict[str, object] = {"count": count, "url": f"/v1/{resource}/index.json"} + if resource in SCORED: + entry["scored"] = scored + manifest_collections[resource] = entry _write_json(output_dir / "v1" / "index.json", manifest) diff --git a/app/routers/cpus.py b/app/routers/cpus.py index 97e3e1f..f2add2b 100644 --- a/app/routers/cpus.py +++ b/app/routers/cpus.py @@ -14,8 +14,9 @@ from app.models.cpu import CPU from app.routers.utils import build_ref_page from app.schemas.common import Page, ResourceRef -from app.schemas.cpu import CPURead -from app.schemas.serializers import cpu_read, resource_ref +from app.schemas.cpu import CPURead, CPUScoreRead +from app.schemas.serializers import cpu_read, cpu_score_read, resource_ref +from app.services.scoring import get_dataset_stats, score_cpu router = APIRouter(prefix="/cpus", tags=["cpus"]) @@ -43,12 +44,24 @@ def list_cpus( ) -@router.get("/{slug}", summary="Get a CPU") -def get_cpu(slug: str, session: SessionDep) -> CPURead: +def _load_cpu(session: SessionDep, slug: str) -> tuple[CPU, Brand]: cpu = session.exec(select(CPU).where(CPU.slug == slug)).first() if cpu is None: raise not_found("CPU", slug) manufacturer = session.get(Brand, cpu.manufacturer_id) if manufacturer is None: # pragma: no cover - guarded by FK + validation raise not_found("Brand", str(cpu.manufacturer_id)) - return cpu_read(cpu, manufacturer) + return cpu, manufacturer + + +@router.get("/{slug}", summary="Get a CPU") +def get_cpu(slug: str, session: SessionDep) -> CPURead: + cpu, manufacturer = _load_cpu(session, slug) + score = score_cpu(cpu, stats=get_dataset_stats(session)) + return cpu_read(cpu, manufacturer, score) + + +@router.get("/{slug}/score", summary="Get a CPU's scores") +def get_cpu_score(slug: str, session: SessionDep) -> CPUScoreRead: + cpu, _manufacturer = _load_cpu(session, slug) + return cpu_score_read(score_cpu(cpu, stats=get_dataset_stats(session))) diff --git a/app/routers/gpus.py b/app/routers/gpus.py index 04d8b0c..5e6c486 100644 --- a/app/routers/gpus.py +++ b/app/routers/gpus.py @@ -12,8 +12,9 @@ from app.models.gpu import DiscreteGPU from app.routers.utils import build_ref_page from app.schemas.common import Page, ResourceRef -from app.schemas.gpu import GPURead -from app.schemas.serializers import gpu_read, resource_ref +from app.schemas.gpu import GPURead, GPUScoreRead +from app.schemas.serializers import gpu_read, gpu_score_read, resource_ref +from app.services.scoring import get_dataset_stats, score_gpu router = APIRouter(prefix="/gpus", tags=["gpus"]) @@ -31,12 +32,24 @@ def list_gpus(session: SessionDep, pagination: PaginationDep) -> Page[ResourceRe return build_ref_page(refs, count=count, path="/v1/gpus", pagination=pagination) -@router.get("/{slug}", summary="Get a discrete GPU") -def get_gpu(slug: str, session: SessionDep) -> GPURead: +def _load_gpu(session: SessionDep, slug: str) -> tuple[DiscreteGPU, Brand]: gpu = session.exec(select(DiscreteGPU).where(DiscreteGPU.slug == slug)).first() if gpu is None: raise not_found("GPU", slug) manufacturer = session.get(Brand, gpu.manufacturer_id) if manufacturer is None: # pragma: no cover - guarded by FK + validation raise not_found("Brand", str(gpu.manufacturer_id)) - return gpu_read(gpu, manufacturer) + return gpu, manufacturer + + +@router.get("/{slug}", summary="Get a discrete GPU") +def get_gpu(slug: str, session: SessionDep) -> GPURead: + gpu, manufacturer = _load_gpu(session, slug) + score = score_gpu(gpu, stats=get_dataset_stats(session)) + return gpu_read(gpu, manufacturer, score) + + +@router.get("/{slug}/score", summary="Get a discrete GPU's scores") +def get_gpu_score(slug: str, session: SessionDep) -> GPUScoreRead: + gpu, _manufacturer = _load_gpu(session, slug) + return gpu_score_read(score_gpu(gpu, stats=get_dataset_stats(session))) diff --git a/app/routers/smartphones.py b/app/routers/smartphones.py index 5e4ed71..76ede4a 100644 --- a/app/routers/smartphones.py +++ b/app/routers/smartphones.py @@ -16,9 +16,9 @@ from app.models.soc import SoC from app.routers.utils import build_ref_page from app.schemas.common import Page, ResourceRef -from app.schemas.serializers import resource_ref, smartphone_read +from app.schemas.serializers import phone_score_read, resource_ref, smartphone_read from app.schemas.smartphone import ScoreRead, SmartphoneRead -from app.services.scoring import compute_scores +from app.services.scoring import get_dataset_stats, score_phone router = APIRouter(prefix="/smartphones", tags=["smartphones"]) @@ -104,20 +104,11 @@ def _load_full(session: SessionDep, slug: str) -> tuple[Smartphone, Brand, SoC, @router.get("/{slug}", summary="Get a smartphone") def get_smartphone(slug: str, session: SessionDep) -> SmartphoneRead: phone, brand, soc, soc_manufacturer = _load_full(session, slug) - scores = compute_scores(phone, soc) + scores = score_phone(phone, soc, stats=get_dataset_stats(session)) return smartphone_read(phone, brand, soc, soc_manufacturer, scores) @router.get("/{slug}/score", summary="Get a smartphone's scores") def get_smartphone_score(slug: str, session: SessionDep) -> ScoreRead: phone, _brand, soc, _manufacturer = _load_full(session, slug) - scores = compute_scores(phone, soc) - return ScoreRead( - algorithm_version=scores.algorithm_version, - overall=scores.overall, - performance=scores.performance, - camera=scores.camera, - battery=scores.battery, - display=scores.display, - value=scores.value, - ) + return phone_score_read(score_phone(phone, soc, stats=get_dataset_stats(session))) diff --git a/app/routers/socs.py b/app/routers/socs.py index 162f1ed..d1aa3ae 100644 --- a/app/routers/socs.py +++ b/app/routers/socs.py @@ -13,8 +13,9 @@ from app.models.soc import SoC from app.routers.utils import build_ref_page from app.schemas.common import Page, ResourceRef -from app.schemas.serializers import resource_ref, soc_read -from app.schemas.soc import SoCRead +from app.schemas.serializers import resource_ref, soc_read, soc_score_read +from app.schemas.soc import SoCRead, SoCScoreRead +from app.services.scoring import get_dataset_stats, score_soc router = APIRouter(prefix="/socs", tags=["socs"]) @@ -29,15 +30,27 @@ def list_socs(session: SessionDep, pagination: PaginationDep) -> Page[ResourceRe return build_ref_page(refs, count=count, path="/v1/socs", pagination=pagination) -@router.get("/{slug}", summary="Get a SoC") -def get_soc(slug: str, session: SessionDep) -> SoCRead: +def _load_soc(session: SessionDep, slug: str) -> tuple[SoC, Brand]: soc = session.exec(select(SoC).where(SoC.slug == slug)).first() if soc is None: raise not_found("SoC", slug) manufacturer = session.get(Brand, soc.manufacturer_id) if manufacturer is None: # pragma: no cover - guarded by FK + validation raise not_found("Brand", str(soc.manufacturer_id)) - return soc_read(soc, manufacturer) + return soc, manufacturer + + +@router.get("/{slug}", summary="Get a SoC") +def get_soc(slug: str, session: SessionDep) -> SoCRead: + soc, manufacturer = _load_soc(session, slug) + score = score_soc(soc, stats=get_dataset_stats(session)) + return soc_read(soc, manufacturer, score) + + +@router.get("/{slug}/score", summary="Get a SoC's scores") +def get_soc_score(slug: str, session: SessionDep) -> SoCScoreRead: + soc, _manufacturer = _load_soc(session, slug) + return soc_score_read(score_soc(soc, stats=get_dataset_stats(session))) @router.get("/{slug}/smartphones", summary="Smartphones using this SoC") diff --git a/app/schemas/common.py b/app/schemas/common.py index 68402ef..436048f 100644 --- a/app/schemas/common.py +++ b/app/schemas/common.py @@ -21,6 +21,19 @@ class ManufacturerRef(BaseModel): url: str +class HybridRead(BaseModel): + """One compute axis (§8): absolute index + within-era standing + provenance. + + ``source`` is the benchmark NAME the index came from (never the raw value, ADR-006). + """ + + index: float | None = None + percentile: float | None = None + tier: str | None = None + era: str | None = None + source: str | None = None + + class Page[T](BaseModel): """Paginated collection envelope (§7.4).""" diff --git a/app/schemas/cpu.py b/app/schemas/cpu.py index 266add9..244a437 100644 --- a/app/schemas/cpu.py +++ b/app/schemas/cpu.py @@ -10,7 +10,16 @@ from pydantic import BaseModel -from app.schemas.common import ManufacturerRef +from app.schemas.common import HybridRead, ManufacturerRef + + +class CPUScoreRead(BaseModel): + """Computed CPU scores (§8): single/multi compute axes.""" + + algorithm_version: str + overall: float | None = None + single: HybridRead + multi: HybridRead class CPURead(BaseModel): @@ -37,6 +46,7 @@ class CPURead(BaseModel): integrated_graphics: str | None = None memory_support: str | None = None msrp_usd: int | None = None + score: CPUScoreRead verified: bool source_urls: list[str] created_at: datetime diff --git a/app/schemas/gpu.py b/app/schemas/gpu.py index 8c926a0..cbb8c25 100644 --- a/app/schemas/gpu.py +++ b/app/schemas/gpu.py @@ -10,7 +10,15 @@ from pydantic import BaseModel -from app.schemas.common import ManufacturerRef +from app.schemas.common import HybridRead, ManufacturerRef + + +class GPUScoreRead(BaseModel): + """Computed GPU scores (§8): a single graphics compute axis.""" + + algorithm_version: str + overall: float | None = None + graphics: HybridRead class GPURead(BaseModel): @@ -37,6 +45,7 @@ class GPURead(BaseModel): pcie_version: str fp32_tflops: float | None = None blender_score: float | None = None + score: GPUScoreRead verified: bool source_urls: list[str] url: str diff --git a/app/schemas/serializers.py b/app/schemas/serializers.py index 24df999..c74d52f 100644 --- a/app/schemas/serializers.py +++ b/app/schemas/serializers.py @@ -10,13 +10,13 @@ from app.models.smartphone import Smartphone from app.models.soc import SoC from app.schemas.brand import BrandRead, BrandSummary -from app.schemas.common import ManufacturerRef, ResourceRef -from app.schemas.cpu import CPURead -from app.schemas.gpu import GPURead +from app.schemas.common import HybridRead, ManufacturerRef, ResourceRef +from app.schemas.cpu import CPURead, CPUScoreRead +from app.schemas.gpu import GPURead, GPUScoreRead from app.schemas.mobile_device import MobileDeviceRead from app.schemas.smartphone import ScoreRead, SmartphoneRead -from app.schemas.soc import SoCManufacturer, SoCRead, SoCSummary -from app.services.scoring import Scores +from app.schemas.soc import SoCManufacturer, SoCRead, SoCScoreRead, SoCSummary +from app.services.scoring import CPUScore, GPUScore, Hybrid, PhoneScore, SoCScore PREFIX = settings.api_version_prefix @@ -26,6 +26,55 @@ def url_for(resource: str, slug: str) -> str: return f"{PREFIX}/{resource}/{slug}" +def _hybrid(hybrid: Hybrid) -> HybridRead: + return HybridRead( + index=hybrid.index, + percentile=hybrid.percentile, + tier=hybrid.tier, + era=hybrid.era, + source=hybrid.source, + ) + + +def cpu_score_read(score: CPUScore) -> CPUScoreRead: + return CPUScoreRead( + algorithm_version=score.algorithm_version, + overall=score.overall, + single=_hybrid(score.single), + multi=_hybrid(score.multi), + ) + + +def gpu_score_read(score: GPUScore) -> GPUScoreRead: + return GPUScoreRead( + algorithm_version=score.algorithm_version, + overall=score.overall, + graphics=_hybrid(score.graphics), + ) + + +def soc_score_read(score: SoCScore) -> SoCScoreRead: + return SoCScoreRead( + algorithm_version=score.algorithm_version, + overall=score.overall, + cpu=_hybrid(score.cpu), + system=_hybrid(score.system), + ) + + +def phone_score_read(score: PhoneScore) -> ScoreRead: + return ScoreRead( + algorithm_version=score.algorithm_version, + overall=score.overall, + performance=score.performance, + camera=score.camera, + battery=score.battery, + display=score.display, + value=score.value, + perf=_hybrid(score.perf), + ) + + def resource_ref(resource: str, slug: str, name: str) -> ResourceRef: return ResourceRef(slug=slug, name=name, url=url_for(resource, slug)) @@ -79,7 +128,7 @@ def soc_summary(soc: SoC, manufacturer: Brand) -> SoCSummary: ) -def soc_read(soc: SoC, manufacturer: Brand) -> SoCRead: +def soc_read(soc: SoC, manufacturer: Brand, score: SoCScore) -> SoCRead: assert soc.id is not None return SoCRead( id=soc.id, @@ -95,6 +144,7 @@ def soc_read(soc: SoC, manufacturer: Brand) -> SoCRead: gpu_clock_mhz=soc.gpu_clock_mhz, npu_tops=soc.npu_tops, modem=soc.modem, + score=soc_score_read(score), verified=soc.verified, source_urls=soc.source_urls, created_at=soc.created_at, @@ -103,7 +153,7 @@ def soc_read(soc: SoC, manufacturer: Brand) -> SoCRead: ) -def gpu_read(gpu: DiscreteGPU, manufacturer: Brand) -> GPURead: +def gpu_read(gpu: DiscreteGPU, manufacturer: Brand, score: GPUScore) -> GPURead: assert gpu.id is not None return GPURead( id=gpu.id, @@ -131,13 +181,14 @@ def gpu_read(gpu: DiscreteGPU, manufacturer: Brand) -> GPURead: pcie_version=gpu.pcie_version, fp32_tflops=gpu.fp32_tflops, blender_score=gpu.blender_score, + score=gpu_score_read(score), verified=gpu.verified, source_urls=gpu.source_urls, url=url_for("gpus", gpu.slug), ) -def cpu_read(cpu: CPU, manufacturer: Brand) -> CPURead: +def cpu_read(cpu: CPU, manufacturer: Brand, score: CPUScore) -> CPURead: assert cpu.id is not None return CPURead( id=cpu.id, @@ -165,6 +216,7 @@ def cpu_read(cpu: CPU, manufacturer: Brand) -> CPURead: integrated_graphics=cpu.integrated_graphics, memory_support=cpu.memory_support, msrp_usd=cpu.msrp_usd, + score=cpu_score_read(score), verified=cpu.verified, source_urls=cpu.source_urls, created_at=cpu.created_at, @@ -178,7 +230,7 @@ def smartphone_read( brand: Brand, soc: SoC, soc_manufacturer: Brand, - scores: Scores, + scores: PhoneScore, ) -> SmartphoneRead: assert phone.id is not None return SmartphoneRead( @@ -206,15 +258,7 @@ def smartphone_read( connectivity=phone.connectivity, image_url=phone.image_url, images=phone.images, - score=ScoreRead( - algorithm_version=scores.algorithm_version, - overall=scores.overall, - performance=scores.performance, - camera=scores.camera, - battery=scores.battery, - display=scores.display, - value=scores.value, - ), + score=phone_score_read(scores), verified=phone.verified, source_urls=phone.source_urls, created_at=phone.created_at, diff --git a/app/schemas/smartphone.py b/app/schemas/smartphone.py index 36399b9..b108763 100644 --- a/app/schemas/smartphone.py +++ b/app/schemas/smartphone.py @@ -8,6 +8,7 @@ from pydantic import BaseModel from app.schemas.brand import BrandSummary +from app.schemas.common import HybridRead from app.schemas.soc import SoCSummary @@ -21,6 +22,7 @@ class ScoreRead(BaseModel): battery: float | None = None display: float | None = None value: float | None = None + perf: HybridRead | None = None class SmartphoneRead(BaseModel): diff --git a/app/schemas/soc.py b/app/schemas/soc.py index b188d0b..f85e6fd 100644 --- a/app/schemas/soc.py +++ b/app/schemas/soc.py @@ -11,6 +11,17 @@ from pydantic import BaseModel +from app.schemas.common import HybridRead + + +class SoCScoreRead(BaseModel): + """Computed SoC scores (§8): a blended CPU axis + a system (AnTuTu) axis.""" + + algorithm_version: str + overall: float | None = None + cpu: HybridRead + system: HybridRead + class SoCManufacturer(BaseModel): """Manufacturer reference nested inside an embedded SoC (appendix C).""" @@ -48,6 +59,7 @@ class SoCRead(BaseModel): gpu_clock_mhz: int | None = None npu_tops: float | None = None modem: str | None = None + score: SoCScoreRead verified: bool source_urls: list[str] created_at: datetime diff --git a/app/services/scoring.py b/app/services/scoring.py deleted file mode 100644 index 99f1895..0000000 --- a/app/services/scoring.py +++ /dev/null @@ -1,163 +0,0 @@ -"""Open scoring algorithm (§8). - -Scores are on a 0–100 scale, carry an ``algorithm_version`` (§8.2), and return -``None`` for any category whose inputs are missing (never 0). - -Phase 0 uses **reference-based** min–max normalization: each metric is scaled -against fixed reference bounds representative of the 2025 flagship range. This -keeps a single detail response self-contained. Phase 1 switches to dataset-wide -min–max re-normalized yearly (§8.4). - -Weights are kept here as module constants for Phase 0; §8.2 calls for moving -them to ``config/scoring.yaml`` in Phase 1. -""" - -from __future__ import annotations - -from dataclasses import dataclass -from typing import Any - -from app.config import settings -from app.models.smartphone import Smartphone -from app.models.soc import SoC - -ALGORITHM_VERSION = settings.scoring_algorithm_version - - -@dataclass(frozen=True, slots=True) -class Bounds: - """Reference min/max used for min–max normalization.""" - - lo: float - hi: float - - -# Reference bounds (2025 flagship range). -GEEKBENCH_SINGLE = Bounds(1000, 3500) -GEEKBENCH_MULTI = Bounds(3000, 10000) -ANTUTU = Bounds(800_000, 3_000_000) -RAM = Bounds(4, 24) -BATTERY = Bounds(3000, 6000) -WIRED_CHARGE = Bounds(15, 120) -WIRELESS_CHARGE = Bounds(0, 50) -PROCESS_NM = Bounds(2.0, 7.0) # smaller is better → inverted -BRIGHTNESS = Bounds(800, 3000) -PPI = Bounds(300, 600) -REFRESH = Bounds(60, 144) -MAIN_CAMERA_MP = Bounds(12, 200) -MSRP = Bounds(300, 1600) - - -def _normalize(value: float, bounds: Bounds, *, invert: bool = False) -> float: - """Min–max normalize ``value`` into 0–100, clamped to the bounds.""" - span = bounds.hi - bounds.lo - if span <= 0: - return 0.0 - scaled = (value - bounds.lo) / span - scaled = max(0.0, min(1.0, scaled)) - if invert: - scaled = 1.0 - scaled - return round(scaled * 100, 1) - - -@dataclass(slots=True) -class Scores: - """Computed category scores (§8.1).""" - - algorithm_version: str - overall: float | None - performance: float | None - camera: float | None - battery: float | None - display: float | None - value: float | None - - -def _performance_score(soc: SoC, ram_gb: int) -> float | None: - """Performance from CPU/GPU benchmarks + RAM (§8.3).""" - if soc.geekbench_single is None or soc.geekbench_multi is None: - return None - single = _normalize(soc.geekbench_single, GEEKBENCH_SINGLE) - multi = _normalize(soc.geekbench_multi, GEEKBENCH_MULTI) - # AnTuTu is a whole-system score; used here as a GPU/system proxy (ADR-006: input only). - gpu = _normalize(soc.antutu_score, ANTUTU) if soc.antutu_score is not None else multi - ram = _normalize(ram_gb, RAM) - return round(single * 0.25 + multi * 0.30 + gpu * 0.30 + ram * 0.15, 1) - - -def _camera_score(cameras: list[dict[str, Any]]) -> float | None: - if not cameras: - return None - main = next((c for c in cameras if c.get("type") == "main"), cameras[0]) - mp = main.get("mp") - if mp is None: - return None - base = _normalize(float(mp), MAIN_CAMERA_MP) - ois_bonus = 8.0 if main.get("ois") else 0.0 - rear = [c for c in cameras if c.get("type") != "selfie"] - versatility = min(len(rear), 4) / 4 * 20 # up to +20 for a full rear array - return round(min(100.0, base * 0.6 + versatility + ois_bonus), 1) - - -def _battery_score( - battery_mah: int, - wired_w: float | None, - wireless_w: float | None, - process_nm: float | None, -) -> float | None: - if battery_mah <= 0: - return None - capacity = _normalize(battery_mah, BATTERY) - wired = _normalize(wired_w, WIRED_CHARGE) if wired_w is not None else 0.0 - wireless = _normalize(wireless_w, WIRELESS_CHARGE) if wireless_w is not None else 0.0 - efficiency = _normalize(process_nm, PROCESS_NM, invert=True) if process_nm is not None else 50.0 - return round(capacity * 0.45 + wired * 0.20 + wireless * 0.10 + efficiency * 0.25, 1) - - -def _display_score(display: dict[str, Any]) -> float | None: - if not display: - return None - refresh = display.get("refresh_hz") - brightness = display.get("brightness_nits") - ppi = display.get("ppi") - if refresh is None and brightness is None and ppi is None: - return None - refresh_n = _normalize(float(refresh), REFRESH) if refresh is not None else 50.0 - brightness_n = _normalize(float(brightness), BRIGHTNESS) if brightness is not None else 50.0 - ppi_n = _normalize(float(ppi), PPI) if ppi is not None else 50.0 - return round(refresh_n * 0.35 + brightness_n * 0.35 + ppi_n * 0.30, 1) - - -def _value_score(overall: float | None, msrp_usd: int | None) -> float | None: - if overall is None or not msrp_usd: - return None - # Higher overall per dollar → higher value, normalized against the MSRP range. - affordability = _normalize(float(msrp_usd), MSRP, invert=True) - return round(overall * 0.5 + affordability * 0.5, 1) - - -def compute_scores(smartphone: Smartphone, soc: SoC) -> Scores: - """Compute all category scores for a smartphone (§8).""" - performance = _performance_score(soc, smartphone.ram_gb) - camera = _camera_score(smartphone.cameras) - battery = _battery_score( - smartphone.battery_mah, - smartphone.charging_wired_w, - smartphone.charging_wireless_w, - soc.process_nm, - ) - display = _display_score(smartphone.display) - - components = [s for s in (performance, camera, battery, display) if s is not None] - overall = round(sum(components) / len(components), 1) if components else None - value = _value_score(overall, smartphone.msrp_usd) - - return Scores( - algorithm_version=ALGORITHM_VERSION, - overall=overall, - performance=performance, - camera=camera, - battery=battery, - display=display, - value=value, - ) diff --git a/app/services/scoring/__init__.py b/app/services/scoring/__init__.py new file mode 100644 index 0000000..ebeee73 --- /dev/null +++ b/app/services/scoring/__init__.py @@ -0,0 +1,44 @@ +"""Open scoring algorithm (§8, ADR-013) — hybrid absolute(log) + within-era relative. + +Scores are 0-100, carry ``algorithm_version``, and are ``None`` when their benchmark +inputs are missing (never 0). Per category: ``score_phone/score_cpu/score_gpu/score_soc``. +Pass a ``DatasetStats`` (``get_dataset_stats(session)``) to fill the relative +percentile/tier; without it only the self-contained absolute index is returned. +""" + +from __future__ import annotations + +from app.config import settings +from app.services.scoring.common import Hybrid, ReferenceScale, capability +from app.services.scoring.config import ScoringConfig, load_config +from app.services.scoring.cpu import CPUScore, score_cpu +from app.services.scoring.gpu import GPUScore, score_gpu +from app.services.scoring.phones import PhoneScore, score_phone +from app.services.scoring.soc import SoCScore, score_soc +from app.services.scoring.stats import ( + DatasetStats, + clear_dataset_stats_cache, + get_dataset_stats, +) + +ALGORITHM_VERSION = settings.scoring_algorithm_version + +__all__ = [ + "ALGORITHM_VERSION", + "CPUScore", + "DatasetStats", + "GPUScore", + "Hybrid", + "PhoneScore", + "ReferenceScale", + "ScoringConfig", + "SoCScore", + "capability", + "clear_dataset_stats_cache", + "get_dataset_stats", + "load_config", + "score_cpu", + "score_gpu", + "score_phone", + "score_soc", +] diff --git a/app/services/scoring/calibrate.py b/app/services/scoring/calibrate.py new file mode 100644 index 0000000..dbdb47c --- /dev/null +++ b/app/services/scoring/calibrate.py @@ -0,0 +1,75 @@ +"""Maintainer CLI: suggest pinned reference scales (p01..p99) from the seeded DB. + +Run: ``python -m app.services.scoring.calibrate``. Prints YAML-ready ``{lo, hi}`` per +benchmark so a maintainer can paste calibrated bounds into ``config/scoring.yaml`` and +bump ``scoring_algorithm_version``. Writes nothing — keeps the pinned+versioned +guarantee. Not run in CI (coverage-omitted). +""" + +from __future__ import annotations + +from typing import Any + +CPU_BENCHES = [ + "cinebench_r23_single", "cinebench_r23_multi", "geekbench_single", "geekbench_multi", + "passmark_single", "passmark_cpu_mark", "cinebench_2024_single", "cinebench_2024_multi", + "cinebench_r15_single", "cinebench_r15_multi", "cinebench_r11_5_single", + "cinebench_r11_5_multi", "cinebench_r10_single", "cinebench_r10_multi", + "specint2006", "specfp2006", "dhrystone_mips", "whetstone_mflops", "superpi_1m_sec", +] +GPU_BENCHES = [ + "timespy_score", "timespy_extreme_score", "passmark_g3d_mark", "fp32_tflops", + "blender_score", "speedway_score", "octanebench_score", +] +SOC_BENCHES = ["geekbench_single", "geekbench_multi", "antutu_score"] + + +def _percentiles(values: list[float]) -> tuple[float, float] | None: + vals = sorted(values) + if len(vals) < 5: + return None + + def at(p: float) -> float: + idx = min(len(vals) - 1, max(0, round(p / 100 * (len(vals) - 1)))) + return vals[idx] + + return at(1), at(99) + + +def main() -> None: + from sqlmodel import Session, select + + from app.database import create_db_and_tables, engine + from app.models.cpu import CPU + from app.models.gpu import DiscreteGPU + from app.models.soc import SoC + from app.seed import seed + + create_db_and_tables() + print("# suggested reference_scales (p01..p99) — paste into config/scoring.yaml") + with Session(engine) as session: + seed(session) + groups: list[tuple[str, type, list[str]]] = [ + ("cpu", CPU, CPU_BENCHES), + ("gpu", DiscreteGPU, GPU_BENCHES), + ("soc", SoC, SOC_BENCHES), + ] + for label, model, benches in groups: + print(f" {label}:") + rows: list[Any] = list(session.exec(select(model)).all()) + for bench in benches: + vals = [ + float(getattr(row, bench)) + for row in rows + if getattr(row, bench, None) is not None + ] + bounds = _percentiles(vals) + if bounds is None: + print(f" # {bench}: insufficient data (n={len(vals)})") + else: + lo, hi = bounds + print(f" {bench}: {{ lo: {lo:g}, hi: {hi:g}, log: true }} # n={len(vals)}") + + +if __name__ == "__main__": + main() diff --git a/app/services/scoring/common.py b/app/services/scoring/common.py new file mode 100644 index 0000000..db8c27b --- /dev/null +++ b/app/services/scoring/common.py @@ -0,0 +1,137 @@ +"""Scoring primitives shared by every category (§8, ADR-013). + +The hybrid model exposes, per compute axis, both an **absolute** capability index +(0-100, calibrated against pinned reference scales — log where benchmarks span +orders of magnitude across eras) and a **within-generation** relative view +(percentile + letter tier among same-era peers). Inputs that are missing yield +``None`` (never 0). +""" + +from __future__ import annotations + +from dataclasses import dataclass, replace +from datetime import date +from math import log +from typing import Protocol + + +@dataclass(frozen=True, slots=True) +class ReferenceScale: + """Absolute [lo, hi] in raw benchmark units → 0-100 capability.""" + + lo: float + hi: float + log: bool = False + invert: bool = False + + +@dataclass(frozen=True, slots=True) +class Hybrid: + """One compute axis: absolute index + within-era relative standing + provenance.""" + + index: float | None = None + percentile: float | None = None + tier: str | None = None + era: str | None = None + source: str | None = None # benchmark NAME the index came from (never the raw value) + + +def capability(value: float | None, ref: ReferenceScale) -> float | None: + """Map a raw benchmark ``value`` to 0-100 against ``ref`` (log/linear, clamped).""" + if value is None: + return None + x = min(max(float(value), ref.lo), ref.hi) + if ref.log: + if ref.lo <= 0 or ref.hi <= ref.lo: + return None + t = (log(x) - log(ref.lo)) / (log(ref.hi) - log(ref.lo)) + else: + span = ref.hi - ref.lo + if span <= 0: + return None + t = (x - ref.lo) / span + if ref.invert: + t = 1.0 - t + return round(100.0 * max(0.0, min(1.0, t)), 1) + + +def combine(parts: list[tuple[float | None, float]]) -> float | None: + """Weighted mean over the present (non-None) parts, renormalizing weights. + + Returns ``None`` when no part is present. + """ + present = [(v, w) for v, w in parts if v is not None and w > 0] + if not present: + return None + total_w = sum(w for _v, w in present) + if total_w <= 0: + return None + return round(sum(v * w for v, w in present) / total_w, 1) + + +def era_band(release: date | None, bands: list[tuple[int, int, str]]) -> str | None: + """Map a release year to its configured generation band label.""" + if release is None: + return None + year = release.year + for lo, hi, label in bands: + if lo <= year <= hi: + return label + return None + + +def tier_label(percentile: float | None, tiers: list[tuple[float, str]]) -> str | None: + """Map a percentile (0-100) to a letter tier via descending thresholds.""" + if percentile is None: + return None + for threshold, label in tiers: + if percentile >= threshold: + return label + return None + + +def axis( + raw_values: dict[str, float | None], + chain: list[str], + scales: dict[str, ReferenceScale], + era: str | None, +) -> Hybrid: + """Build the absolute side of a Hybrid from the first present benchmark in ``chain``. + + Benchmark-only: returns an index-less Hybrid (still carrying ``era``) when no + benchmark in the chain has a value. + """ + for name in chain: + value = raw_values.get(name) + if value is None: + continue + scale = scales.get(name) + if scale is None: + continue + index = capability(value, scale) + if index is None: + continue + return Hybrid(index=index, era=era, source=name) + return Hybrid(era=era) + + +class StatsLike(Protocol): + """Structural type for the dataset cohort lookup (see ``stats.DatasetStats``).""" + + def percentile( + self, category: str, dimension: str, era: str, index: float + ) -> float | None: ... + + +def with_relative( + hybrid: Hybrid, + category: str, + dimension: str, + stats: StatsLike | None, + tiers: list[tuple[float, str]], +) -> Hybrid: + """Fill ``percentile``/``tier`` from the same-era cohort (no-op without stats).""" + if hybrid.index is None or hybrid.era is None or stats is None: + return hybrid + pct = stats.percentile(category, dimension, hybrid.era, hybrid.index) + return replace(hybrid, percentile=pct, tier=tier_label(pct, tiers)) diff --git a/app/services/scoring/config.py b/app/services/scoring/config.py new file mode 100644 index 0000000..7650b5d --- /dev/null +++ b/app/services/scoring/config.py @@ -0,0 +1,83 @@ +"""Load and validate ``config/scoring.yaml`` into typed structures (§8.2). + +Reference scales are pinned and versioned: the loaded ``algorithm_version`` must +equal ``settings.scoring_algorithm_version`` (fail fast on drift). Cached per path. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from functools import lru_cache +from pathlib import Path +from typing import Any + +import yaml + +from app.config import settings +from app.services.scoring.common import ReferenceScale + + +@dataclass(frozen=True, slots=True) +class ScoringConfig: + algorithm_version: str + reference_scales: dict[str, dict[str, ReferenceScale]] + chains: dict[str, dict[str, list[str]]] + weights: dict[str, dict[str, float]] + era_bands: list[tuple[int, int, str]] + tiers: list[tuple[float, str]] + + def scale(self, category: str, benchmark: str) -> ReferenceScale | None: + return self.reference_scales.get(category, {}).get(benchmark) + + +def _as_scale(raw: dict[str, Any]) -> ReferenceScale: + return ReferenceScale( + lo=float(raw["lo"]), + hi=float(raw["hi"]), + log=bool(raw.get("log", False)), + invert=bool(raw.get("invert", False)), + ) + + +def _parse(raw: dict[str, Any]) -> ScoringConfig: + scales: dict[str, dict[str, ReferenceScale]] = { + cat: {name: _as_scale(spec) for name, spec in benches.items()} + for cat, benches in raw["reference_scales"].items() + } + chains: dict[str, dict[str, list[str]]] = { + cat: {dim: [str(b) for b in benches] for dim, benches in dims.items()} + for cat, dims in raw["chains"].items() + } + weights: dict[str, dict[str, float]] = { + key: {k: float(v) for k, v in group.items()} for key, group in raw["weights"].items() + } + era_bands: list[tuple[int, int, str]] = [ + (int(lo), int(hi), str(label)) for lo, hi, label in raw["era_bands"] + ] + tiers: list[tuple[float, str]] = [ + (float(threshold), str(label)) for threshold, label in raw["tiers"] + ] + return ScoringConfig( + algorithm_version=str(raw["algorithm_version"]), + reference_scales=scales, + chains=chains, + weights=weights, + era_bands=era_bands, + tiers=tiers, + ) + + +@lru_cache(maxsize=4) +def load_config(path: str | None = None) -> ScoringConfig: + """Parse the scoring config; assert its version matches settings.""" + cfg_path = Path(path or settings.scoring_config_path) + raw = yaml.safe_load(cfg_path.read_text(encoding="utf-8")) + if not isinstance(raw, dict): + raise ValueError(f"scoring config at {cfg_path} is not a mapping") + config = _parse(raw) + if config.algorithm_version != settings.scoring_algorithm_version: + raise ValueError( + f"scoring.yaml version {config.algorithm_version!r} != " + f"settings {settings.scoring_algorithm_version!r}" + ) + return config diff --git a/app/services/scoring/cpu.py b/app/services/scoring/cpu.py new file mode 100644 index 0000000..57d53a9 --- /dev/null +++ b/app/services/scoring/cpu.py @@ -0,0 +1,53 @@ +"""CPU scoring (§8) — single/multi compute axes, benchmark-only.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from app.config import settings +from app.models.cpu import CPU +from app.services.scoring.common import ( + Hybrid, + StatsLike, + axis, + combine, + era_band, + with_relative, +) +from app.services.scoring.config import ScoringConfig, load_config + +CATEGORY = "cpu" + + +@dataclass(slots=True) +class CPUScore: + algorithm_version: str + overall: float | None + single: Hybrid + multi: Hybrid + + +def score_cpu( + cpu: CPU, stats: StatsLike | None = None, config: ScoringConfig | None = None +) -> CPUScore: + cfg = config or load_config() + era = era_band(cpu.release_date, cfg.era_bands) + scales = cfg.reference_scales[CATEGORY] + chains = cfg.chains[CATEGORY] + raw: dict[str, float | None] = { + name: getattr(cpu, name, None) for dim in chains.values() for name in dim + } + single = with_relative( + axis(raw, chains["single"], scales, era), CATEGORY, "single", stats, cfg.tiers + ) + multi = with_relative( + axis(raw, chains["multi"], scales, era), CATEGORY, "multi", stats, cfg.tiers + ) + weights = cfg.weights["cpu"] + overall = combine([(single.index, weights["single"]), (multi.index, weights["multi"])]) + return CPUScore( + algorithm_version=settings.scoring_algorithm_version, + overall=overall, + single=single, + multi=multi, + ) diff --git a/app/services/scoring/gpu.py b/app/services/scoring/gpu.py new file mode 100644 index 0000000..debeb3f --- /dev/null +++ b/app/services/scoring/gpu.py @@ -0,0 +1,37 @@ +"""GPU scoring (§8) — a single graphics compute axis, benchmark-only.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from app.config import settings +from app.models.gpu import DiscreteGPU +from app.services.scoring.common import Hybrid, StatsLike, axis, era_band, with_relative +from app.services.scoring.config import ScoringConfig, load_config + +CATEGORY = "gpu" + + +@dataclass(slots=True) +class GPUScore: + algorithm_version: str + overall: float | None + graphics: Hybrid + + +def score_gpu( + gpu: DiscreteGPU, stats: StatsLike | None = None, config: ScoringConfig | None = None +) -> GPUScore: + cfg = config or load_config() + era = era_band(gpu.release_date, cfg.era_bands) + scales = cfg.reference_scales[CATEGORY] + chain = cfg.chains[CATEGORY]["graphics"] + raw: dict[str, float | None] = {name: getattr(gpu, name, None) for name in chain} + graphics = with_relative( + axis(raw, chain, scales, era), CATEGORY, "graphics", stats, cfg.tiers + ) + return GPUScore( + algorithm_version=settings.scoring_algorithm_version, + overall=graphics.index, + graphics=graphics, + ) diff --git a/app/services/scoring/phones.py b/app/services/scoring/phones.py new file mode 100644 index 0000000..f6b9a21 --- /dev/null +++ b/app/services/scoring/phones.py @@ -0,0 +1,168 @@ +"""Smartphone scoring (§8). + +Performance is **benchmark-only** (from the SoC's Geekbench/AnTuTu, gated → ``None`` +when the SoC has no benchmark). Camera/battery/display are inherently spec-derived +(there is no benchmark for them) and keep their established formulas. A hybrid +``perf`` view adds the within-generation tier/percentile for the compute axis. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from app.config import settings +from app.models.smartphone import Smartphone +from app.models.soc import SoC +from app.services.scoring.common import ( + Hybrid, + ReferenceScale, + StatsLike, + capability, + combine, + era_band, + with_relative, +) +from app.services.scoring.config import ScoringConfig, load_config + +CATEGORY = "phone" + + +@dataclass(slots=True) +class PhoneScore: + algorithm_version: str + overall: float | None + performance: float | None # == perf.index (back-compat with ScoreRead + site bars) + camera: float | None + battery: float | None + display: float | None + value: float | None + perf: Hybrid + + +def _coalesce(value: float | None, default: float) -> float: + return default if value is None else value + + +def _perf( + phone: Smartphone, soc: SoC, cfg: ScoringConfig, era: str | None, stats: StatsLike | None +) -> Hybrid: + scales = cfg.reference_scales[CATEGORY] + weights = cfg.weights["phone_perf"] + single = capability(soc.geekbench_single, scales["geekbench_single"]) + multi = capability(soc.geekbench_multi, scales["geekbench_multi"]) + system = capability(soc.antutu_score, scales["antutu_score"]) + if single is None and multi is None and system is None: + return Hybrid(era=era) # benchmark-only gate: RAM alone never yields a perf score + ram = capability(float(phone.ram_gb), scales["ram_gb"]) + index = combine( + [ + (single, weights["single"]), + (multi, weights["multi"]), + (system, weights["system"]), + (ram, weights["ram"]), + ] + ) + hybrid = Hybrid(index=index, era=era, source="geekbench") + return with_relative(hybrid, CATEGORY, "perf", stats, cfg.tiers) + + +def _camera(cameras: list[dict[str, Any]], scales: dict[str, ReferenceScale]) -> float | None: + if not cameras: + return None + main = next((c for c in cameras if c.get("type") == "main"), cameras[0]) + mp = main.get("mp") + if mp is None: + return None + base = capability(float(mp), scales["main_camera_mp"]) + if base is None: + return None + ois_bonus = 8.0 if main.get("ois") else 0.0 + rear = [c for c in cameras if c.get("type") != "selfie"] + versatility = min(len(rear), 4) / 4 * 20 # up to +20 for a full rear array + return round(min(100.0, base * 0.6 + versatility + ois_bonus), 1) + + +def _battery( + battery_mah: int, + wired_w: float | None, + wireless_w: float | None, + process_nm: float | None, + scales: dict[str, ReferenceScale], +) -> float | None: + if battery_mah <= 0: + return None + capacity = capability(float(battery_mah), scales["battery_mah"]) + if capacity is None: + return None + wired = _coalesce(capability(wired_w, scales["charging_wired_w"]), 0.0) if wired_w else 0.0 + wireless = ( + _coalesce(capability(wireless_w, scales["charging_wireless_w"]), 0.0) + if wireless_w + else 0.0 + ) + efficiency = ( + _coalesce(capability(process_nm, scales["process_nm"]), 50.0) + if process_nm is not None + else 50.0 + ) + return round(capacity * 0.45 + wired * 0.20 + wireless * 0.10 + efficiency * 0.25, 1) + + +def _display(display: dict[str, Any], scales: dict[str, ReferenceScale]) -> float | None: + if not display: + return None + refresh = display.get("refresh_hz") + brightness = display.get("brightness_nits") + ppi = display.get("ppi") + if refresh is None and brightness is None and ppi is None: + return None + refresh_n = _coalesce(capability(refresh, scales["refresh_hz"]), 50.0) + brightness_n = _coalesce(capability(brightness, scales["brightness_nits"]), 50.0) + ppi_n = _coalesce(capability(ppi, scales["ppi"]), 50.0) + return round(refresh_n * 0.35 + brightness_n * 0.35 + ppi_n * 0.30, 1) + + +def _value( + overall: float | None, msrp_usd: int | None, scales: dict[str, ReferenceScale] +) -> float | None: + if overall is None or not msrp_usd: + return None + affordability = capability(float(msrp_usd), scales["msrp_usd"]) + if affordability is None: + return None + return round(overall * 0.5 + affordability * 0.5, 1) + + +def score_phone( + phone: Smartphone, + soc: SoC, + stats: StatsLike | None = None, + config: ScoringConfig | None = None, +) -> PhoneScore: + cfg = config or load_config() + scales = cfg.reference_scales[CATEGORY] + era = era_band(phone.release_date, cfg.era_bands) + perf = _perf(phone, soc, cfg, era, stats) + camera = _camera(phone.cameras, scales) + battery = _battery( + phone.battery_mah, + phone.charging_wired_w, + phone.charging_wireless_w, + soc.process_nm, + scales, + ) + display = _display(phone.display, scales) + components = [s for s in (perf.index, camera, battery, display) if s is not None] + overall = round(sum(components) / len(components), 1) if components else None + value = _value(overall, phone.msrp_usd, scales) + return PhoneScore( + algorithm_version=settings.scoring_algorithm_version, + overall=overall, + performance=perf.index, + camera=camera, + battery=battery, + display=display, + value=value, + perf=perf, + ) diff --git a/app/services/scoring/soc.py b/app/services/scoring/soc.py new file mode 100644 index 0000000..564295c --- /dev/null +++ b/app/services/scoring/soc.py @@ -0,0 +1,59 @@ +"""SoC scoring (§8) — a blended CPU axis (Geekbench) + a system axis (AnTuTu).""" + +from __future__ import annotations + +from dataclasses import dataclass + +from app.config import settings +from app.models.soc import SoC +from app.services.scoring.common import ( + Hybrid, + StatsLike, + axis, + capability, + combine, + era_band, + with_relative, +) +from app.services.scoring.config import ScoringConfig, load_config + +CATEGORY = "soc" + + +@dataclass(slots=True) +class SoCScore: + algorithm_version: str + overall: float | None + cpu: Hybrid + system: Hybrid + + +def _cpu_axis(soc: SoC, cfg: ScoringConfig, era: str | None) -> Hybrid: + scales = cfg.reference_scales[CATEGORY] + weights = cfg.weights["soc_cpu"] + single = capability(soc.geekbench_single, scales["geekbench_single"]) + multi = capability(soc.geekbench_multi, scales["geekbench_multi"]) + index = combine([(single, weights["single"]), (multi, weights["multi"])]) + return Hybrid(index=index, era=era, source="geekbench" if index is not None else None) + + +def score_soc( + soc: SoC, stats: StatsLike | None = None, config: ScoringConfig | None = None +) -> SoCScore: + cfg = config or load_config() + era = era_band(soc.release_date, cfg.era_bands) + scales = cfg.reference_scales[CATEGORY] + chains = cfg.chains[CATEGORY] + raw: dict[str, float | None] = {"antutu_score": soc.antutu_score} + cpu = with_relative(_cpu_axis(soc, cfg, era), CATEGORY, "cpu", stats, cfg.tiers) + system = with_relative( + axis(raw, chains["system"], scales, era), CATEGORY, "system", stats, cfg.tiers + ) + weights = cfg.weights["soc"] + overall = combine([(cpu.index, weights["cpu"]), (system.index, weights["system"])]) + return SoCScore( + algorithm_version=settings.scoring_algorithm_version, + overall=overall, + cpu=cpu, + system=system, + ) diff --git a/app/services/scoring/stats.py b/app/services/scoring/stats.py new file mode 100644 index 0000000..00babd4 --- /dev/null +++ b/app/services/scoring/stats.py @@ -0,0 +1,90 @@ +"""Dataset cohorts for the within-generation relative view (§8, ADR-013). + +The absolute index is self-contained (pinned scales); the relative percentile/tier +needs the whole category. ``DatasetStats`` bins every entity's absolute index by +(category, dimension, era band) once; ``percentile`` then bisects. Built once per +process (the served DB is static between dumps) and cached. +""" + +from __future__ import annotations + +from bisect import bisect_right + +from sqlmodel import Session, select + +from app.models.cpu import CPU +from app.models.gpu import DiscreteGPU +from app.models.smartphone import Smartphone +from app.models.soc import SoC +from app.services.scoring.common import Hybrid +from app.services.scoring.cpu import score_cpu +from app.services.scoring.gpu import score_gpu +from app.services.scoring.phones import score_phone +from app.services.scoring.soc import score_soc + +CohortKey = tuple[str, str, str] + + +class DatasetStats: + """Sorted absolute-index cohorts keyed by (category, dimension, era).""" + + def __init__(self, cohorts: dict[CohortKey, list[float]]) -> None: + self._cohorts: dict[CohortKey, list[float]] = { + key: sorted(values) for key, values in cohorts.items() + } + + def percentile( + self, category: str, dimension: str, era: str, index: float + ) -> float | None: + arr = self._cohorts.get((category, dimension, era)) + if not arr: + return None + return round(100.0 * bisect_right(arr, index) / len(arr), 1) + + def cohort_size(self, category: str, dimension: str, era: str) -> int: + return len(self._cohorts.get((category, dimension, era), [])) + + @classmethod + def build(cls, session: Session) -> DatasetStats: + cohorts: dict[CohortKey, list[float]] = {} + + def add(category: str, dimension: str, hybrid: Hybrid) -> None: + if hybrid.index is None or hybrid.era is None: + return + cohorts.setdefault((category, dimension, hybrid.era), []).append(hybrid.index) + + for cpu in session.exec(select(CPU)).all(): + cpu_score = score_cpu(cpu) + add("cpu", "single", cpu_score.single) + add("cpu", "multi", cpu_score.multi) + for gpu in session.exec(select(DiscreteGPU)).all(): + add("gpu", "graphics", score_gpu(gpu).graphics) + socs: dict[int | None, SoC] = {} + for soc in session.exec(select(SoC)).all(): + socs[soc.id] = soc + soc_score = score_soc(soc) + add("soc", "cpu", soc_score.cpu) + add("soc", "system", soc_score.system) + for phone in session.exec(select(Smartphone)).all(): + phone_soc = socs.get(phone.soc_id) + if phone_soc is not None: + add("phone", "perf", score_phone(phone, phone_soc).perf) + + return cls(cohorts) + + +_CACHE: DatasetStats | None = None + + +def get_dataset_stats(session: Session) -> DatasetStats: + """Process-singleton cohorts (the served DB is static between dumps).""" + global _CACHE + if _CACHE is None: + _CACHE = DatasetStats.build(session) + return _CACHE + + +def clear_dataset_stats_cache() -> None: + """Drop the cached cohorts (used by tests that swap the database).""" + global _CACHE + _CACHE = None diff --git a/config/scoring.yaml b/config/scoring.yaml new file mode 100644 index 0000000..ab366f6 --- /dev/null +++ b/config/scoring.yaml @@ -0,0 +1,103 @@ +# Scoring configuration (§8.2, ADR-013) — hybrid absolute(log) + within-era relative. +# +# reference_scales: per benchmark, the absolute [lo, hi] in RAW units used to map a +# value to a 0-100 "capability". `log: true` for benchmarks spanning orders of +# magnitude across eras; `invert: true` when smaller-is-better (time/process_nm/price). +# These are PINNED and versioned: re-calibrating (via `python -m app.services.scoring.calibrate`) +# requires bumping algorithm_version. Calibrated to dataset p01..p99. +# chains: pick the FIRST present benchmark per dimension; normalize on its own scale. +# era_bands / tiers: drive the within-generation relative percentile + letter tier. +algorithm_version: "2.0.0" +calibrated_at: "2026-06-24" +calibration: "dataset p01-p99 (calibrate.py), log space where noted" + +reference_scales: + cpu: + cinebench_r23_single: { lo: 480, hi: 2380, log: true } + cinebench_r23_multi: { lo: 1900, hi: 180000, log: true } + geekbench_single: { lo: 590, hi: 3200, log: true } + geekbench_multi: { lo: 1900, hi: 92000, log: true } + passmark_single: { lo: 300, hi: 4730, log: true } + passmark_cpu_mark: { lo: 210, hi: 148000, log: true } + cinebench_2024_single: { lo: 72, hi: 144, log: true } + cinebench_2024_multi: { lo: 483, hi: 4321, log: true } + cinebench_r15_single: { lo: 83, hi: 346, log: true } + cinebench_r15_multi: { lo: 149, hi: 6536, log: true } + cinebench_r11_5_single: { lo: 0.91, hi: 4.0, log: true } + cinebench_r11_5_multi: { lo: 1.0, hi: 69, log: true } + cinebench_r10_single: { lo: 541, hi: 12522, log: true } + cinebench_r10_multi: { lo: 865, hi: 99662, log: true } + specint2006: { lo: 11.4, hi: 81.5, log: true } + specfp2006: { lo: 10.3, hi: 151, log: true } + dhrystone_mips: { lo: 8.7, hi: 9726, log: true } + whetstone_mflops: { lo: 1, hi: 200000, log: true } # n=0, placeholder + superpi_1m_sec: { lo: 5, hi: 600, log: true, invert: true } # n=0, placeholder + gpu: + timespy_score: { lo: 513, hi: 34163, log: true } + timespy_extreme_score: { lo: 173, hi: 19460, log: true } + passmark_g3d_mark: { lo: 3, hi: 38073, log: true } + fp32_tflops: { lo: 0.023, hi: 81.72, log: true } + blender_score: { lo: 97.45, hi: 14972, log: true } + speedway_score: { lo: 180, hi: 10074, log: true } + octanebench_score: { lo: 16, hi: 1258, log: true } + soc: + geekbench_single: { lo: 153, hi: 3640, log: true } + geekbench_multi: { lo: 471, hi: 10558, log: true } + antutu_score: { lo: 102154, hi: 3161830, log: true } + phone: + geekbench_single: { lo: 153, hi: 3640, log: true } + geekbench_multi: { lo: 471, hi: 10558, log: true } + antutu_score: { lo: 102154, hi: 3161830, log: true } + ram_gb: { lo: 2, hi: 24, log: false } + battery_mah: { lo: 1500, hi: 6000, log: false } + charging_wired_w: { lo: 5, hi: 120, log: false } + charging_wireless_w: { lo: 0, hi: 50, log: false } + process_nm: { lo: 2.0, hi: 14.0, log: false, invert: true } + brightness_nits: { lo: 400, hi: 3000, log: false } + ppi: { lo: 250, hi: 600, log: false } + refresh_hz: { lo: 60, hi: 165, log: false } + main_camera_mp: { lo: 8, hi: 200, log: false } + msrp_usd: { lo: 200, hi: 1600, log: false, invert: true } + +chains: + cpu: + single: [cinebench_r23_single, geekbench_single, passmark_single, cinebench_2024_single, + cinebench_r15_single, cinebench_r11_5_single, cinebench_r10_single, + specint2006, dhrystone_mips, superpi_1m_sec] + multi: [cinebench_r23_multi, geekbench_multi, passmark_cpu_mark, cinebench_2024_multi, + cinebench_r15_multi, cinebench_r11_5_multi, cinebench_r10_multi, + specfp2006, whetstone_mflops] + gpu: + graphics: [timespy_score, passmark_g3d_mark, fp32_tflops, blender_score, + timespy_extreme_score, speedway_score, octanebench_score] + soc: + single: [geekbench_single] + multi: [geekbench_multi] + system: [antutu_score] + +weights: + cpu: { single: 0.45, multi: 0.55 } + soc: { cpu: 0.70, system: 0.30 } + soc_cpu: { single: 0.40, multi: 0.60 } + phone_perf: { single: 0.25, multi: 0.30, system: 0.30, ram: 0.15 } + phone_overall: { performance: 0.30, camera: 0.25, battery: 0.20, display: 0.25 } + value: { overall: 0.5, affordability: 0.5 } + +era_bands: + - [0, 2005, "pre-2006"] + - [2006, 2009, "2006-2009"] + - [2010, 2013, "2010-2013"] + - [2014, 2016, "2014-2016"] + - [2017, 2019, "2017-2019"] + - [2020, 2021, "2020-2021"] + - [2022, 2023, "2022-2023"] + - [2024, 2999, "2024-2026"] + +tiers: + - [95, "S"] + - [80, "A"] + - [60, "B"] + - [40, "C"] + - [20, "D"] + - [5, "E"] + - [0, "F"] diff --git a/docs/SPEC.md b/docs/SPEC.md index 9788e96..5d9523c 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -1925,9 +1925,21 @@ TechAPI의 **핵심 목표 중 하나**. TechPicks 외에 다양한 앱·플랫 - **영향**: - `CPU` 모델·스키마·라우터·시드/검증·테스트 추가 - 원시 벤치마크(Cinebench/Geekbench)는 ADR-006대로 API 미노출, 알고리즘 입력으로만 보존 - - CPU/GPU용 점수 알고리즘은 Phase 1로 연기 (현재는 스펙 데이터만 제공) + - CPU/GPU용 점수 알고리즘은 Phase 1로 연기 (현재는 스펙 데이터만 제공) → **ADR-012에서 구현** - 데이터는 출처 가능 범위에서 배치로 지속 확장 +### ADR-012: 하이브리드 점수 v2.0.0 (절대 로그지수 + 세대내 상대) + +- **날짜**: 2026-06-24 +- **상태**: Accepted (`scoring_algorithm_version` 1.0.0 → 2.0.0) +- **결정**: Phase-0의 손으로 정한 고정 기준값(2025 플래그십)을 폐기하고, **벤치마크 기반 하이브리드** 점수를 **스마트폰 + CPU + GPU + SoC**에 도입. 코드는 `app/services/scoring/` 패키지(common/config/stats/phones/cpu/gpu/soc/calibrate), 가중치·기준척도는 `config/scoring.yaml`. + - **절대 지수**(0–100): 각 벤치 원값을 **버전 고정된 로그 기준척도**(데이터셋 p01–p99, `calibrate.py`로 산출)로 정규화 → 1996년 부품 낮음, 2026 플래그십 높음, 전 시대 비교가능. + - **세대내 상대**: 출시연도 밴드 동료 중 **퍼센타일 + 레터 티어**(S~F). 전 카테고리 코호트가 필요하므로 `DatasetStats`(프로세스 캐시)로 계산; 라이브 API는 절대지수 self-contained, 덤프는 동일 `/score` 경로 재생으로 상대필드 포함. + - **벤치 우선순위 체인**: 차원별 첫 존재 벤치 채택(예 CPU multi: cinebench_r23 → geekbench → passmark → … → 레거시), 각자 자기 척도로 정규화. +- **벤치 게이트**: 성능/연산 차원은 **실측 벤치만**. 없으면 `null`(스펙으로 성능 추정 금지). CPU/GPU/SoC는 벤치 전무 시 overall `null`. 폰 camera/battery/display는 본질적 스펙 기반이라 예외. +- **출처 공개**: 각 지수의 **벤치 이름**을 `source`로 노출(예 "cinebench_r23_multi"). **원값은 여전히 ADR-006대로 비공개**. +- **영향**: `/v1/{cpus,gpus,socs}/{slug}/score` 신규 + 상세에 `score` 임베드, 매니페스트 `scored` 카운트, 사이트 카드에 ring+bar+티어배지+출처. 재보정 시 `algorithm_version` bump 필수(기준척도 불변 보장). + --- ## 24. 용어집 diff --git a/pyproject.toml b/pyproject.toml index db06087..a52bfee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,7 @@ dependencies = [ "scalar-fastapi>=1.0.3", "httpx>=0.27", # TestClient (app/dump.py) + HTTP for app/coverage sources "beautifulsoup4>=4.12", # HTML parsing for app/coverage Wikipedia sources + "pyyaml>=6", # config/scoring.yaml (§8.2 scoring weights + reference scales) ] [project.optional-dependencies] @@ -25,6 +26,7 @@ dev = [ "pytest-asyncio>=0.24", "ruff>=0.6", "mypy>=1.11", + "types-PyYAML>=6", ] [project.urls] @@ -71,4 +73,6 @@ omit = [ # are unit-covered. "app/verify/cli.py", "app/verify/__main__.py", + # calibrate is a maintainer CLI (suggests reference scales), not run in CI. + "app/services/scoring/calibrate.py", ] diff --git a/tests/integration/test_cpus.py b/tests/integration/test_cpus.py index 10ee32f..429abd0 100644 --- a/tests/integration/test_cpus.py +++ b/tests/integration/test_cpus.py @@ -43,3 +43,21 @@ def test_unknown_cpu_404(client: TestClient) -> None: response = client.get("/v1/cpus/nope") assert response.status_code == 404 assert response.json()["error"]["code"] == "NOT_FOUND" + + +def test_cpu_score_endpoint(client: TestClient) -> None: + body = client.get("/v1/cpus/core-i9-14900k/score").json() + assert body["algorithm_version"] == "2.0.0" + assert {"single", "multi"} <= body.keys() + overall = body["overall"] + assert overall is None or 0.0 <= overall <= 100.0 + + +def test_cpu_detail_embeds_score_with_provenance(client: TestClient) -> None: + score = client.get("/v1/cpus/core-i9-14900k").json()["score"] + assert score["algorithm_version"] == "2.0.0" + multi = score["multi"] + if multi["index"] is not None: + # provenance is the benchmark NAME (never the raw value, ADR-006) + assert isinstance(multi["source"], str) + assert 0.0 <= multi["index"] <= 100.0 diff --git a/tests/integration/test_dump.py b/tests/integration/test_dump.py index 7388d14..9143848 100644 --- a/tests/integration/test_dump.py +++ b/tests/integration/test_dump.py @@ -33,3 +33,17 @@ def test_dump_writes_list_detail_and_manifest(client: TestClient, tmp_path: Path # Manifest enumerates all collections. manifest = json.loads((tmp_path / "v1" / "index.json").read_text()) assert set(manifest["collections"].keys()) == set(collections) + + +def test_dump_writes_scores_and_scored_count(client: TestClient, tmp_path: Path) -> None: + generate(client, output_dir=tmp_path, collections=["cpus"]) + score_file = tmp_path / "v1" / "cpus" / "core-i9-14900k" / "score" / "index.json" + assert score_file.exists() + score = json.loads(score_file.read_text()) + assert score["algorithm_version"] == "2.0.0" + assert score == client.get("/v1/cpus/core-i9-14900k/score").json() + + manifest = json.loads((tmp_path / "v1" / "index.json").read_text()) + cpus = manifest["collections"]["cpus"] + assert isinstance(cpus["scored"], int) + assert 0 <= cpus["scored"] <= cpus["count"] diff --git a/tests/integration/test_gpus.py b/tests/integration/test_gpus.py index 657e025..29e9246 100644 --- a/tests/integration/test_gpus.py +++ b/tests/integration/test_gpus.py @@ -37,3 +37,18 @@ def test_unknown_gpu_404(client: TestClient) -> None: response = client.get("/v1/gpus/nope") assert response.status_code == 404 assert response.json()["error"]["code"] == "NOT_FOUND" + + +def test_gpu_score_endpoint(client: TestClient) -> None: + body = client.get("/v1/gpus/geforce-rtx-4090/score").json() + assert body["algorithm_version"] == "2.0.0" + assert "graphics" in body + overall = body["overall"] + assert overall is None or 0.0 <= overall <= 100.0 + + +def test_gpu_detail_embeds_score(client: TestClient) -> None: + score = client.get("/v1/gpus/geforce-rtx-4090").json()["score"] + assert score["algorithm_version"] == "2.0.0" + if score["graphics"]["index"] is not None: + assert isinstance(score["graphics"]["source"], str) diff --git a/tests/integration/test_meta.py b/tests/integration/test_meta.py index f3e9099..08d415e 100644 --- a/tests/integration/test_meta.py +++ b/tests/integration/test_meta.py @@ -19,7 +19,7 @@ def test_health_sets_request_id_header(client: TestClient) -> None: def test_version_reports_algorithm(client: TestClient) -> None: body = client.get("/v1/version").json() assert body["api_version"] == "v1" - assert body["scoring_algorithm_version"] == "1.0.0" + assert body["scoring_algorithm_version"] == "2.0.0" def test_openapi_schema_available(client: TestClient) -> None: diff --git a/tests/integration/test_smartphones.py b/tests/integration/test_smartphones.py index 783dffa..91d44d4 100644 --- a/tests/integration/test_smartphones.py +++ b/tests/integration/test_smartphones.py @@ -21,7 +21,7 @@ def test_detail_matches_appendix_c_shape(client: TestClient) -> None: assert isinstance(body["cameras"], list) # score object present with all categories (§8.1) score = body["score"] - assert score["algorithm_version"] == "1.0.0" + assert score["algorithm_version"] == "2.0.0" assert {"overall", "performance", "camera", "battery", "display", "value"} <= score.keys() @@ -33,7 +33,7 @@ def test_unknown_smartphone_404(client: TestClient) -> None: def test_score_endpoint(client: TestClient) -> None: body = client.get("/v1/smartphones/galaxy-s25/score").json() - assert body["algorithm_version"] == "1.0.0" + assert body["algorithm_version"] == "2.0.0" assert body["overall"] is not None diff --git a/tests/integration/test_socs.py b/tests/integration/test_socs.py index ecf5971..b21fa82 100644 --- a/tests/integration/test_socs.py +++ b/tests/integration/test_socs.py @@ -34,3 +34,18 @@ def test_soc_smartphones_relation(client: TestClient) -> None: body = client.get("/v1/socs/snapdragon-8-elite/smartphones?limit=100").json() slugs = {item["slug"] for item in body["results"]} assert {"galaxy-s25", "oneplus-13"} <= slugs + + +def test_soc_score_endpoint(client: TestClient) -> None: + body = client.get("/v1/socs/snapdragon-8-elite/score").json() + assert body["algorithm_version"] == "2.0.0" + assert {"cpu", "system"} <= body.keys() + overall = body["overall"] + assert overall is None or 0.0 <= overall <= 100.0 + + +def test_soc_detail_embeds_score(client: TestClient) -> None: + score = client.get("/v1/socs/snapdragon-8-elite").json()["score"] + assert score["algorithm_version"] == "2.0.0" + if score["cpu"]["index"] is not None: + assert score["cpu"]["source"] == "geekbench" diff --git a/tests/unit/test_scoring.py b/tests/unit/test_scoring.py deleted file mode 100644 index f05608b..0000000 --- a/tests/unit/test_scoring.py +++ /dev/null @@ -1,99 +0,0 @@ -"""Unit tests for the scoring service (§8, §15.1).""" - -from __future__ import annotations - -from datetime import date - -from app.models.smartphone import Smartphone -from app.models.soc import SoC -from app.services import scoring - - -def _soc(**overrides: object) -> SoC: - base = dict( - slug="test-soc", - name="Test SoC", - manufacturer_id=1, - release_date=date(2024, 1, 1), - process_nm=3.0, - gpu_name="Test GPU", - geekbench_single=3000, - geekbench_multi=9000, - antutu_score=2_500_000, - ) - base.update(overrides) - return SoC(**base) - - -def _phone(**overrides: object) -> Smartphone: - base = dict( - slug="test-phone", - name="Test Phone", - brand_id=1, - soc_id=1, - release_date=date(2024, 1, 1), - msrp_usd=999, - ram_gb=12, - battery_mah=5000, - charging_wired_w=45, - charging_wireless_w=15, - weight_g=180.0, - os="Android", - display={"refresh_hz": 120, "brightness_nits": 2600, "ppi": 460}, - cameras=[ - {"type": "main", "mp": 50, "ois": True}, - {"type": "ultrawide", "mp": 12}, - {"type": "selfie", "mp": 12}, - ], - ) - base.update(overrides) - return Smartphone(**base) - - -def test_scores_are_within_0_100() -> None: - scores = scoring.compute_scores(_phone(), _soc()) - for value in ( - scores.overall, - scores.performance, - scores.camera, - scores.battery, - scores.display, - scores.value, - ): - assert value is not None - assert 0.0 <= value <= 100.0 - - -def test_algorithm_version_is_attached() -> None: - scores = scoring.compute_scores(_phone(), _soc()) - assert scores.algorithm_version == scoring.ALGORITHM_VERSION - - -def test_missing_benchmarks_yield_null_performance_not_zero() -> None: - scores = scoring.compute_scores(_phone(), _soc(geekbench_single=None, geekbench_multi=None)) - assert scores.performance is None # §8.2: null, never 0 - - -def test_missing_camera_yields_null() -> None: - scores = scoring.compute_scores(_phone(cameras=[]), _soc()) - assert scores.camera is None - - -def test_value_requires_msrp() -> None: - scores = scoring.compute_scores(_phone(msrp_usd=None), _soc()) - assert scores.value is None - - -def test_normalize_clamps_to_bounds() -> None: - bounds = scoring.Bounds(0, 100) - assert scoring._normalize(-50, bounds) == 0.0 - assert scoring._normalize(150, bounds) == 100.0 - assert scoring._normalize(50, bounds) == 50.0 - - -def test_normalize_invert() -> None: - bounds = scoring.Bounds(2.0, 7.0) - # smaller process_nm should score higher when inverted - high = scoring._normalize(2.0, bounds, invert=True) - low = scoring._normalize(7.0, bounds, invert=True) - assert high > low diff --git a/tests/unit/test_scoring_common.py b/tests/unit/test_scoring_common.py new file mode 100644 index 0000000..2406392 --- /dev/null +++ b/tests/unit/test_scoring_common.py @@ -0,0 +1,93 @@ +"""Unit tests for scoring primitives (§8).""" + +from __future__ import annotations + +from datetime import date + +from app.services.scoring.common import ( + Hybrid, + ReferenceScale, + axis, + capability, + combine, + era_band, + tier_label, + with_relative, +) +from app.services.scoring.config import load_config + +BANDS = [(0, 2005, "pre-2006"), (2006, 2023, "2006-2023"), (2024, 2999, "2024-2026")] +TIERS = [(95.0, "S"), (80.0, "A"), (60.0, "B"), (40.0, "C"), (20.0, "D"), (5.0, "E"), (0.0, "F")] + + +def test_capability_linear_clamps() -> None: + ref = ReferenceScale(0, 100) + assert capability(-50, ref) == 0.0 + assert capability(150, ref) == 100.0 + assert capability(50, ref) == 50.0 + assert capability(None, ref) is None + + +def test_capability_invert() -> None: + ref = ReferenceScale(2.0, 7.0, invert=True) + assert capability(2.0, ref) == 100.0 # smaller is better + assert capability(7.0, ref) == 0.0 + + +def test_capability_log_is_monotonic_across_eras() -> None: + ref = ReferenceScale(100, 100000, log=True) + old = capability(150, ref) + new = capability(90000, ref) + assert old is not None and new is not None + assert old < new # a tiny old benchmark scores far below a huge modern one + + +def test_combine_renormalizes_over_present() -> None: + assert combine([(80.0, 0.5), (None, 0.5)]) == 80.0 # missing part drops out + assert combine([(None, 1.0)]) is None + assert combine([(100.0, 0.25), (0.0, 0.75)]) == 25.0 + + +def test_era_band_boundaries() -> None: + assert era_band(date(2005, 12, 31), BANDS) == "pre-2006" + assert era_band(date(2006, 1, 1), BANDS) == "2006-2023" + assert era_band(date(2026, 6, 1), BANDS) == "2024-2026" + assert era_band(None, BANDS) is None + + +def test_tier_label_thresholds() -> None: + assert tier_label(96, TIERS) == "S" + assert tier_label(80, TIERS) == "A" + assert tier_label(5, TIERS) == "E" + assert tier_label(0, TIERS) == "F" + assert tier_label(None, TIERS) is None + + +def test_axis_picks_first_present_and_records_source() -> None: + scales = {"a": ReferenceScale(0, 100), "b": ReferenceScale(0, 100)} + chain = ["a", "b"] + got = axis({"a": None, "b": 50.0}, chain, scales, era="2024-2026") + assert got.index == 50.0 and got.source == "b" and got.era == "2024-2026" + # benchmark-only: nothing present -> index-less, era retained + none = axis({"a": None, "b": None}, chain, scales, era="2024-2026") + assert none.index is None and none.source is None and none.era == "2024-2026" + + +class _Stats: + def percentile(self, category: str, dimension: str, era: str, index: float) -> float | None: + return 72.0 + + +def test_with_relative_fills_percentile_and_tier() -> None: + base = Hybrid(index=88.0, era="2024-2026", source="x") + filled = with_relative(base, "cpu", "multi", _Stats(), TIERS) + assert filled.percentile == 72.0 and filled.tier == "B" + # no stats -> unchanged + assert with_relative(base, "cpu", "multi", None, TIERS).percentile is None + + +def test_config_loads_and_version_matches_settings() -> None: + cfg = load_config() + assert cfg.algorithm_version == "2.0.0" + assert "cinebench_r23_multi" in cfg.reference_scales["cpu"] + assert cfg.chains["cpu"]["multi"][0] == "cinebench_r23_multi" diff --git a/tests/unit/test_scoring_cpu.py b/tests/unit/test_scoring_cpu.py new file mode 100644 index 0000000..2e914a8 --- /dev/null +++ b/tests/unit/test_scoring_cpu.py @@ -0,0 +1,49 @@ +"""Unit tests for CPU scoring (§8).""" + +from __future__ import annotations + +from datetime import date + +from app.models.cpu import CPU +from app.services.scoring import score_cpu + + +def _cpu(**overrides: object) -> CPU: + base: dict[str, object] = dict( + slug="test-cpu", + name="Test CPU", + manufacturer_id=1, + release_date=date(2024, 1, 1), + segment="desktop", + architecture="Test", + cores=8, + threads=16, + ) + base.update(overrides) + return CPU(**base) + + +def test_modern_cpu_scores_within_bounds_with_version() -> None: + score = score_cpu(_cpu(cinebench_r23_single=2000, cinebench_r23_multi=35000)) + assert score.algorithm_version == "2.0.0" + for value in (score.overall, score.single.index, score.multi.index): + assert value is not None and 0.0 <= value <= 100.0 + assert score.single.source == "cinebench_r23_single" + assert score.multi.source == "cinebench_r23_multi" + assert score.multi.era == "2024-2026" + + +def test_chain_falls_back_to_legacy_benchmark() -> None: + # only an old Cinebench R10 multi present -> still scored, via the fallback chain + score = score_cpu(_cpu(release_date=date(2008, 6, 1), cinebench_r10_multi=8000)) + assert score.multi.index is not None + assert score.multi.source == "cinebench_r10_multi" + assert score.single.index is None # no single-thread benchmark + + +def test_no_benchmark_yields_null_overall_not_zero() -> None: + score = score_cpu(_cpu()) # no benchmark fields at all + assert score.overall is None # §8.2 null, never 0 + assert score.single.index is None and score.multi.index is None + # era is still attached even with no benchmark + assert score.multi.era == "2024-2026" diff --git a/tests/unit/test_scoring_gpu.py b/tests/unit/test_scoring_gpu.py new file mode 100644 index 0000000..39cc661 --- /dev/null +++ b/tests/unit/test_scoring_gpu.py @@ -0,0 +1,48 @@ +"""Unit tests for GPU scoring (§8).""" + +from __future__ import annotations + +from datetime import date + +from app.models.gpu import DiscreteGPU +from app.services.scoring import score_gpu + + +def _gpu(**overrides: object) -> DiscreteGPU: + base: dict[str, object] = dict( + slug="test-gpu", + name="Test GPU", + manufacturer_id=1, + architecture="Test", + release_date=date(2025, 1, 1), + memory_gb=16.0, + memory_type="GDDR7", + memory_bus_bit=256, + base_clock_mhz=2000, + boost_clock_mhz=2500, + tdp_w=300, + pcie_version="PCIe 5.0 x16", + ) + base.update(overrides) + return DiscreteGPU(**base) + + +def test_gpu_scores_within_bounds_with_source() -> None: + score = score_gpu(_gpu(timespy_score=30000)) + assert score.algorithm_version == "2.0.0" + assert score.overall is not None and 0.0 <= score.overall <= 100.0 + assert score.graphics.index == score.overall + assert score.graphics.source == "timespy_score" + assert score.graphics.era == "2024-2026" + + +def test_gpu_chain_falls_back_to_tflops() -> None: + score = score_gpu(_gpu(fp32_tflops=32.0)) # no timespy/g3d + assert score.graphics.index is not None + assert score.graphics.source == "fp32_tflops" + + +def test_gpu_without_benchmark_is_null() -> None: + score = score_gpu(_gpu()) + assert score.overall is None + assert score.graphics.index is None diff --git a/tests/unit/test_scoring_phones.py b/tests/unit/test_scoring_phones.py new file mode 100644 index 0000000..82dd849 --- /dev/null +++ b/tests/unit/test_scoring_phones.py @@ -0,0 +1,102 @@ +"""Unit tests for smartphone scoring (§8, §15.1).""" + +from __future__ import annotations + +from datetime import date + +from app.models.smartphone import Smartphone +from app.models.soc import SoC +from app.services.scoring import ALGORITHM_VERSION, DatasetStats, score_phone + + +def _soc(**overrides: object) -> SoC: + base: dict[str, object] = dict( + slug="test-soc", + name="Test SoC", + manufacturer_id=1, + release_date=date(2024, 1, 1), + process_nm=3.0, + gpu_name="Test GPU", + geekbench_single=3000, + geekbench_multi=9000, + antutu_score=2_500_000, + ) + base.update(overrides) + return SoC(**base) + + +def _phone(**overrides: object) -> Smartphone: + base: dict[str, object] = dict( + slug="test-phone", + name="Test Phone", + brand_id=1, + soc_id=1, + release_date=date(2024, 1, 1), + msrp_usd=999, + ram_gb=12, + battery_mah=5000, + charging_wired_w=45, + charging_wireless_w=15, + weight_g=180.0, + os="Android", + display={"refresh_hz": 120, "brightness_nits": 2600, "ppi": 460}, + cameras=[ + {"type": "main", "mp": 50, "ois": True}, + {"type": "ultrawide", "mp": 12}, + {"type": "selfie", "mp": 12}, + ], + ) + base.update(overrides) + return Smartphone(**base) + + +def test_scores_within_0_100_and_version() -> None: + score = score_phone(_phone(), _soc()) + assert score.algorithm_version == ALGORITHM_VERSION == "2.0.0" + for value in ( + score.overall, + score.performance, + score.camera, + score.battery, + score.display, + score.value, + ): + assert value is not None and 0.0 <= value <= 100.0 + + +def test_performance_equals_perf_index() -> None: + score = score_phone(_phone(), _soc()) + assert score.performance == score.perf.index + assert score.perf.era == "2024-2026" + + +def test_no_soc_benchmark_yields_null_performance() -> None: + # benchmark-only: every SoC benchmark missing -> null perf, never spec-estimated + score = score_phone( + _phone(), _soc(geekbench_single=None, geekbench_multi=None, antutu_score=None) + ) + assert score.performance is None + assert score.perf.index is None + + +def test_antutu_alone_still_scores_performance() -> None: + score = score_phone(_phone(), _soc(geekbench_single=None, geekbench_multi=None)) + assert score.performance is not None # AnTuTu is a benchmark + + +def test_missing_camera_yields_null() -> None: + assert score_phone(_phone(cameras=[]), _soc()).camera is None + + +def test_value_requires_msrp() -> None: + assert score_phone(_phone(msrp_usd=None), _soc()).value is None + + +def test_relative_fields_filled_only_with_stats() -> None: + soc = _soc() + plain = score_phone(_phone(), soc) + assert plain.perf.percentile is None and plain.perf.tier is None + stats = DatasetStats({("phone", "perf", "2024-2026"): [0.0, 50.0, 100.0]}) + ranked = score_phone(_phone(), soc, stats=stats) + assert ranked.perf.percentile is not None + assert ranked.perf.tier is not None diff --git a/tests/unit/test_scoring_soc.py b/tests/unit/test_scoring_soc.py new file mode 100644 index 0000000..b07d7a6 --- /dev/null +++ b/tests/unit/test_scoring_soc.py @@ -0,0 +1,42 @@ +"""Unit tests for SoC scoring (§8).""" + +from __future__ import annotations + +from datetime import date + +from app.models.soc import SoC +from app.services.scoring import score_soc + + +def _soc(**overrides: object) -> SoC: + base: dict[str, object] = dict( + slug="test-soc", + name="Test SoC", + manufacturer_id=1, + release_date=date(2025, 1, 1), + process_nm=3.0, + gpu_name="Test GPU", + ) + base.update(overrides) + return SoC(**base) + + +def test_soc_blends_geekbench_and_antutu() -> None: + score = score_soc(_soc(geekbench_single=3000, geekbench_multi=9000, antutu_score=2_500_000)) + assert score.algorithm_version == "2.0.0" + for value in (score.overall, score.cpu.index, score.system.index): + assert value is not None and 0.0 <= value <= 100.0 + assert score.cpu.source == "geekbench" + assert score.system.source == "antutu_score" + + +def test_soc_cpu_axis_from_single_only() -> None: + score = score_soc(_soc(geekbench_single=2000)) + assert score.cpu.index is not None # blend tolerates a missing multi + assert score.system.index is None # no antutu + + +def test_soc_without_benchmark_is_null() -> None: + score = score_soc(_soc()) + assert score.overall is None + assert score.cpu.index is None and score.system.index is None