From a1f32986fca8ba602531aee0c45abbdd6e6966c6 Mon Sep 17 00:00:00 2001 From: Christophe Combelles Date: Thu, 2 Jul 2026 17:02:37 +0200 Subject: [PATCH 1/6] feat(scoring): display labels for computed scoring variables Scoring breakdowns resolved indicator names only through the variables map, so computed variables (e.g. a freshwater-ecotoxicity aggregate rebuilt from its organics/inorganics parts) leaked their raw TOML key into API responses and the UI. Add an optional [methods.scoring.labels] table (var -> display label); name resolution now checks labels, then variables, then falls back to the key. Labels are also exposed in list_scoring_sets. --- src/API/MCP.hs | 1 + src/API/Routes.hs | 6 ++++- src/Config.hs | 2 ++ src/Database/Manager.hs | 1 + src/Method/Types.hs | 4 +++ test/MCPColumnarSpec.hs | 1 + test/ScoringLabelSpec.hs | 53 ++++++++++++++++++++++++++++++++++++++++ volca.cabal | 1 + 8 files changed, 68 insertions(+), 1 deletion(-) create mode 100644 test/ScoringLabelSpec.hs diff --git a/src/API/MCP.hs b/src/API/MCP.hs index e795832a..deb5cb3e 100644 --- a/src/API/MCP.hs +++ b/src/API/MCP.hs @@ -1942,6 +1942,7 @@ callListScoringSets dbManager rid args = do , "unit" .= ssUnit ss , "variables" .= ssVariables ss , "computed" .= ssComputed ss + , "labels" .= ssLabels ss , "normalization" .= ssNormalization ss , "weighting" .= ssWeighting ss , "scores" .= ssScores ss diff --git a/src/API/Routes.hs b/src/API/Routes.hs index 9bf2e98d..ecbb4748 100644 --- a/src/API/Routes.hs +++ b/src/API/Routes.hs @@ -2063,7 +2063,11 @@ computeAllScoringSets scoringSets rawScoreMap = do in M.mapWithKey ( \var val -> ScoringIndicator - { siCategory = M.findWithDefault var var (ssVariables ss) + { siCategory = + M.findWithDefault + (M.findWithDefault var var (ssVariables ss)) + var + (ssLabels ss) , siValue = val } ) diff --git a/src/Config.hs b/src/Config.hs index cd420c7a..90fdc290 100644 --- a/src/Config.hs +++ b/src/Config.hs @@ -146,6 +146,7 @@ data ScoringSetConfig = ScoringSetConfig , sscUnit :: !Text -- Display unit (e.g., "Pts") , sscVariables :: !(M.Map Text Text) -- var → impact category name , sscComputed :: !(M.Map Text Text) -- var → formula string + , sscLabels :: !(M.Map Text Text) -- var → display label (for computed vars) , sscNormalization :: !(M.Map Text Double) -- var → normalization factor , sscWeighting :: !(M.Map Text Double) -- var → weight , sscScores :: !(M.Map Text Text) -- score name → formula @@ -263,6 +264,7 @@ instance DecodeTOML ScoringSetConfig where sscUnit <- fromMaybe "Pt" <$> getFieldOpt "unit" sscVariables <- fromMaybe M.empty <$> getFieldOpt "variables" sscComputed <- fromMaybe M.empty <$> getFieldOpt "computed" + sscLabels <- fromMaybe M.empty <$> getFieldOpt "labels" sscNormalization <- fromMaybe M.empty <$> getFieldOpt "normalization" sscWeighting <- fromMaybe M.empty <$> getFieldOpt "weighting" sscScores <- fromMaybe M.empty <$> getFieldOpt "scores" diff --git a/src/Database/Manager.hs b/src/Database/Manager.hs index 6806a160..fb89a8c3 100644 --- a/src/Database/Manager.hs +++ b/src/Database/Manager.hs @@ -1211,6 +1211,7 @@ configToScoringSet ssc = , ssUnit = sscUnit ssc , ssVariables = sscVariables ssc , ssComputed = sscComputed ssc + , ssLabels = sscLabels ssc , ssNormalization = sscNormalization ssc , ssWeighting = sscWeighting ssc , ssScores = sscScores ssc diff --git a/src/Method/Types.hs b/src/Method/Types.hs index a13955cf..d488d0c9 100644 --- a/src/Method/Types.hs +++ b/src/Method/Types.hs @@ -201,6 +201,10 @@ data ScoringSet = ScoringSet -- ^ var → impact category name , ssComputed :: !(M.Map Text Text) -- ^ var → formula (e.g., "2 * etfo + etfi") + , ssLabels :: !(M.Map Text Text) + {- ^ var → display label, for variables (typically computed ones) whose + name is not an impact category from 'ssVariables' + -} , ssNormalization :: !(M.Map Text Double) -- ^ var → normalization factor (divisor) , ssWeighting :: !(M.Map Text Double) diff --git a/test/MCPColumnarSpec.hs b/test/MCPColumnarSpec.hs index 801f9a96..9d74236e 100644 --- a/test/MCPColumnarSpec.hs +++ b/test/MCPColumnarSpec.hs @@ -41,6 +41,7 @@ pefSet = , ssUnit = "µPts PEF" , ssVariables = M.fromList [("acd", "Acidification"), ("cch", "Climate change")] , ssComputed = M.empty + , ssLabels = M.empty , ssNormalization = M.empty , ssWeighting = M.empty , ssScores = M.fromList [("total", "acd + cch")] diff --git a/test/ScoringLabelSpec.hs b/test/ScoringLabelSpec.hs new file mode 100644 index 00000000..66e4c528 --- /dev/null +++ b/test/ScoringLabelSpec.hs @@ -0,0 +1,53 @@ +{-# LANGUAGE OverloadedStrings #-} + +{- | Display names for scoring-set breakdown indicators: primitive variables +take their impact-category name, computed variables take their entry in +@labels@, and only as a last resort does the raw variable key leak out. +-} +module ScoringLabelSpec (spec) where + +import API.Routes (computeAllScoringSets) +import API.Types (ScoringIndicator (..)) +import qualified Data.Map.Strict as M +import Data.Text (Text) +import Method.Types (ScoringSet (..)) +import Test.Hspec + +scoringSet :: ScoringSet +scoringSet = + ScoringSet + { ssName = "ECS" + , ssUnit = "Pts" + , ssVariables = + M.fromList + [ ("cch", "Climate change") + , ("etfo", "Ecotoxicity, freshwater_organics") + , ("etfi", "Ecotoxicity, freshwater_inorganics") + ] + , ssComputed = M.fromList [("etf", "2 * etfo + etfi")] + , ssLabels = M.fromList [("etf", "Ecotoxicity, freshwater")] + , ssNormalization = M.empty + , ssWeighting = M.fromList [("cch", 1.0), ("etf", 1.0)] + , ssScores = M.fromList [("total", "cch + etf")] + , ssDisplayMultiplier = Nothing + } + +rawScores :: M.Map Text Double +rawScores = + M.fromList + [ ("Climate change", 10.0) + , ("Ecotoxicity, freshwater_organics", 2.0) + , ("Ecotoxicity, freshwater_inorganics", 3.0) + ] + +spec :: Spec +spec = describe "scoring indicator display names" $ do + it "labels a computed variable via [methods.scoring.labels]" $ do + (_, indicators) <- computeAllScoringSets [scoringSet] rawScores + (siCategory <$> (M.lookup "ECS" indicators >>= M.lookup "etf")) + `shouldBe` Just "Ecotoxicity, freshwater" + + it "keeps impact-category names for primitive variables" $ do + (_, indicators) <- computeAllScoringSets [scoringSet] rawScores + (siCategory <$> (M.lookup "ECS" indicators >>= M.lookup "cch")) + `shouldBe` Just "Climate change" diff --git a/volca.cabal b/volca.cabal index 3f8500b1..d4c29b51 100644 --- a/volca.cabal +++ b/volca.cabal @@ -307,6 +307,7 @@ test-suite lca-tests , PropertiesSpec , NativeActivityTypeSpec , WasteCrossDBSpec + , ScoringLabelSpec build-depends: base >=4.14 && <5 , volca , hspec From d29dbd83236aaccb5539c55112f24aacae8854f0 Mon Sep 17 00:00:00 2001 From: Christophe Combelles Date: Thu, 2 Jul 2026 20:14:52 +0200 Subject: [PATCH 2/6] fix(scoring): reject labels naming unknown scoring variables A [methods.scoring.labels] key that matches no computed or primitive variable was parsed and then silently never displayed, so a config typo (e.g. 'eft' for 'etf') left the raw key leaking into breakdowns with no signal. The decoder now fails with the offending key names, surfacing the mistake at config load instead of hiding it. --- src/Config.hs | 5 +++++ test/ConfigSpec.hs | 38 ++++++++++++++++++++++++++++++++++++++ volca.cabal | 1 + 3 files changed, 44 insertions(+) diff --git a/src/Config.hs b/src/Config.hs index 90fdc290..f96e8cbe 100644 --- a/src/Config.hs +++ b/src/Config.hs @@ -269,6 +269,11 @@ instance DecodeTOML ScoringSetConfig where sscWeighting <- fromMaybe M.empty <$> getFieldOpt "weighting" sscScores <- fromMaybe M.empty <$> getFieldOpt "scores" sscDisplayMultiplier <- getFieldOpt "displayMultiplier" + let orphanLabels = M.keysSet sscLabels S.\\ (M.keysSet sscComputed <> M.keysSet sscVariables) + unless (S.null orphanLabels) $ + fail $ + "labels: unknown scoring variable(s): " + <> T.unpack (T.intercalate ", " (S.toList orphanLabels)) pure ScoringSetConfig{..} instance DecodeTOML RefDataConfig where diff --git a/test/ConfigSpec.hs b/test/ConfigSpec.hs index 7ff44cec..470c65b8 100644 --- a/test/ConfigSpec.hs +++ b/test/ConfigSpec.hs @@ -6,10 +6,13 @@ import Config ( Config (..), MethodConfig (..), RefDataConfig (..), + ScoringSetConfig (..), applyDataDir, defaultConfig, redirectIntoDataDir, ) +import qualified Data.Map.Strict as M +import Data.Text (Text) import qualified TOML import Test.Hspec @@ -75,3 +78,38 @@ spec = do it "is a no-op when the env var is unset" $ applyDataDir Nothing cfg `shouldBe` cfg + + describe "ScoringSetConfig labels" $ do + let decodeSet :: Text -> Either TOML.TOMLError ScoringSetConfig + decodeSet = TOML.decode + + it "accepts a label on a computed variable" $ do + let toml = + "name = \"ECS\"\n\ + \[computed]\n\ + \etf = \"2 * etfo + etfi\"\n\ + \[labels]\n\ + \etf = \"Ecotoxicity, freshwater\"\n" + fmap sscLabels (decodeSet toml) + `shouldBe` Right (M.singleton "etf" "Ecotoxicity, freshwater") + + it "accepts a label on a primitive variable" $ do + let toml = + "name = \"ECS\"\n\ + \[variables]\n\ + \cch = \"Climate change\"\n\ + \[labels]\n\ + \cch = \"Changement climatique\"\n" + fmap sscLabels (decodeSet toml) + `shouldBe` Right (M.singleton "cch" "Changement climatique") + + it "rejects a label whose key matches no scoring variable" $ do + let toml = + "name = \"ECS\"\n\ + \[computed]\n\ + \etf = \"2 * etfo + etfi\"\n\ + \[labels]\n\ + \eft = \"Ecotoxicity, freshwater\"\n" + case decodeSet toml of + Right _ -> expectationFailure "orphan label key must be rejected" + Left err -> show err `shouldContain` "eft" diff --git a/volca.cabal b/volca.cabal index d4c29b51..6b7eaae5 100644 --- a/volca.cabal +++ b/volca.cabal @@ -337,6 +337,7 @@ test-suite lca-tests , optparse-applicative , QuickCheck , network + , toml-reader build-tool-depends: hspec-discover:hspec-discover default-language: Haskell2010 From b4786b25f3be034c2c07110f6d643f9a64f42609 Mon Sep 17 00:00:00 2001 From: Christophe Combelles Date: Thu, 2 Jul 2026 20:14:52 +0200 Subject: [PATCH 3/6] docs: changelog entry for scoring display labels --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 94a391f1..e6acc5fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## [Unreleased] + +### Added +- Scoring-set breakdowns can now show a human-readable name for computed + indicators (for example "Ecotoxicity, freshwater" instead of the raw key + `etf`), via an optional `[methods.scoring.labels]` table in the scoring + configuration. A label naming an unknown scoring variable is rejected when + the configuration loads instead of being silently ignored. + ## [0.8.1] - 2026-06-24 ### Fixed From 1c2ff7ad5aed8b9af49eafc199fe47e580791bad Mon Sep 17 00:00:00 2001 From: Christophe Combelles Date: Thu, 2 Jul 2026 20:16:13 +0200 Subject: [PATCH 4/6] refactor(scoring): resolve indicator names via one left-biased union MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The nested findWithDefault encoded the labels → variables → raw-key precedence in argument position, where a swapped argument would still type-check. A single lookup in ssLabels <> ssVariables states the same priority left-to-right, matching the identity-default idiom used elsewhere. The spec now also covers the raw-key fallback its docstring already promised, and uses a neutral scoring-set name. --- src/API/Routes.hs | 7 ++----- test/ScoringLabelSpec.hs | 25 +++++++++++++++---------- 2 files changed, 17 insertions(+), 15 deletions(-) diff --git a/src/API/Routes.hs b/src/API/Routes.hs index ecbb4748..769b5856 100644 --- a/src/API/Routes.hs +++ b/src/API/Routes.hs @@ -2060,14 +2060,11 @@ computeAllScoringSets scoringSets rawScoreMap = do -- `scores.*` formula) are hidden from the breakdown. toIndicators ss e = let displayed = S.fromList (concatMap (Expr.collectIdentifiers '.') (M.elems (ssScores ss))) + names = ssLabels ss <> ssVariables ss in M.mapWithKey ( \var val -> ScoringIndicator - { siCategory = - M.findWithDefault - (M.findWithDefault var var (ssVariables ss)) - var - (ssLabels ss) + { siCategory = M.findWithDefault var var names , siValue = val } ) diff --git a/test/ScoringLabelSpec.hs b/test/ScoringLabelSpec.hs index 66e4c528..2fd28c06 100644 --- a/test/ScoringLabelSpec.hs +++ b/test/ScoringLabelSpec.hs @@ -16,7 +16,7 @@ import Test.Hspec scoringSet :: ScoringSet scoringSet = ScoringSet - { ssName = "ECS" + { ssName = "SingleScore" , ssUnit = "Pts" , ssVariables = M.fromList @@ -40,14 +40,19 @@ rawScores = , ("Ecotoxicity, freshwater_inorganics", 3.0) ] +-- | Display name of one breakdown indicator, straight from the scoring pass. +categoryOf :: ScoringSet -> Text -> IO (Maybe Text) +categoryOf ss var = do + (_, indicators) <- computeAllScoringSets [ss] rawScores + pure (siCategory <$> (M.lookup (ssName ss) indicators >>= M.lookup var)) + spec :: Spec spec = describe "scoring indicator display names" $ do - it "labels a computed variable via [methods.scoring.labels]" $ do - (_, indicators) <- computeAllScoringSets [scoringSet] rawScores - (siCategory <$> (M.lookup "ECS" indicators >>= M.lookup "etf")) - `shouldBe` Just "Ecotoxicity, freshwater" - - it "keeps impact-category names for primitive variables" $ do - (_, indicators) <- computeAllScoringSets [scoringSet] rawScores - (siCategory <$> (M.lookup "ECS" indicators >>= M.lookup "cch")) - `shouldBe` Just "Climate change" + it "labels a computed variable via [methods.scoring.labels]" $ + categoryOf scoringSet "etf" >>= (`shouldBe` Just "Ecotoxicity, freshwater") + + it "keeps impact-category names for primitive variables" $ + categoryOf scoringSet "cch" >>= (`shouldBe` Just "Climate change") + + it "falls back to the raw key for a computed variable with no label" $ + categoryOf scoringSet{ssLabels = M.empty} "etf" >>= (`shouldBe` Just "etf") From d450ffb67b644d763dcc23272a20966a2b591c39 Mon Sep 17 00:00:00 2001 From: Christophe Combelles Date: Thu, 2 Jul 2026 20:16:13 +0200 Subject: [PATCH 5/6] feat(mcp): label the dominant indicator in columnar batches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit score_activities summary mode emitted only the raw variable key in dominant_indicator, so clients showed an opaque key (e.g. 'etf') for the very field meant for human triage — while score_activity's breakdown now resolves display labels. Carry the indicator's display name alongside the key; it was already in hand via siCategory. --- src/API/MCP/Columnar.hs | 9 +++++---- test/MCPColumnarSpec.hs | 16 +++++++++------- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/src/API/MCP/Columnar.hs b/src/API/MCP/Columnar.hs index c2070b7f..8a5f6e53 100644 --- a/src/API/MCP/Columnar.hs +++ b/src/API/MCP/Columnar.hs @@ -185,9 +185,10 @@ toColumnarBatch summaryOnly mBaseUrl dbName coll ss bir = ++ [total] ++ tailCells -{- | Format the dominant indicator of a row as a @{key, share_pct}@ -object. Returns 'Null' when the row has no total, the total is zero -(share is undefined), or the indicator map is empty. +{- | Format the dominant indicator of a row as a @{key, label, share_pct}@ +object — @label@ is the indicator's display name ('siCategory'), so clients +never have to show the raw variable key. Returns 'Null' when the row has no +total, the total is zero (share is undefined), or the indicator map is empty. -} dominantIndicatorCell :: Maybe Double -> M.Map Text ScoringIndicator -> Value dominantIndicatorCell mTotal indMap @@ -199,5 +200,5 @@ dominantIndicatorCell mTotal indMap (comparing (abs . siValue . snd)) (M.toList indMap) share = abs (siValue ind) / abs t * 100 - in object ["key" .= k, "share_pct" .= share] + in object ["key" .= k, "label" .= siCategory ind, "share_pct" .= share] | otherwise = Null diff --git a/test/MCPColumnarSpec.hs b/test/MCPColumnarSpec.hs index 9d74236e..754fab90 100644 --- a/test/MCPColumnarSpec.hs +++ b/test/MCPColumnarSpec.hs @@ -248,12 +248,12 @@ spec = do ) ) - it "fills the cell with a {key, share_pct} object, not a delimited string" $ do + it "fills the cell with a {key, label, share_pct} object, not a delimited string" $ do case KM.lookup (fromText "rows") km of Just (Array rs) -> case V.toList rs of [Array a] -> last (V.toList a) - `shouldBe` object ["key" .= ("cch" :: Text), "share_pct" .= (80.0 :: Double)] + `shouldBe` object ["key" .= ("cch" :: Text), "label" .= ("cch" :: Text), "share_pct" .= (80.0 :: Double)] other -> expectationFailure ("expected one row, got " <> show other) other -> expectationFailure ("expected rows array, got " <> show other) @@ -309,17 +309,19 @@ spec = do KM.lookup (fromText "rows") km `shouldBe` Just (Array (V.fromList [])) describe "dominantIndicatorCell" $ do - let inds = M.fromList [("a", ScoringIndicator "a" 1.0), ("b", ScoringIndicator "b" 9.0)] + -- The indicator's siCategory is its display name — it rides along as + -- 'label' so clients never have to show the raw variable key. + let inds = M.fromList [("a", ScoringIndicator "a" 1.0), ("b", ScoringIndicator "Big impact" 9.0)] - it "picks the indicator with the largest absolute share" $ + it "picks the indicator with the largest absolute share, labelled with its display name" $ dominantIndicatorCell (Just 10.0) inds - `shouldBe` object ["key" .= ("b" :: Text), "share_pct" .= (90.0 :: Double)] + `shouldBe` object ["key" .= ("b" :: Text), "label" .= ("Big impact" :: Text), "share_pct" .= (90.0 :: Double)] it "uses absolute value so negative contributors can win" $ dominantIndicatorCell (Just 10.0) - (M.fromList [("a", ScoringIndicator "a" 1.0), ("b", ScoringIndicator "b" (-9.0))]) - `shouldBe` object ["key" .= ("b" :: Text), "share_pct" .= (90.0 :: Double)] + (M.fromList [("a", ScoringIndicator "a" 1.0), ("b", ScoringIndicator "Big impact" (-9.0))]) + `shouldBe` object ["key" .= ("b" :: Text), "label" .= ("Big impact" :: Text), "share_pct" .= (90.0 :: Double)] it "returns Null when the total is zero (share undefined)" $ dominantIndicatorCell (Just 0.0) inds `shouldBe` Null From 4029b9d5ef7f175e280feabf3103dbbded04d92f Mon Sep 17 00:00:00 2001 From: Christophe Combelles Date: Thu, 2 Jul 2026 20:16:24 +0200 Subject: [PATCH 6/6] docs(scoring): document labels on every client-facing surface The list_scoring_sets tool description now names the labels key it returns, the pyvolca ScoringIndicator docstring no longer claims category is always an impact category (README regenerated), and the changelog gains an entry for the feature. --- CHANGELOG.md | 5 +++-- pyvolca/README.md | 5 ++++- pyvolca/src/volca/types.py | 5 ++++- src/API/Resources.hs | 4 +++- src/Method/Types.hs | 6 ++++-- 5 files changed, 18 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e6acc5fc..3e4e8472 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,8 +6,9 @@ - Scoring-set breakdowns can now show a human-readable name for computed indicators (for example "Ecotoxicity, freshwater" instead of the raw key `etf`), via an optional `[methods.scoring.labels]` table in the scoring - configuration. A label naming an unknown scoring variable is rejected when - the configuration loads instead of being silently ignored. + configuration. The dominant indicator of batch scoring summaries carries + the same display name. A label naming an unknown scoring variable is + rejected when the configuration loads instead of being silently ignored. ## [0.8.1] - 2026-06-24 diff --git a/pyvolca/README.md b/pyvolca/README.md index 315b7810..756c10d1 100644 --- a/pyvolca/README.md +++ b/pyvolca/README.md @@ -1367,7 +1367,10 @@ One per-variable entry inside ``LCIABatchResult.scoring_indicators``. ``value`` is pre-multiplied by the scoring set's ``displayMultiplier`` (configured in the scoring TOML) and expressed in the set's display unit. -``category`` names the impact category the variable was resolved from. +``category`` is the indicator's display name: the scoring set's +``labels`` entry when one is configured (typically for computed +variables), otherwise the impact category the variable was resolved +from, or as a last resort the raw variable key. | Field | Type | Default | |-------|------|---------| diff --git a/pyvolca/src/volca/types.py b/pyvolca/src/volca/types.py index 72cdb4bb..cea8eb5d 100644 --- a/pyvolca/src/volca/types.py +++ b/pyvolca/src/volca/types.py @@ -268,7 +268,10 @@ class ScoringIndicator(FromJson): ``value`` is pre-multiplied by the scoring set's ``displayMultiplier`` (configured in the scoring TOML) and expressed in the set's display unit. - ``category`` names the impact category the variable was resolved from. + ``category`` is the indicator's display name: the scoring set's + ``labels`` entry when one is configured (typically for computed + variables), otherwise the impact category the variable was resolved + from, or as a last resort the raw variable key. """ category: str diff --git a/src/API/Resources.hs b/src/API/Resources.hs index 614057ca..1119949b 100644 --- a/src/API/Resources.hs +++ b/src/API/Resources.hs @@ -427,7 +427,9 @@ description r = case r of \values (e.g. an overall single score plus per-area-of-protection \ \sub-scores). For each set returns: name, display unit, variables \ \referenced (with the impact category each binds to), computed \ - \intermediates, normalization and weighting factors, and the score \ + \intermediates, display labels (variable → human-readable indicator \ + \name shown in score breakdowns), normalization and weighting \ + \factors, and the score \ \formulas. Use the returned set names as keys when interpreting \ \score_activity / score_activities responses." diff --git a/src/Method/Types.hs b/src/Method/Types.hs index d488d0c9..da205c19 100644 --- a/src/Method/Types.hs +++ b/src/Method/Types.hs @@ -202,8 +202,10 @@ data ScoringSet = ScoringSet , ssComputed :: !(M.Map Text Text) -- ^ var → formula (e.g., "2 * etfo + etfi") , ssLabels :: !(M.Map Text Text) - {- ^ var → display label, for variables (typically computed ones) whose - name is not an impact category from 'ssVariables' + {- ^ var → display label, overriding the breakdown name. Needed for + computed variables, whose raw key would otherwise leak; on a primitive + variable it deliberately overrides the 'ssVariables' category name. + Keys are validated against declared variables when the config decodes. -} , ssNormalization :: !(M.Map Text Double) -- ^ var → normalization factor (divisor)