4. Weighted Score Aggregation Across Assessment Categories

Why weighted aggregation needs to be configuration, not code

Bharat NCAP publishes an overall vehicle safety score that is not a plain average of the assessment categories. Each category carries a different maximum point value, and the overall score is the sum of the points earned across categories, normalised against the total available points. When the protocol is revised, the category maxima and the set of contributing categories can change. Hard-coding the arithmetic into the dashboard means every protocol revision becomes a code change and a redeploy. The task here is to externalise the weighting into a configuration object, apply it deterministically, and expose the per-category contribution so a reviewer can see exactly where the overall score came from.

The aggregation model used in this case is a weighted sum with explicit maxima. For each category c with earned points p_c and maximum m_c, the category contributes p_c raw points and a weight w_c = m_c / Σm. The overall score is Σ(p_c × w_c) expressed on a 0–100 scale, which is equivalent to 100 × Σp_c / Σm. Keeping both the raw points and the weight in the output is what makes the dashboard auditable: the raw points show what the vehicle achieved, the weight shows how much that category mattered, and the product shows the contribution.

Configuration schema

The configuration is a versioned object so that a stored score can always be traced back to the rules that produced it. Each category entry declares its identifier, its maximum points, and whether it is included in the overall aggregation. Categories that are reported but excluded (for example, a category that is informational only in a given protocol revision) still appear in the breakdown with a zero weight.

{
  "protocol": "BNCAP-2023",
  "scale": 100,
  "categories": [
    { "id": "AOP", "label": "Adult Occupant Protection", "max": 32, "include": true },
    { "id": "COP", "label": "Child Occupant Protection",   "max": 49, "include": true },
    { "id": "SA",  "label": "Safety Assist",               "max": 16, "include": true },
    { "id": "PED", "label": "Pedestrian Protection",       "max": 0,  "include": false }
  ]
}

Note that max values above are illustrative teaching parameters for this exercise, not a claim about the current published protocol. The implementation must not assume them; it reads whatever the configuration declares. The include flag is the single switch that decides whether a category enters the denominator, which prevents the classic bug where an excluded category silently inflates or deflates the overall score.

Implementation

The aggregator is a pure function: given a configuration and a map of earned points, it returns the overall score plus a contribution record per category. Purity matters because the dashboard will call it for every vehicle in a list view, and the result must be identical for identical inputs regardless of call order.

function aggregateScores(config, earned) {
  const included = config.categories.filter(c => c.include);
  const totalMax = included.reduce((s, c) => s + c.max, 0);
  if (totalMax <= 0) throw new Error("No included categories with positive max");

  const contributions = config.categories.map(c => {
    const points = earned[c.id] ?? 0;
    if (points < 0 || points > c.max) {
      throw new RangeError(`${c.id}: ${points} outside 0..${c.max}`);
    }
    const weight = c.include ? c.max / totalMax : 0;
    return {
      id: c.id,
      label: c.label,
      points,
      max: c.max,
      weight,
      contribution: points * weight
    };
  });

  const overall = contributions.reduce((s, c) => s + c.contribution, 0);
  const scaled = (overall / 100) * config.scale;

  return {
    protocol: config.protocol,
    overall: round(scaled, 2),
    totalMax,
    contributions
  };
}

function round(v, dp) {
  const f = 10 ** dp;
  return Math.round((v + Number.EPSILON) * f) / f;
}

Two validation rules are deliberate. First, a points value outside 0..max throws rather than being clamped, because a value above the category maximum almost always indicates an ingestion error upstream and silently clamping it would hide the defect. Second, the function rejects a configuration with no positive included maximum, which would otherwise produce a division by zero or a meaningless score.

Worked example

Using the configuration above, suppose a vehicle earns AOP 28, COP 41, and SA 12. The included maxima sum to 32 + 49 + 16 = 97. The weights are therefore 32/97 ≈ 0.3299, 49/97 ≈ 0.5052, and 16/97 ≈ 0.1649. The contributions are 28 × 0.3299 ≈ 9.24, 41 × 0.5052 ≈ 20.71, and 12 × 0.1649 ≈ 1.98, giving an overall of about 31.93 on the 0–100 scale. The same result follows from 100 × (28 + 41 + 12) / 97 = 100 × 81 / 97 ≈ 83.51 raw, which is not the same number — the first is the weighted contribution sum on the configured scale, the second is the percentage of available points. The dashboard must display one convention consistently; this implementation reports the weighted sum on config.scale and also returns totalMax so the percentage view can be derived without ambiguity.

Do not mix the two conventions in the same view. If the overall score is shown as a weighted sum on a 100-point scale, the per-category contributions must sum to that same number. If the view shows percentage of available points, label it explicitly as such. A dashboard that shows a weighted sum next to a percentage-of-maximum without labels will be read as inconsistent by reviewers.
Weighted aggregation: points → weights → overall score EARNED POINTS AOP 28 / 32 COP 41 / 49 SA 12 / 16 PED (excluded) weight = 0 WEIGHT w = max / Σmax 32 / 97 = 0.3299 49 / 97 = 0.5052 16 / 97 = 0.1649 excluded → 0 CONTRIBUTION p × w 28 × 0.3299 = 9.24 41 × 0.5052 = 20.71 12 × 0.1649 = 1.98 0 Σ contributions overall = 31.93 scale = 100, protocol BNCAP-2023 Σmax = 97 excluded categories do not enter denominator

Exposing per-category contributions to the dashboard

The aggregator returns a contributions array that the dashboard renders directly. Each entry carries the raw points, the maximum, the weight, and the contribution, so a reviewer can answer "why is this vehicle's overall score what it is" without recomputing anything. A useful rendering rule is to sort contributions by descending contribution value, which immediately surfaces the category that dominates the score. For the worked example the order is COP (20.71), AOP (9.24), SA (1.98), which tells the reviewer that child occupant protection is the largest single driver of this vehicle's result.

Because the configuration is versioned, the stored score record should persist protocol alongside the overall value. When the protocol changes, previously stored scores remain interpretable against the configuration that produced them, and the dashboard can flag records whose protocol differs from the currently active one rather than silently recomputing them under new rules.

Verification checklist

  • Sum of contribution across all returned categories equals overall within rounding tolerance (here, 9.24 + 20.71 + 1.98 = 31.93).
  • An excluded category returns weight = 0 and contribution = 0 but still appears in the array with its points and maximum.
  • A points value above the category maximum throws RangeError naming the category, rather than being clamped.
  • A configuration with all categories excluded throws, rather than returning NaN or Infinity.
  • Changing a category's max in the configuration changes the weights and the overall score without any code change.
Keep the aggregator free of I/O and of any reference to the currently active configuration. Pass the configuration in as an argument. This makes the function trivially testable with fixture configurations and lets the dashboard evaluate historical records against their original protocol rules.