"""Metrics: pure formulas (reproducible, coverage-normalised) and the DB flow (metrics_current / metric_series / company_daily / global_daily).""" from __future__ import annotations from datetime import UTC, date, datetime, timedelta import pytest from factories import intel_db # noqa: F401 — registers the fixture from companyatlas.services.metrics import ( activity_score, ai_adoption, anomaly_score, corporate_change_index, hiring_momentum, historical_coverage, saturate, ) from companyatlas.taxonomy import CCI_FORMULA_VERSION, METRICS_FORMULA_VERSION, Metric # ------------------------------------------------------------------------------------------------------------ pure def test_activity_score_coverage_normalisation(): changes = [(1.0, "meaningful")] * 10 small = activity_score(changes, [], active_sensors=2) big = activity_score(changes, [], active_sensors=32) assert small and big assert small.value > big.value # same raw activity spread over 16× more sensors → lower density assert small.inputs["active_sensors"] == 2 and small.formula_version == METRICS_FORMULA_VERSION assert 0 < big.value < small.value <= 100 def test_activity_score_decay_and_weights(): fresh = activity_score([(0.0, "critical")], [], 1) old = activity_score([(30.0, "critical")], [], 1) minor_kind = activity_score([(0.0, "meaningful")], [], 1) assert fresh.value > old.value > 0 assert fresh.value > minor_kind.value assert activity_score([], [], 0) is None # no sensors, no activity → no row def test_hiring_momentum_requires_listings(): assert hiring_momentum(10, 2, window=30) is None # < 3 listings at the reference point m = hiring_momentum(12, 8, window=30, extra={"jobs_new_30d": 5}) assert m.metric == Metric.HIRING_MOMENTUM_30D and m.value == 50.0 and m.inputs["jobs_new_30d"] == 5 assert hiring_momentum(5, 10, window=7).value == -50.0 def test_ai_adoption_renormalises_over_available_inputs(): jobs_only = ai_adoption(ai_open=5, open_jobs=10, ai_events_90d=0, keyword_hits=0, has_text_inputs=False) assert jobs_only.value == pytest.approx(100 * (0.5 * 1.0 + 0.25 * 0.0) / 0.75, abs=0.01) none = ai_adoption(ai_open=None, open_jobs=None, ai_events_90d=0, keyword_hits=0, has_text_inputs=False) assert none is None text = ai_adoption(ai_open=None, open_jobs=None, ai_events_90d=3, keyword_hits=5, has_text_inputs=True) assert 0 < text.value < 100 and set(text.inputs["weights_used"]) == {"events", "keywords"} def test_cci_renormalises_and_maps_momentum(): full = corporate_change_index({Metric.HIRING_MOMENTUM_30D: 50.0, Metric.PRODUCT_VELOCITY: 80.0, Metric.GEO_EXPANSION: 20.0, Metric.LEADERSHIP_ACTIVITY: 0.0, Metric.DEVELOPER_MOMENTUM: 40.0, Metric.COMMUNICATION_ACTIVITY: 60.0, Metric.PRICING_ACTIVITY: 10.0}) expected = 0.25 * 75 + 0.20 * 80 + 0.15 * 20 + 0.15 * 0 + 0.10 * 40 + 0.10 * 60 + 0.05 * 10 assert full.value == pytest.approx(expected, abs=0.01) and full.formula_version == CCI_FORMULA_VERSION partial = corporate_change_index({Metric.PRODUCT_VELOCITY: 80.0, Metric.PRICING_ACTIVITY: 20.0}) assert partial.value == pytest.approx((0.20 * 80 + 0.05 * 20) / 0.25, abs=0.01) assert partial.inputs["weight_coverage"] == 0.25 and partial.confidence < full.confidence assert corporate_change_index({}) is None def test_anomaly_and_coverage(): assert anomaly_score(10, 2.0, 1.0, samples=2) is None z = anomaly_score(10, 2.0, 1.0, samples=8) assert z.value == 8.0 flat = anomaly_score(3, 0.0, 0.0, samples=8) assert flat.value == 6.0 # stddev floor 0.5 cov = historical_coverage(observed=50, expected=100.0, days_with_obs=15, days_since_first=30, surfaces=4) assert cov.value == pytest.approx(100 * (0.5 * 0.5 + 0.3 * 0.5 + 0.2 * 0.5), abs=0.01) assert historical_coverage(observed=0, expected=0.0, days_with_obs=0, days_since_first=0, surfaces=0) is None assert saturate(6.0, 6.0) == pytest.approx(0.632, abs=0.001) # ------------------------------------------------------------------------------------------------------------ database @pytest.mark.usefixtures("intel_db") async def test_company_metrics_end_to_end(): from factories import ( CONNECTOR_ATS, cleanup, make_change, make_company, make_event, make_job, make_location, make_observation, make_sensor, ) from companyatlas.db import fetch_all, transaction from companyatlas.services.metrics import compute_company_metrics try: async with transaction() as conn: co = await make_company(conn, first_observed_days_ago=40) board = await make_sensor(conn, co, "jobs_board", connector_id=CONNECTOR_ATS, created_days_ago=40) await make_sensor(conn, co, "pricing", created_days_ago=40) await make_sensor(conn, co, "docs", created_days_ago=40) await make_sensor(conn, co, "locations", created_days_ago=40) # jobs: 8 open now, 4 of them existed 30 days ago (+ 1 removed since) → momentum_30d = (8 − 5) / 5 = +60 % for i in range(4): await make_job(conn, co, title=f"Engineer {i}", first_seen_days_ago=45, country=None, sensor_id=board["id"]) await make_job(conn, co, title="Old role", first_seen_days_ago=45, removed_days_ago=10, country=None, sensor_id=board["id"]) for i in range(4): await make_job(conn, co, title=f"ML Engineer {i}", first_seen_days_ago=5, is_ai=True, country="JP", sensor_id=board["id"]) await make_location(conn, co, name="Tokyo office", country="JP", city="Tokyo", first_seen_days_ago=3) await make_event(conn, co, subtype="NEW_PRODUCT", importance=0.7, days_ago=2) await make_event(conn, co, subtype="DOC_CHANGE", importance=0.4, days_ago=3) await make_event(conn, co, subtype="PRICE_INCREASE", importance=0.8, days_ago=4) await make_event(conn, co, subtype="COUNTRY_EXPANSION", importance=0.8, days_ago=3) await make_event(conn, co, subtype="AI_HIRING", importance=0.5, days_ago=5, tags=["hiring", "ai"]) await make_change(conn, board, significance=0.6, detected_at=datetime.now(UTC) - timedelta(days=1), status="processed") for d in range(0, 40, 2): await make_observation(conn, board, days_ago=d) stats = await compute_company_metrics([co["id"]]) assert stats["companies"] == 1 async with transaction() as conn: rows = {r["metric"]: r for r in await fetch_all(conn, "select * from metrics_current where company_id = :c", c=co["id"])} series = await fetch_all(conn, "select metric from metric_series where company_id = :c and day = current_date", c=co["id"]) assert set(series and [r["metric"] for r in series]) == set(rows) assert rows["open_jobs"]["value"] == 8 assert rows["hiring_momentum_30d"]["value"] == pytest.approx(60.0) assert rows["hiring_momentum_30d"]["inputs"]["jobs_new_30d"] == 4 and rows["hiring_momentum_30d"]["confidence"] == pytest.approx(0.9, abs=0.01) assert rows["hiring_momentum_7d"]["value"] == pytest.approx(100.0) # 4 open 7 days ago (removed role already gone), 4 new since assert "hiring_momentum_90d" not in rows # nothing was open 90 days ago → no fabricated momentum assert rows["ai_adoption"]["value"] > 40 and rows["ai_adoption"]["inputs"]["ai_open"] == 4 assert rows["geo_expansion"]["inputs"]["new_countries_90d"] == ["JP"] and rows["geo_expansion"]["value"] > 0 assert rows["product_velocity"]["value"] > 0 and rows["pricing_activity"]["value"] > 0 and rows["developer_momentum"]["value"] > 0 assert "leadership_activity" not in rows # no leadership sensor and no leadership events → no row cci = rows["corporate_change_index"] assert cci["formula_version"] == CCI_FORMULA_VERSION and 0 < cci["value"] <= 100 and "leadership_activity" not in cci["inputs"]["components"] assert rows["activity_score"]["value"] > 0 and rows["activity_score"]["inputs"]["active_sensors"] == 4 assert 0 < rows["historical_coverage"]["value"] <= 100 assert "anomaly_score" not in rows # no baseline yet finally: await cleanup() @pytest.mark.usefixtures("intel_db") async def test_daily_aggregates_and_activity_index(): from factories import cleanup, make_change, make_company, make_event, make_observation, make_sensor from companyatlas.db import fetch_all, fetch_one, transaction from companyatlas.services.metrics import compute_daily day0 = date(2001, 1, 10) # far in the past: never collides with live data, cleaned by factories try: async with transaction() as conn: a = await make_company(conn, country="CA", industries=["fintech"]) b = await make_company(conn, country="US", industries=["retail"]) sa = await make_sensor(conn, a, "homepage") sb = await make_sensor(conn, b, "homepage") for i in range(7): d = datetime(2001, 1, 4 + i, 12, tzinfo=UTC) for s in (sa, sb): await make_observation(conn, s, days_ago=(datetime.now(UTC) - d).total_seconds() / 86400) if i < 6: await make_change(conn, sa, significance=0.6, detected_at=d, status="processed") # day0: burst on company a for _ in range(5): await make_change(conn, sa, significance=0.7, detected_at=datetime(2001, 1, 10, 13, tzinfo=UTC), status="processed") await make_event(conn, a, subtype="PRICE_INCREASE", days_ago=(datetime.now(UTC) - datetime(2001, 1, 10, 14, tzinfo=UTC)).total_seconds() / 86400) for i in range(6): await compute_daily(date(2001, 1, 4 + i)) result = await compute_daily(day0) assert result["companies"] == 2 and result["meaningful_changes"] == 5 and result["events"] == 1 assert result["baseline_days"] == 6 assert result["activity_index"] == pytest.approx(500.0) # 5 changes / 2 sensors vs baseline 1 change / 2 sensors → ×100 async with transaction() as conn: g = await fetch_one(conn, "select * from global_daily where day = :d", d=day0) assert g["sensors_active"] == 2 and g["companies_active"] == 2 and g["events_by_type"] == {"PRICING": 1} assert g["by_country"]["CA"]["meaningful_changes"] == 5 and g["by_industry"]["fintech"]["events"] == 1 cd = {r["company_id"]: r for r in await fetch_all(conn, "select * from company_daily where day = :d", d=day0)} assert cd[a["id"]]["meaningful_changes"] == 5 and cd[b["id"]]["meaningful_changes"] == 0 and cd[b["id"]]["observations"] == 1 finally: await cleanup()