Skip to content

Commit 3c3627c

Browse files
committed
feat(scoring): hybrid v2.0.0 — absolute log index + within-era tier, CPU/GPU/SoC scored
Replaces the Phase-0 placeholder (hand-picked 2025-flagship fixed bounds, smartphones only) with a benchmark-based hybrid model across smartphones + CPUs + GPUs + SoCs. - `app/services/scoring/` package (common/config/stats/phones/cpu/gpu/soc/calibrate). Each compute axis exposes an absolute capability index (0-100, log-calibrated against pinned dataset p01-p99 reference scales in config/scoring.yaml) AND a within-generation relative percentile + letter tier (S-F) computed from per-era cohorts (DatasetStats). - Benchmark-only: performance/compute axes use real benchmarks via priority chains (e.g. cinebench_r23 -> geekbench -> passmark -> legacy); no benchmark -> null (never 0). Phone camera/battery/display stay spec-derived (no benchmark exists for them). - Provenance: each index carries the source benchmark NAME (raw values still hidden, ADR-006). - New `/v1/{cpus,gpus,socs}/{slug}/score` endpoints + `score` embedded in details; dump emits score files for all four categories + a `scored` manifest count. - algorithm_version 1.0.0 -> 2.0.0; weights/scales/chains/eras/tiers in config/scoring.yaml (pyyaml dep). ADR-012. Scoring unit + integration tests; ruff/mypy strict green. Refs #1
1 parent fa0031e commit 3c3627c

37 files changed

Lines changed: 1445 additions & 317 deletions

app/config.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,8 @@ class Settings(BaseSettings):
3232

3333
# Project metadata
3434
version: str = __version__
35-
scoring_algorithm_version: str = "1.0.0"
35+
scoring_algorithm_version: str = "2.0.0"
36+
scoring_config_path: str = "./config/scoring.yaml"
3637

3738
# Security
3839
secret_key: str = "change-me"

app/dump.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@
2121

2222
# Collections that expose list + detail endpoints.
2323
COLLECTIONS = ["brands", "socs", "smartphones", "tablets", "watches", "pdas", "gpus", "cpus"]
24+
# Collections with a /score sub-resource (§8) and a `scored` manifest count.
25+
SCORED = {"smartphones", "cpus", "gpus", "socs"}
2426
PAGE_LIMIT = 100 # API max page size (§7.3)
2527

2628

@@ -58,17 +60,23 @@ def generate(
5860
output_dir / "v1" / resource / "index.json",
5961
{"count": count, "results": items},
6062
)
63+
scored = 0
6164
for item in items:
6265
slug = item["slug"]
6366
detail = client.get(f"/v1/{resource}/{slug}").json()
6467
_write_json(output_dir / "v1" / resource / slug / "index.json", detail)
65-
if resource == "smartphones":
68+
if resource in SCORED:
6669
score = client.get(f"/v1/{resource}/{slug}/score").json()
6770
_write_json(output_dir / "v1" / resource / slug / "score" / "index.json", score)
71+
if score.get("overall") is not None:
72+
scored += 1
6873
counts[resource] = len(items)
6974
manifest_collections = manifest["collections"]
7075
assert isinstance(manifest_collections, dict)
71-
manifest_collections[resource] = {"count": count, "url": f"/v1/{resource}/index.json"}
76+
entry: dict[str, object] = {"count": count, "url": f"/v1/{resource}/index.json"}
77+
if resource in SCORED:
78+
entry["scored"] = scored
79+
manifest_collections[resource] = entry
7280

7381
_write_json(output_dir / "v1" / "index.json", manifest)
7482

app/routers/cpus.py

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,9 @@
1414
from app.models.cpu import CPU
1515
from app.routers.utils import build_ref_page
1616
from app.schemas.common import Page, ResourceRef
17-
from app.schemas.cpu import CPURead
18-
from app.schemas.serializers import cpu_read, resource_ref
17+
from app.schemas.cpu import CPURead, CPUScoreRead
18+
from app.schemas.serializers import cpu_read, cpu_score_read, resource_ref
19+
from app.services.scoring import get_dataset_stats, score_cpu
1920

2021
router = APIRouter(prefix="/cpus", tags=["cpus"])
2122

@@ -43,12 +44,24 @@ def list_cpus(
4344
)
4445

4546

46-
@router.get("/{slug}", summary="Get a CPU")
47-
def get_cpu(slug: str, session: SessionDep) -> CPURead:
47+
def _load_cpu(session: SessionDep, slug: str) -> tuple[CPU, Brand]:
4848
cpu = session.exec(select(CPU).where(CPU.slug == slug)).first()
4949
if cpu is None:
5050
raise not_found("CPU", slug)
5151
manufacturer = session.get(Brand, cpu.manufacturer_id)
5252
if manufacturer is None: # pragma: no cover - guarded by FK + validation
5353
raise not_found("Brand", str(cpu.manufacturer_id))
54-
return cpu_read(cpu, manufacturer)
54+
return cpu, manufacturer
55+
56+
57+
@router.get("/{slug}", summary="Get a CPU")
58+
def get_cpu(slug: str, session: SessionDep) -> CPURead:
59+
cpu, manufacturer = _load_cpu(session, slug)
60+
score = score_cpu(cpu, stats=get_dataset_stats(session))
61+
return cpu_read(cpu, manufacturer, score)
62+
63+
64+
@router.get("/{slug}/score", summary="Get a CPU's scores")
65+
def get_cpu_score(slug: str, session: SessionDep) -> CPUScoreRead:
66+
cpu, _manufacturer = _load_cpu(session, slug)
67+
return cpu_score_read(score_cpu(cpu, stats=get_dataset_stats(session)))

app/routers/gpus.py

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,9 @@
1212
from app.models.gpu import DiscreteGPU
1313
from app.routers.utils import build_ref_page
1414
from app.schemas.common import Page, ResourceRef
15-
from app.schemas.gpu import GPURead
16-
from app.schemas.serializers import gpu_read, resource_ref
15+
from app.schemas.gpu import GPURead, GPUScoreRead
16+
from app.schemas.serializers import gpu_read, gpu_score_read, resource_ref
17+
from app.services.scoring import get_dataset_stats, score_gpu
1718

1819
router = APIRouter(prefix="/gpus", tags=["gpus"])
1920

@@ -31,12 +32,24 @@ def list_gpus(session: SessionDep, pagination: PaginationDep) -> Page[ResourceRe
3132
return build_ref_page(refs, count=count, path="/v1/gpus", pagination=pagination)
3233

3334

34-
@router.get("/{slug}", summary="Get a discrete GPU")
35-
def get_gpu(slug: str, session: SessionDep) -> GPURead:
35+
def _load_gpu(session: SessionDep, slug: str) -> tuple[DiscreteGPU, Brand]:
3636
gpu = session.exec(select(DiscreteGPU).where(DiscreteGPU.slug == slug)).first()
3737
if gpu is None:
3838
raise not_found("GPU", slug)
3939
manufacturer = session.get(Brand, gpu.manufacturer_id)
4040
if manufacturer is None: # pragma: no cover - guarded by FK + validation
4141
raise not_found("Brand", str(gpu.manufacturer_id))
42-
return gpu_read(gpu, manufacturer)
42+
return gpu, manufacturer
43+
44+
45+
@router.get("/{slug}", summary="Get a discrete GPU")
46+
def get_gpu(slug: str, session: SessionDep) -> GPURead:
47+
gpu, manufacturer = _load_gpu(session, slug)
48+
score = score_gpu(gpu, stats=get_dataset_stats(session))
49+
return gpu_read(gpu, manufacturer, score)
50+
51+
52+
@router.get("/{slug}/score", summary="Get a discrete GPU's scores")
53+
def get_gpu_score(slug: str, session: SessionDep) -> GPUScoreRead:
54+
gpu, _manufacturer = _load_gpu(session, slug)
55+
return gpu_score_read(score_gpu(gpu, stats=get_dataset_stats(session)))

app/routers/smartphones.py

Lines changed: 4 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,9 @@
1616
from app.models.soc import SoC
1717
from app.routers.utils import build_ref_page
1818
from app.schemas.common import Page, ResourceRef
19-
from app.schemas.serializers import resource_ref, smartphone_read
19+
from app.schemas.serializers import phone_score_read, resource_ref, smartphone_read
2020
from app.schemas.smartphone import ScoreRead, SmartphoneRead
21-
from app.services.scoring import compute_scores
21+
from app.services.scoring import get_dataset_stats, score_phone
2222

2323
router = APIRouter(prefix="/smartphones", tags=["smartphones"])
2424

@@ -104,20 +104,11 @@ def _load_full(session: SessionDep, slug: str) -> tuple[Smartphone, Brand, SoC,
104104
@router.get("/{slug}", summary="Get a smartphone")
105105
def get_smartphone(slug: str, session: SessionDep) -> SmartphoneRead:
106106
phone, brand, soc, soc_manufacturer = _load_full(session, slug)
107-
scores = compute_scores(phone, soc)
107+
scores = score_phone(phone, soc, stats=get_dataset_stats(session))
108108
return smartphone_read(phone, brand, soc, soc_manufacturer, scores)
109109

110110

111111
@router.get("/{slug}/score", summary="Get a smartphone's scores")
112112
def get_smartphone_score(slug: str, session: SessionDep) -> ScoreRead:
113113
phone, _brand, soc, _manufacturer = _load_full(session, slug)
114-
scores = compute_scores(phone, soc)
115-
return ScoreRead(
116-
algorithm_version=scores.algorithm_version,
117-
overall=scores.overall,
118-
performance=scores.performance,
119-
camera=scores.camera,
120-
battery=scores.battery,
121-
display=scores.display,
122-
value=scores.value,
123-
)
114+
return phone_score_read(score_phone(phone, soc, stats=get_dataset_stats(session)))

app/routers/socs.py

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,9 @@
1313
from app.models.soc import SoC
1414
from app.routers.utils import build_ref_page
1515
from app.schemas.common import Page, ResourceRef
16-
from app.schemas.serializers import resource_ref, soc_read
17-
from app.schemas.soc import SoCRead
16+
from app.schemas.serializers import resource_ref, soc_read, soc_score_read
17+
from app.schemas.soc import SoCRead, SoCScoreRead
18+
from app.services.scoring import get_dataset_stats, score_soc
1819

1920
router = APIRouter(prefix="/socs", tags=["socs"])
2021

@@ -29,15 +30,27 @@ def list_socs(session: SessionDep, pagination: PaginationDep) -> Page[ResourceRe
2930
return build_ref_page(refs, count=count, path="/v1/socs", pagination=pagination)
3031

3132

32-
@router.get("/{slug}", summary="Get a SoC")
33-
def get_soc(slug: str, session: SessionDep) -> SoCRead:
33+
def _load_soc(session: SessionDep, slug: str) -> tuple[SoC, Brand]:
3434
soc = session.exec(select(SoC).where(SoC.slug == slug)).first()
3535
if soc is None:
3636
raise not_found("SoC", slug)
3737
manufacturer = session.get(Brand, soc.manufacturer_id)
3838
if manufacturer is None: # pragma: no cover - guarded by FK + validation
3939
raise not_found("Brand", str(soc.manufacturer_id))
40-
return soc_read(soc, manufacturer)
40+
return soc, manufacturer
41+
42+
43+
@router.get("/{slug}", summary="Get a SoC")
44+
def get_soc(slug: str, session: SessionDep) -> SoCRead:
45+
soc, manufacturer = _load_soc(session, slug)
46+
score = score_soc(soc, stats=get_dataset_stats(session))
47+
return soc_read(soc, manufacturer, score)
48+
49+
50+
@router.get("/{slug}/score", summary="Get a SoC's scores")
51+
def get_soc_score(slug: str, session: SessionDep) -> SoCScoreRead:
52+
soc, _manufacturer = _load_soc(session, slug)
53+
return soc_score_read(score_soc(soc, stats=get_dataset_stats(session)))
4154

4255

4356
@router.get("/{slug}/smartphones", summary="Smartphones using this SoC")

app/schemas/common.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,19 @@ class ManufacturerRef(BaseModel):
2121
url: str
2222

2323

24+
class HybridRead(BaseModel):
25+
"""One compute axis (§8): absolute index + within-era standing + provenance.
26+
27+
``source`` is the benchmark NAME the index came from (never the raw value, ADR-006).
28+
"""
29+
30+
index: float | None = None
31+
percentile: float | None = None
32+
tier: str | None = None
33+
era: str | None = None
34+
source: str | None = None
35+
36+
2437
class Page[T](BaseModel):
2538
"""Paginated collection envelope (§7.4)."""
2639

app/schemas/cpu.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,16 @@
1010

1111
from pydantic import BaseModel
1212

13-
from app.schemas.common import ManufacturerRef
13+
from app.schemas.common import HybridRead, ManufacturerRef
14+
15+
16+
class CPUScoreRead(BaseModel):
17+
"""Computed CPU scores (§8): single/multi compute axes."""
18+
19+
algorithm_version: str
20+
overall: float | None = None
21+
single: HybridRead
22+
multi: HybridRead
1423

1524

1625
class CPURead(BaseModel):
@@ -37,6 +46,7 @@ class CPURead(BaseModel):
3746
integrated_graphics: str | None = None
3847
memory_support: str | None = None
3948
msrp_usd: int | None = None
49+
score: CPUScoreRead
4050
verified: bool
4151
source_urls: list[str]
4252
created_at: datetime

app/schemas/gpu.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,15 @@
1010

1111
from pydantic import BaseModel
1212

13-
from app.schemas.common import ManufacturerRef
13+
from app.schemas.common import HybridRead, ManufacturerRef
14+
15+
16+
class GPUScoreRead(BaseModel):
17+
"""Computed GPU scores (§8): a single graphics compute axis."""
18+
19+
algorithm_version: str
20+
overall: float | None = None
21+
graphics: HybridRead
1422

1523

1624
class GPURead(BaseModel):
@@ -37,6 +45,7 @@ class GPURead(BaseModel):
3745
pcie_version: str
3846
fp32_tflops: float | None = None
3947
blender_score: float | None = None
48+
score: GPUScoreRead
4049
verified: bool
4150
source_urls: list[str]
4251
url: str

0 commit comments

Comments
 (0)