# Score Calculation

This document explains how individual submission scores and company-level scores are calculated in this codebase.

## Scope and terminology

- Individual score = one submission's persisted results.
- Company score = aggregate score for a location and survey.
- Most aggregate tables are location-scoped (not global company-scoped).

## Where the logic lives

- Persisted submission + company aggregates:
  - `services/result_pipeline_service.py`
  - Triggered by `POST /submissions/{submission_id}/calculate-results`
- Lightweight on-demand score response:
  - `services/survey_service.py`
  - Triggered by `GET /submissions/{submission_id}/score`

## 1) Individual submission scoring (persisted)

### Inputs

Only scored answers are used:
- `SurveyQuestion.question_type == "Scored"`
- `SurveyResponse.score is not null`

Each scored answer uses:
- response score (`SurveyResponse.score`)
- question weight (`SurveyQuestion.question_weight`, default 1.0)
- question max score (`SurveyQuestion.max_score`, default 5.0)
- category weight (`Category.category_weighting`, default 1.0)
- risk metadata (`primary_risk`, `secondary_risk`, `risk_direction`)

### Scored answer normalization at submit time

`POST /submit-survey` accepts scored values in multiple forms and normalizes them before persistence:

- Plain numeric value (`"1"`..`"5"` or `1`..`5`) -> stored directly as score
- Number embedded in text (`"1 - Completely depleted"`) -> number is extracted
- Option label text for scored questions (`"Completely depleted"`) -> mapped by option order to score (`1` for first option, `2` for second, etc.)
- CSS-selector style values that include `for$="_N"` (for example from malformed Q031 UI payloads) -> `N` is extracted and stored as score

This is especially relevant for UI variants that display custom labels/icons for Q031 while still requiring a valid numeric score in storage.

### Per-question percentage normalization

For each scored answer:

- `percentage = ((score - 1) / (max_score - 1)) * 100`
- clamped to `[0, 100]`

This maps a 1..max scale to 0..100.

### Category-level score

Within each category:

1. Compute weighted raw average (raw scale):
- `raw_score = sum(score * question_weight) / sum(question_weight)`

2. Convert raw score to percentage (currently assumes 1..5 scale):
- `percentage_score = ((raw_score - 1) / 4) * 100`
- clamped to `[0, 100]`

3. Apply category weight:
- `weighted_contribution = percentage_score * category_weight`

### Overall submission score

Across all categories that have data:

- `overall_score = sum(weighted_contribution) / sum(category_weight)`
- rounded to 2 decimals

This value is stored in `submission_results.overall_score`.

### Completion percentage

Coverage is tracked as:

- `completion_percentage = (answered_scored_questions / total_scored_questions_in_survey) * 100`
- rounded to 2 decimals

### Risk scoring (submission)

For each scored answer, a risk percentage is computed from the answer percentage:

- If `risk_direction` indicates higher score is worse:
  - `risk_percentage = percentage`
- Otherwise (default behavior):
  - `risk_percentage = 100 - percentage`

Risk accumulation weights:
- primary risk: full question weight
- secondary risk: half question weight

Per risk dimension, average risk percentage is mapped to label:
- `>= 66` -> `High`
- `>= 33` and `< 66` -> `Medium`
- `< 33` -> `Low`

The numeric averages are persisted per submission as:
- `burnout_risk_score`
- `trust_risk_score`
- `leadership_disconnect_risk_score`
- `retention_risk_score`

The label fields (`burnout_risk`, `trust_risk`, etc.) are derived from these numeric values.

Dimensions:
- burnout
- trust
- leadership disconnect
- retention

### Culture band (submission)

From overall score:
- `>= 85`: `Strong & Sustainable`
- `>= 70`: `Good with Improvement Areas`
- `>= 55`: `At Risk`
- `>= 40`: `Struggling`
- `< 40`: `Burnout Zone`

### Persistence side effects

The pipeline upserts:
- `submission_category_results` (per submission + category)
- `submission_results` (one row per submission)

## 2) Company score calculation (company + country + city aggregate)

After submission results are stored, company aggregates are recalculated for that submission's `company_id` + `country_id` + `city_id` + `survey_id`.

### Company category averages

For each category:

- average over submitted submissions at same company + country + city + survey:
  - `avg(submission_category_results.percentage_score)`
- stored in `company_category_results.average_score`
- `submission_count` is also stored per category

### Overall company score

Take category averages and apply category weights:

- `overall_company_score = sum(category_average * category_weight) / sum(category_weight)`
- rounded to 2 decimals

Stored in `company_results.overall_score`.

### Company risk averages and labels

Company risk averages are computed directly from submission-level numeric risk percentages (`*_risk_score`) and stored in:
- `average_burnout_risk`
- `average_trust_risk`
- `average_leadership_disconnect_risk`
- `average_retention_risk`

Company risk labels are then derived from those averages with the same thresholds:
- `>= 66` -> `High`
- `>= 33` -> `Medium`
- else `Low`

### Comparison readiness

`company_results.meets_minimum_response_threshold` becomes `true` when the eligible submission count meets the configured threshold (default `5`).

Eligibility for this count uses unique `Submission.id` and includes only submissions that are:
- `submitted`
- linked to a verification request with status `verified` or `used`
- backed by a successful `submission_results` row

`has_company_comparison` is retained as a backward-compatible alias during transition.

## 3) Lightweight score endpoint (`GET /submissions/{id}/score`)

This endpoint is read-only and does not write aggregate tables.

It uses the same shared scoring computation as the persisted pipeline.

It computes:
- overall normalized score from the shared 0..100 result:
  - `overall_score_0_to_10 = overall_score_0_to_100 / 10`
- derived raw-like display value:
  - `overall_score_0_to_5 = ((overall_score_0_to_100 / 100) * 4) + 1`
- per-category 0..5 display scores derived from category percentages:
  - `category_score_0_to_5 = ((category_percentage / 100) * 4) + 1`
- completion percentage for scored questions

## Notes and implementation details

- Most persisted aggregate scoring is location-scoped.
- Both submission and company calculations use only submissions with status `submitted` for aggregate recomputation.
- Rounding is typically 2 decimals for stored score outputs (except stored category weight which keeps 4 decimals in submission category results).