Scoring formula
Goal: turn an arbitrary number of flags — positive and negative — into one -100–100 score plus a traffic-light band. 0 = neutral (no data). 100 = strongly positive. -100 = strongly negative.
Inputs (all data-driven, extendable)
data/categories.json — one entry per flag category:
{ "id": "boycott-conflict", "name": "Boycott / conflict complicity", "icon": "Megaphone", "defaultWeight": 3 }
Flag polarity — each flag is either a concern (negative, default) or a positive signal (positive).
Severity multiplier (per flag):
| severity | multiplier |
|---|---|
| minor | 1 |
| moderate | 2 |
| severe | 3 |
| systemic | 5 |
Confidence multiplier (derived from the flag's status):
| status | multiplier |
|---|---|
| sourced | 1.0 |
| unverified | 0.6 |
Decay multiplier — reserved extension point, defaults to 1.0. A future version could reduce the weight of old flags without changing the rest of the formula.
Step 1 — per-flag contribution
contribution(flag) = category.weight × severity.multiplier × confidence.multiplier × decay.multiplier
Step 2 — raw totals (per polarity)
rawNeg = Σ contribution(flag) for all negative flags
rawPos = Σ contribution(flag) for all positive flags
Step 3 — saturate each direction
A straight sum would let a company with many flags blow past any sensible scale. Apply diminishing returns separately to each direction so each component stays in [0, 100):
saturate(raw) = 100 × (1 − e^(−raw / K))
At raw = 0: saturate = 0 (no effect). As raw → ∞: saturate → 100 (component maxes out).
Step 4 — combine into -100–100 score
score = round(saturate(rawPos) − saturate(rawNeg))
A company with no flags has rawPos = rawNeg = 0, so score = 0 exactly — neutral/unknown, not perfect. Green flags push toward 100; red flags push toward -100.
K is a single tunable constant. Currently 12. Raising it makes flags hit more gently; lowering makes them hit harder. Calibrated against the first ~20 real seeded companies — revisit as more data is added.
Example at K = 12:
| scenario | rawNeg | rawPos | score |
|---|---|---|---|
| no flags | 0 | 0 | 0 |
| one sourced, severe flag in a weight-3 category | 9 | 0 | -53 |
| same, plus one sourced, severe green flag | 9 | 9 | 0 |
| one sourced, minor flag in a weight-2 category | 2 | 0 | -15 |
| two sourced, minor red flags in weight-2 categories | 4 | 0 | -28 |
Step 5 — traffic-light band
| score | band | meaning |
|---|---|---|
| 34 to 100 | green | low concern |
| -33 to 33 | yellow | neutral / insufficient data |
| -100 to -34 | red | high concern |
Extending this later
- New category: add to
categories.jsonwith adefaultWeight. No formula change. - New severity level: add a row to the severity table + its multiplier.
- Decay: implement the reserved
decay.multiplierfunction; every flag already carriesdateAdded. - Per-user weighting: categories are already just weighted numbers — a future "mute category X" is a per-user override of
category.weightat render time, no server-side change.
Scores do not flow along the ownership graph
A company is scored on its own flags only. Ownership is displayed for context, but a parent's conduct never lowers a subsidiary's score and vice versa.
If a parent's conduct genuinely implicates a subsidiary, record it as a flag on the subsidiary with its own source. That keeps every point on a company's score traceable to a sourced claim about that company, and keeps the score stable when an ownership edge is later corrected.
Editorial note
Default category weights reflect a starting judgment about relative severity. This is a defensible starting point, not a claim of moral authority — change via normal PRs.