# LATAM Tenants Scoring Task

Handler: `scoring_function.lambda_handler`

This SQS-triggered Lambda recalculates a tenant's commercial signal score and
updates:

- `latam_active_tenants.total_score`;
- `latam_active_tenants.priority`;
- `latam_active_tenants.key_lead`;
- the tenant's current UTC-day row in `latam_tenant_score_snapshots`;
- `latam_pql_priority_tier` on the existing HubSpot Contact matched by email.

The implementation in [`scoring_function.py`](scoring_function.py) is the
source of truth for the model described below.

## Inputs

Scoring reloads the current tenant row from `latam_active_tenants`. The model
uses these fields:

| Field | Use |
| --- | --- |
| `created_at` | 24-hour and 48-hour urgency and priority gates |
| `plan` | POC urgency and priority gates |
| `has_credit_card` | Activity points and no-card organization gates |
| `cluster_count` | Activity points and organization priority gates |
| `tenant_avg_qps` | Activity, urgency, and priority gates |
| `email` | Exact HubSpot Contact lookup for priority writeback |
| `email_domain` | Free-domain detection and key-lead matching |
| `entity_ai_category` | Fit score and organization/AI-company gates |
| `icp_categories` | Multi-select ICP Fit floor for recognized organizations |
| `name`, `alias`, `official_website` | Key-lead matching only |

`NULL`, invalid, or unavailable QPS is treated as numeric zero by Scoring. An
invalid or missing `created_at` does not satisfy either tenant-age gate.

The handler reads only `tenant_id` from the SQS body. `source_type`,
`event_type`, and `processed_at` may be present for observability but do not
affect the score.

## Score composition

```text
total_score = activity_score + fit_score + urgency_score
```

| Component | Maximum |
| --- | ---: |
| Activity | 40 |
| Fit | 30 |
| Urgency | 30 |
| Total | 100 |

### Activity score

Activity signals are additive and the result is capped at 40.

| Signal | Points |
| --- | ---: |
| `tenant_avg_qps >= 1` | +25 |
| `0.001 <= tenant_avg_qps < 1` | +12 |
| `cluster_count >= 1` | +10 |
| `cluster_count >= 3` | +8 additional |
| `cluster_count >= 10` | +5 additional |
| Has a credit card | +10 |
| `tenant_avg_qps >= 1` and no credit card | +5 |

Cluster thresholds are cumulative: 1, 3, and 10 or more clusters contribute
10, 18, and 23 points respectively before the overall Activity cap.

### Fit score

Fit selects exactly one row from this table; it is not additive.

| `entity_ai_category` and domain condition | Points |
| --- | ---: |
| `company_ai_core` | 30 |
| `company_ai_related` | 20 |
| `company_non_ai` with a non-free email domain | 15 |
| `company_non_ai` with a recognized free email domain | 5 |
| `institution_ai_related` | 15 |
| `institution_non_ai` | 8 |
| `individual` | 0 |
| Unknown or any other unmatched case | 3 |

An empty domain is neither an enterprise domain nor a recognized free domain.
Therefore, `company_non_ai` with an empty domain falls through to 3 points.
Free-domain detection uses the exact `FREE_EMAIL_DOMAINS` set in the code,
including common Gmail, Outlook, Yahoo, iCloud, Proton, QQ, 163, and 126
domains.

After the base Fit score is selected, a recognized organization matching at
least one canonical ICP category receives a Fit score floor of 25. The five
multi-select categories are `fintech`, `retail`, `e_commerce`, `ai`, and
`igaming`. This is a floor rather than an additive bonus: an AI Core company
keeps its existing Fit score of 30, and total score remains capped by the
existing 100-point model. Individuals, unknown entities, and unrecognized
categories receive no ICP boost. ICP does not directly force P0 or P1.

### Urgency score

Urgency signals are additive and the result is capped at 30.

| Signal | Points |
| --- | ---: |
| Created within 24 hours and `company_ai_core` | +20 |
| Created within 48 hours and `company_ai_related` | +15 |
| `plan = 'poc'` | +15 |
| `tenant_avg_qps >= 50` | +15 |
| At least 3 clusters, no credit card, and a known organization | +12 |

The age checks use `created_at` for every tenant. They do not check
`source_type`, so active candidates and new signups follow one scoring model.
`error_message` is not a scoring input and does not add urgency or force a
priority.

## Category gates

The following categories are treated as known organizations:

- `company_ai_core`;
- `company_ai_related`;
- `company_non_ai`;
- `institution_ai_related`;
- `institution_non_ai`.

Only `company_ai_core` and `company_ai_related` are treated as AI companies.
`individual`, `unknown`, empty, and unrecognized categories do not satisfy
organization-only priority gates.

## Priority calculation

Priority is not determined by total score alone. Scoring first evaluates all
P0 conditions. If none match, it evaluates the P1 conditions; otherwise the
tenant is P2.

### P0

A tenant is P0 when any of these conditions is true:

- `cluster_count >= 50`, regardless of tenant category;
- `tenant_avg_qps >= 50`;
- `tenant_avg_qps >= 10` and category is `company_ai_core`;
- created within 24 hours and category is `company_ai_core`;
- `plan = 'poc'` and category is either AI-company category;
- `total_score >= 75` and category is a known organization.

### P1

When no P0 condition matches, a tenant is P1 when any of these conditions is
true:

- `total_score >= 45`;
- created within 48 hours and category is `company_ai_related`;
- created within 24 hours, category is `company_non_ai`, and the email domain
  is non-free;
- `cluster_count >= 10` and category is a known organization;
- `cluster_count >= 3`, there is no credit card, and category is a known
  organization.

### P2

A tenant is P2 when it matches neither the P0 nor the P1 rules.

## Large-cluster behavior

Worker always retrieves the complete DevOps cluster list, including exact
service-plan counts. At `cluster_count >= 10`, Worker skips every per-cluster
Clinic QPS request and stores `tenant_avg_qps = NULL`.

Scoring consequently grants the cumulative 23 cluster Activity points but no
QPS points. At 50 or more clusters, every tenant is P0 regardless of category.
From 10 through 49 clusters, a known organization is at least P1 through the
explicit 10-cluster priority rule, while `individual` and `unknown` are not
promoted by cluster count alone.

## Key-lead matching

Key-lead matching is independent of `total_score` and `priority`; it only sets
`key_lead` to 1 or 0. Only active rows in `latam_key_leads` participate. The
matching order is:

1. email-domain root or official-website root equals
   `website_domain_root`;
2. either domain root equals `linkedin_slug`;
3. normalized tenant name or alias equals the normalized key-lead company;
4. a normalized name or alias of at least four characters equals
   `linkedin_slug`.

Company normalization is Unicode-aware and removes punctuation and common
Western and LATAM legal suffixes. Domain-root extraction supports common LATAM
multi-part public suffixes. Recognized free-email domains are not used as
domain roots for key-lead matching. The active key-lead list is cached in each
warm Lambda environment for `KEY_LEADS_CACHE_TTL_SECONDS`, defaulting to 900
seconds.

## Examples

| Tenant signals | Score result | Priority result |
| --- | --- | --- |
| New `company_ai_core` within 24 hours, no other signals | Fit 30 + Urgency 20 = 50 | P0 through the new-core override |
| Enterprise-domain `company_non_ai`, 10 clusters, no card, unknown QPS | Activity 23 + Fit 15 + Urgency 12 = 50 | P1 by score and organization cluster gates |
| `institution_non_ai`, 10 clusters, has card, unknown QPS | Activity 33 + Fit 8 = 41 | P1 through the 10-cluster organization gate |
| `individual`, 10 clusters, no card, unknown QPS | Activity 23 + Fit 0 = 23 | P2; cluster count alone does not promote an individual |
| `individual`, 50 clusters, no card, unknown QPS | Activity 23 + Fit 0 = 23 | P0 through the 50-cluster override |
| `unknown`, no clusters, no card, unknown QPS | Fit 3 = 3 | P2 |
| Fintech `company_non_ai`, no activity or card | ICP Fit floor 25 | P2; ICP alone does not force priority |
| Retail `company_non_ai`, one cluster and card | Activity 20 + ICP Fit 25 = 45 | P1 through total score |

## Message and transaction behavior

A Worker message has this shape:

```json
{
  "tenant_id": "1372813089209295124",
  "source_type": "active_candidate",
  "event_type": "active_processed",
  "processed_at": "2026-05-25T02:00:00Z"
}
```

If the tenant no longer exists, the record is logged and skipped. Otherwise,
the tenant update and UTC-day score/ICP snapshot upsert commit in one
transaction. After that commit, Scoring normalizes the tenant email and
partially updates the existing HubSpot Contact identified by that exact email.
It writes only `latam_pql_priority_tier` with P0, P1, or P2 and never creates a
Contact.

- HubSpot `404` is recorded as `contact_not_found` and does not fail scoring.
- Empty or invalid tenant emails are recorded as `invalid_email` and skipped.
- An empty writeback token records `disabled` and leaves local scoring enabled.
- `429`, `5xx`, and network failures use bounded retries and then fail the SQS
  record, allowing the queue retry/DLQ policy to apply.
- Other `4xx` responses fail immediately.

A database processing exception rolls back the transaction and is raised to
Lambda. A HubSpot failure occurs after the database commit and is also raised,
so the SQS retry safely recalculates the score and repeats the idempotent
property update. Logs include tenant ID, priority, and outcome, but never the
tenant email, HubSpot token, or HubSpot response body. The deployed event-source
mapping uses batch size 1 and maximum concurrency 2.

## Configuration

Required:

- `TARGET_DATABASE_URL`, or the equivalent `TARGET_DB_*` variables.

Optional:

- `TARGET_TIDB_SSL_CA_PATH` or `TIDB_SSL_CA_PATH`;
- `KEY_LEADS_CACHE_TTL_SECONDS` (default 900);
- `HUBSPOT_WRITEBACK_TOKEN` (empty disables Contact writeback);
- `HUBSPOT_WRITEBACK_PROPERTY_NAME` (default `latam_pql_priority_tier`);
- `HUBSPOT_WRITEBACK_TIMEOUT_SECONDS` (default `30`);
- `HUBSPOT_WRITEBACK_RETRY_COUNT` (default `3`).

The Lambda timeout is 180 seconds so the default HubSpot timeout and retry
budget fit within one invocation.
