Scoring — metrics, indices, baselines, signals, trends
Owner: services/metrics.py (metrics-v1, CCI cci-v1), services/signals.py (signals-v1), services/trends.py (trends-v1).
Parameters live in taxonomy.METRIC_PARAMS / taxonomy.CCI_WEIGHTS / config.Settings; bump the formula version whenever they change
(spec §177). Every metrics_current row stores value, confidence, inputs (the numbers the formula saw), formula_version, computed_at;
metric_series keeps one point per company/metric/day. No inputs → no row (never 0-as-measured).
Schedules: metrics-hourly (CA_METRICS_CRON, default 7 * * * *) for companies with activity in the last CA_METRICS_ACTIVE_WINDOW_DAYS (90);
daily-aggregates (CA_DAILY_CRON, 20 0 * * *) = catch-up of missed days + all-company recompute; signals hourly; trends at :25.
CLI: catlas metrics [--all] [--company slug], catlas daily [--day|--catch-up], catlas signals, catlas trends.
Notation: sat(x, k) = 1 − e^(−x/k) (0–1, x=k → 0.63); decay(age) = e^(−age/τ); ages in days.
Company metrics (metrics_current)
| Metric | Formula | Inputs stored | Emitted when |
|---|---|---|---|
activity_score (0–100) |
raw = Σ_changes w(kind)·decay(age, τ=10) + Σ_events importance·decay(age, τ) over 30 d, w = meaningful 1 / major 2 / critical 3; density = raw / active_sensors^0.5 (coverage normalisation); score = 100·log1p(density)/log1p(6) capped at 100 |
changes_30d, events_30d, raw_changes, raw_events, active_sensors, density, τ, exponent, D_max | ≥ 1 active sensor or any activity |
open_jobs |
count of jobs.status='open' |
jobs_new_30d, jobs_removed_30d, remote_ratio, by_country, by_department, ai_open | company has a careers/jobs_board sensor or any job rows |
hiring_momentum_{7,30,90}d (%) |
(open_now − open_then)/open_then × 100, open_then reconstructed from first_seen_at ≤ t−N and (removed_at is null or > t−N) |
open_now, open_then, window (+ 30 d extras) | open_then ≥ 3 (hiring_min_listings) — no % from 0 or 2 listings |
ai_adoption (0–100) |
components: jobs min(1, ai_open/open × 2) (w 0.5), events sat(ai_events_90d, 3) (w 0.25: AI_HIRING/AI_LAUNCH/tag ai), keywords sat(hits, 5) (w 0.25: AI_KEYWORDS in product names, 90 d news titles, latest snapshot titles); weights renormalised over available components |
ai_open, open_jobs, ai_events_90d, keyword_hits, components, weights_used | any component has inputs |
product_velocity |
100·sat(Σ importance·decay(age, 30), 6) over 90 d PRODUCT + changelog/docs/API/SDK subtypes |
events_90d, surfaces | products/changelog/docs/api/developer/services sensor or a PRODUCT event |
developer_momentum |
100·sat(Σ DEVELOPER importance·decay + 0.5·meaningful dev-surface changes 30 d, 6) |
events_90d, meaningful_changes_30d, surfaces | docs/api/developer/changelog sensor or DEVELOPER event |
communication_activity |
100·sat(max(news_30d, comm_events_30d) + 0.5·min(·), 8) |
news_items_30d, events_30d | newsroom/blog/feed/IR/research sensor or news rows |
pricing_activity |
100·sat(Σ PRICING importance·decay + 0.25·pricing changes 30 d, 3) |
events_90d, meaningful_changes_30d | pricing sensor or PRICING event |
leadership_activity |
100·sat(Σ LEADERSHIP importance·decay, 3) |
events_90d | leadership sensor or LEADERSHIP event |
geo_expansion |
`100·sat(new_locations_90d + 3· | new countries | + Σ COUNTRY_EXPANSION importance, 4)`; new countries = countries whose first location or first job appeared within 90 d |
corporate_change_index (cci-v1) |
Σ w_i·c_i / Σ w_i over available components with CCI_WEIGHTS (hiring 0.25 · product 0.20 · geo 0.15 · leadership 0.15 · developer 0.10 · communication 0.10 · pricing 0.05); hiring momentum (%) mapped to 50 + clamp(m, −100, 100)/2; confidence 0.4 + 0.6·weight_coverage |
components, weights, weight_coverage | ≥ 1 component |
anomaly_score (z) |
(this_week_meaningful − mean) / max(stddev, 0.5) against baselines.meaningful_changes_weekly |
this_week, mean, stddev, samples | baseline with ≥ 4 samples |
historical_coverage (0–100) |
100·(0.5·min(1, observed_ok/expected) + 0.3·continuity + 0.2·min(1, surfaces/8)); expected = Σ_sensors age_s / current_interval_s; continuity = days_with_observations / days_since_first_observed |
observed, expected, days_with_obs, days_since_first, surfaces | first_observed_at set and ≥ 1 sensor |
Confidence conventions: activity min(0.95, 0.5 + 0.05·sensors); hiring 0.9 with a structured jobs_board sensor else 0.75; AI adoption 0.6
(inferred from public signals only); saturating scores 0.7; coverage 0.8.
Daily aggregates
compute_daily(day) (UTC day, idempotent upserts):
company_daily— observations, changes, meaningful_changes, events (+events_by_type), jobs_open / jobs_new / jobs_removed / jobs_ai_open at end of day (reconstructed fromfirst_seen_at/removed_at), news_items, sensors_active. Only companies with activity that day get a row.global_daily— sums +companies_active/sensors_active(distinct in observations that day; falls back to active sensors when no observation rows exist),events_by_type,jobs_open,by_country/by_industry({companies, meaningful_changes, events}per key) and the Global Corporate Activity Index:ratio = meaningful_changes / sensors_active;baseline = mean(ratio)over the trailing 28 days with sensors;activity_index = ratio / baseline × 100(baseline = 100). Null when no baseline exists yet (never fabricated).baselines— per company, weekly buckets ofcompany_dailyoverCA_BASELINE_WINDOW_DAYS(56):meaningful_changes_weekly,jobs_new_weekly,news_weekly→ population mean / stddev / samples. Requires ≥ 2 observed weeks. Consumed byanomaly_score,HIRING_SURGEand signals.compute_daily_catch_up()fills every day between the last computed day (or the first observation/change/event) and yesterday.
Coverage normalisation (spec §146): activity uses per-sensor density, the global index divides by active sensors and compares to a trailing baseline, trends divide mentions by active companies, and the CCI renormalises over available components — growth of the sensor network alone must not move any index.
Signals (signals, signals-v1)
Labelled as signals, with strength 0–1, confidence, explanation, evidence (event ids + metric values), window_days, expires_at
(CA_SIGNALS_TTL_DAYS = 14). One active row per (company, kind): re-detection updates it, disappearance sets status='expired'.
| kind | trigger |
|---|---|
hiring_surge |
hiring_momentum_30d ≥ +30 % with ≥ 10 open, or a HIRING_SURGE event in the window |
hiring_freeze |
hiring_momentum_30d ≤ −30 % with ≤ 1 new listing, or a HIRING_FREEZE_SIGNAL event — worded "listings no longer visible" |
launch_buildup |
within 14 d ≥ 3 of {product, docs/API, changelog, careers, messaging} categories including product, or ≥ 4 categories — "Possible launch preparation signal" |
expansion |
COUNTRY_EXPANSION event, or new countries (locations or job countries, 90 d) with location events / new jobs |
pricing_migration |
≥ 2 PRICING events in 30 d, or a tier added and a tier removed |
developer_push |
≥ 3 DEVELOPER events in 30 d or developer_momentum ≥ 60 |
enterprise_repositioning |
new contact-sales tier(s) in 30 d, or ≥ 2 events mentioning enterprise / SSO / audit logs / contact sales |
ai_acceleration |
≥ 2 AI-tagged events in 30 d, or ≥ 25 % of ≥ 4 new listings are AI-related |
abnormal_activity |
anomaly_score ≥ CA_ANOMALY_Z (2.5) |
Scope aggregates (scope = industry | country): ≥ 3 companies with the same active kind → one aggregate signal per (scope, key, kind) with the
company slugs as evidence; aggregates expire when the support drops below the threshold.
Trends (trends, trends-v1)
Terms = lowercase 1–3-grams from event titles (prefix like "News release:" removed) and news titles of the day, company names removed,
stopwords excluded at n-gram edges, tokens ≥ 3 chars (unigrams ≥ 4). A term is kept for a day only when ≥ CA_TRENDS_MIN_COMPANIES (3) distinct
companies used it (mentions, companies). Momentum for a window W ∈ {7, 30, 90}: rate = mentions / mean(companies_active) over the window,
momentum = (rate_now − rate_prev) / max(rate_prev, ε); snapshots are stored in settings_kv['trends:momentum:<W>d'] for the API.