diff --git a/app/verify/cli.py b/app/verify/cli.py index 71f8857..cdd224a 100644 --- a/app/verify/cli.py +++ b/app/verify/cli.py @@ -198,7 +198,7 @@ def _print_markdown(hist: dict[str, Counter[str]], scored: int, hard_flags: Coun ) gtot = sum(totals.values()) or 1 print(f"**{scored} record(s) assessed.**\n") - print("Laptop, monitor, software and website assess required fields and sources only; " + print("Software and website assess required fields and sources only; " "domain consistency rules are unavailable and these categories cannot earn green.\n") # Overall distribution as a Mermaid pie (rendered by GitHub). Mermaid colors diff --git a/app/verify/offline.py b/app/verify/offline.py index a86e6a6..62385b8 100644 --- a/app/verify/offline.py +++ b/app/verify/offline.py @@ -50,6 +50,10 @@ "cameras", "os_version"), "watch": ("display.size_inch", "display.resolution", "os_version"), "pda": ("display.size_inch", "display.resolution", "os_version"), + "laptop": ("cpu_name", "ram_gb", "storage_gb", "display.size_inch", + "display.resolution", "weight_g", "gpu_name", "os", "release_date"), + "monitor": ("size_inch", "resolution", "refresh_hz", "panel_type", "ppi", + "aspect_ratio", "features.ports", "features.response_time_ms"), "brand": ("founded_year", "description_en"), } diff --git a/app/verify/signals.py b/app/verify/signals.py index 0ccfb89..ab2803f 100644 --- a/app/verify/signals.py +++ b/app/verify/signals.py @@ -286,9 +286,69 @@ def brand_signals(rec: dict[str, Any], now_year: int) -> list[Signal]: return [founded] +def _positive_range(name: str, value: Any, lo: float, hi: float) -> Signal: + """Missing measurements are NA; impossible ones are hard, outliers soft.""" + if value is None: + return Signal(name, "na") + number = _num(value) + if number is None: + return Signal(name, "fail") + if not math.isfinite(number) or number <= 0: + return Signal(name, "fail", hard=True) + return Signal(name, "pass" if lo <= number <= hi else "fail") + + +def _resolution_signal(value: Any) -> Signal: + if value in (None, ""): + return Signal("resolution_parses", "na") + parsed = parse_resolution(value) + if parsed is None: + return Signal("resolution_parses", "fail") + if min(parsed) <= 0: + return Signal("resolution_parses", "fail", hard=True) + return Signal("resolution_parses", "pass") + + +def _device_release_signal(rec: dict[str, Any], now_year: int) -> Signal: + # Future dates may describe announced products, so flag rather than force red. + year = _year_of(rec.get("release_date")) + if year is None: + return Signal("release_not_future", "na") + return Signal("release_not_future", "pass" if year <= now_year else "fail") + + +def laptop_signals(rec: dict[str, Any], now_year: int) -> list[Signal]: + raw_display = rec.get("display") + display = raw_display if isinstance(raw_display, dict) else {} + return [ + _positive_range("ram_plausible", rec.get("ram_gb"), 1, 256), + _positive_range("storage_plausible", rec.get("storage_gb"), 1, 16384), + _positive_range("display_size_plausible", display.get("size_inch"), 7, 21), + _positive_range("weight_plausible", rec.get("weight_g"), 400, 6000), + _resolution_signal(display.get("resolution")), + _ppi_signal(display), + _device_release_signal(rec, now_year), + ] + + +def monitor_signals(rec: dict[str, Any], now_year: int) -> list[Signal]: + # Includes 7-inch touch monitors and 86-inch signage in the seed dataset. + return [ + _positive_range("display_size_plausible", rec.get("size_inch"), 7, 86), + _positive_range("refresh_plausible", rec.get("refresh_hz"), 24, 600), + _resolution_signal(rec.get("resolution")), + _ppi_signal(rec), + _device_release_signal(rec, now_year), + ] + + def signals_for( category: str, rec: dict[str, Any], now_year: int, soc_release: dict[str, str] ) -> list[Signal]: + if category == "laptop": + return laptop_signals(rec, now_year) + if category == "monitor": + return monitor_signals(rec, now_year) if category == "cpu": return cpu_signals(rec, now_year) if category == "gpu": diff --git a/tests/unit/test_bot_coverage.py b/tests/unit/test_bot_coverage.py index 0d126e2..d4415fe 100644 --- a/tests/unit/test_bot_coverage.py +++ b/tests/unit/test_bot_coverage.py @@ -21,7 +21,7 @@ def test_all_categories_share_registry(): } -@pytest.mark.parametrize("category", ["laptop", "monitor", "software", "website", "future"]) +@pytest.mark.parametrize("category", ["software", "website", "future"]) def test_missing_domain_rules_never_earn_green(category): score = offline.score_record(Record(category, "example.json", { "slug": "example", "name": "Example", "source_urls": ["https://intel.com/example"], diff --git a/tests/verify/test_laptop_monitor_signals.py b/tests/verify/test_laptop_monitor_signals.py new file mode 100644 index 0000000..9501bb5 --- /dev/null +++ b/tests/verify/test_laptop_monitor_signals.py @@ -0,0 +1,36 @@ +"""Plausibility boundaries distinguish outliers, missing data and impossibilities.""" + +import pytest + +from app.verify.signals import signals_for + + +@pytest.mark.parametrize("category,record,name,hard", [ + ("laptop", {"ram_gb": 512}, "ram_plausible", False), + ("laptop", {"weight_g": 300}, "weight_plausible", False), + ("laptop", {"storage_gb": -1}, "storage_plausible", True), + ("laptop", {"display": {"size_inch": float("inf")}}, "display_size_plausible", True), + ("monitor", {"refresh_hz": 700}, "refresh_plausible", False), + ("monitor", {"size_inch": -1}, "display_size_plausible", True), + ("monitor", {"resolution": "unknown"}, "resolution_parses", False), + ("monitor", {"resolution": "00x1080"}, "resolution_parses", True), + ("monitor", {"refresh_hz": True}, "refresh_plausible", False), + ("laptop", {"release_date": "2027-01-01"}, "release_not_future", False), + ("monitor", {"release_date": "2027-01-01"}, "release_not_future", False), + ("monitor", {"size_inch": 24, "resolution": "1920x1080", "ppi": 300}, + "ppi_consistent", False), +]) +def test_outliers_are_soft_and_impossible_measurements_are_hard(category, record, name, hard): + signal = next(s for s in signals_for(category, record, 2026, {}) if s.name == name) + assert signal.failed + assert signal.hard is hard + + +@pytest.mark.parametrize("size", [7, 86]) +def test_specialty_monitor_sizes_are_plausible(size): + assert not any(s.failed for s in signals_for("monitor", {"size_inch": size}, 2026, {})) + + +@pytest.mark.parametrize("category", ["laptop", "monitor"]) +def test_absent_measurements_are_not_flags(category): + assert all(s.result == "na" for s in signals_for(category, {}, 2026, {})) diff --git a/tests/verify/test_offline.py b/tests/verify/test_offline.py index ceeb862..c3ec1bc 100644 --- a/tests/verify/test_offline.py +++ b/tests/verify/test_offline.py @@ -2,7 +2,6 @@ import pytest -from app import validate from app.verify import hosts, offline from app.verify.common import Record @@ -10,52 +9,6 @@ NO_SOC: dict[str, str] = {} -@pytest.mark.parametrize("category,slug,fields,url,expected", [ - ("laptop", "acer-chromebook-14-ammok-691", - {"ram_gb": 4, "os": "Chrome OS"}, - "https://huggingface.co/datasets/Ammok/laptop_price_prediction", 53.8), - ("monitor", "acer-nitro-monitor-amazonmon-636", - {"size_inch": 23.8, "resolution": "1920x1080"}, - "https://www.kaggle.com/datasets/durjoychandrapaul/amazon-products-sales-monitor-dataset", - 58.5), -]) -def test_structurally_valid_variants_are_yellow(category, slug, fields, url, expected): - base = "acer-chromebook-14" if category == "laptop" else "acer-nitro-monitor" - data = { - "slug": slug, "base_model_slug": base, "name": "Example", "brand": "acer", - "release_date": "2023-01-01", "verified": False, "source_urls": [url], **fields, - } - path = f"{category}/acer/2023/{base}/{slug}.json" - required = getattr(validate, f"{category.upper()}_REQUIRED") - errors = [] - validate._check_required(path, data, required, errors) - validate._check_slug(path, slug, errors) - validate._check_source_urls(path, data, errors) - validate._check_variant_path(path, data, category, errors, allow_flat=True) - assert errors == [] - - def score(): - return offline.score_record(Record(category, path, data), NOW, NO_SOC) - - result = score() - assert result.band == "yellow" - assert result.score == expected - assert result.subscores["consistency"] == 0 - assert result.flags == ["domain_rules_unavailable"] - - # Strong sources can increase the normalized score, but cannot earn green - # without domain consistency rules. - data["source_urls"] = ["https://intel.com/example", "https://en.wikipedia.org/wiki/x"] - assert score().score >= offline.GREEN_MIN - assert score().band == "yellow" - - del data[next(iter(fields))] - broken = score() - assert broken.score >= offline.RED_MAX # hard fail, despite a good numeric score - assert broken.band == "red" - assert "!structural_integrity" in broken.flags - - def _score(category, data): return offline.score_record(Record(category, f"{category}/x.json", data), NOW, NO_SOC) @@ -148,3 +101,32 @@ def test_model_key_of_a_standalone_record_is_itself(): rec = Record("cpu", "cpu/intel/2023/desktop/core-i9-14900k.json", {"slug": "core-i9-14900k"}) assert rec.model_key == ("cpu", "core-i9-14900k") + + +@pytest.mark.parametrize("category,fields", [ + ("laptop", {"cpu_name": "Example CPU", "ram_gb": 16, "storage_gb": 512, + "display": {"size_inch": 14, "resolution": "1920x1080", "ppi": 157}, + "weight_g": 1400, "gpu_name": "Integrated", "os": "Linux"}), + ("monitor", {"size_inch": 24, "resolution": "1920x1080", "refresh_hz": 144, + "panel_type": "IPS", "ppi": 92, "aspect_ratio": "16:9", + "features": {"ports": ["HDMI"], "response_time_ms": 1}}), +]) +@pytest.mark.parametrize("url", ["https://intel.com/example", "https://wikidata.org/wiki/Q1"]) +def test_complete_laptop_monitor_can_be_green(category, fields, url): + data = {**fields, "release_date": "2023-01-01", "source_urls": [url]} + score = _score(category, data) + assert score.band == "green" + assert score.subscores["consistency"] == 35 + assert score.flags == [] + # A physical impossibility overrides an otherwise rich, well-sourced record. + data["ram_gb" if category == "laptop" else "size_inch"] = 0 + assert _score(category, data).band == "red" + + +@pytest.mark.parametrize("category", ["laptop", "monitor"]) +def test_sparse_bulk_laptop_monitor_stays_yellow(category): + data = {"release_date": "2023-01-01", "source_urls": ["https://kaggle.com/example"]} + score = _score(category, data) + assert score.band == "yellow" + assert "domain_rules_unavailable" not in score.flags + assert score.subscores["consistency"] == 35