rig-ores 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,26 @@
1
+ # Binaries
2
+ bin/
3
+ dist/
4
+ *.wasm
5
+
6
+ # Generated
7
+ gen/
8
+
9
+ # IDE
10
+ .idea/
11
+ .vscode/
12
+ *.swp
13
+
14
+ # OS
15
+ .DS_Store
16
+ Thumbs.db
17
+
18
+ # Test
19
+ coverage.txt
20
+ coverage.html
21
+
22
+ # Documentation build output
23
+ site/
24
+
25
+ # Superpowers specs and plans (auto-generated, not for version control)
26
+ docs/superpowers/
@@ -0,0 +1,173 @@
1
+ Metadata-Version: 2.5
2
+ Name: rig-ores
3
+ Version: 0.1.0
4
+ Summary: Python client for ORES: local V0 severity-count risk scoring
5
+ Author-email: liad@rig.security, hila@rig.security
6
+ Maintainer-email: liad@rig.security, hila@rig.security
7
+ License-Expression: Apache-2.0
8
+ Requires-Python: >=3.10
9
+ Provides-Extra: test
10
+ Requires-Dist: pytest; extra == 'test'
11
+ Description-Content-Type: text/markdown
12
+
13
+ # rig-ores
14
+
15
+ Python client for [ORES](https://github.com/rigsecurity/ores)'s **V0** risk score:
16
+ a severity-count model for identities/assets, ported from rig-security/ml-services'
17
+ `rice/risk_score/risk_score.py`. It runs entirely locally — no `oresd` daemon or
18
+ network call required — so it's suited to notebooks and batch analysis as well as
19
+ services.
20
+
21
+ Given an identity's finding counts by severity (critical / high / medium / low),
22
+ it returns a 0–10 score capped by the identity's most severe tier, plus the named
23
+ contributions ("factors") that produced it.
24
+
25
+ ## Install
26
+
27
+ Not yet published to PyPI (see below). Until then, install directly from the repo:
28
+
29
+ ```bash
30
+ # editable install from a local clone
31
+ pip install -e /path/to/ores/clients/python
32
+
33
+ # or straight from GitHub, no clone needed
34
+ pip install "git+https://github.com/rigsecurity/ores.git#subdirectory=clients/python"
35
+ ```
36
+
37
+ ## Usage
38
+
39
+ ### Single identity
40
+
41
+ ```python
42
+ from rig_ores import score_identity
43
+
44
+ result = score_identity("cluster-123", critical=1, high=2)
45
+
46
+ result.score # 9.42
47
+ result.capped # False
48
+ result.factors # [Factor(feature="primary", tier="critical", contribution=9.0, reasoning="..."), ...]
49
+
50
+ print(result.explain())
51
+ # Score: 9.42 (critical)
52
+ # +9.00 1 critical finding (highest-severity present)
53
+ # +0.35 1 high finding (next tier down)
54
+ # +0.07 1 further high finding(s), diminishing weight
55
+ ```
56
+
57
+ `explain()` only lists non-zero contributions, ranked highest first — see
58
+ [Examples](#examples) below for the full range of shapes it can take.
59
+
60
+ ### Batch
61
+
62
+ `score_batch` takes any iterable of mappings with `id`, `critical`, `high`,
63
+ `medium`, `low` keys (case-insensitive; missing severities default to 0) — a list
64
+ of dicts, or a pandas DataFrame via `df.to_dict("records")`:
65
+
66
+ ```python
67
+ from rig_ores import score_batch
68
+
69
+ rows = [
70
+ {"id": "cluster-123", "critical": 1, "high": 2, "medium": 0, "low": 0},
71
+ {"id": "cluster-456", "critical": 0, "high": 0, "medium": 3, "low": 1},
72
+ ]
73
+
74
+ for result in score_batch(rows):
75
+ print(result.id, result.score, result.explain())
76
+
77
+ # from a DataFrame
78
+ # score_batch(findings_df.to_dict("records"))
79
+ ```
80
+
81
+ ## Examples
82
+
83
+ 24 finding-count combinations chosen to exercise every named contribution
84
+ (`primary`, `same_secondary`, `lower_secondary`, `same_tier_rest`,
85
+ `cross_tier_rest`) and both non-capped and capped scores, computed with the
86
+ default weights:
87
+
88
+ | Scenario | critical | high | medium | low | Score | Capped | Features exercised |
89
+ |---|---:|---:|---:|---:|---:|:---:|---|
90
+ | No findings | 0 | 0 | 0 | 0 | 0.00 | no | — |
91
+ | Single critical | 1 | 0 | 0 | 0 | 9.00 | no | primary |
92
+ | Single high | 0 | 1 | 0 | 0 | 7.00 | no | primary |
93
+ | Single medium | 0 | 0 | 1 | 0 | 4.00 | no | primary |
94
+ | Single low | 0 | 0 | 0 | 1 | 2.00 | no | primary |
95
+ | Same secondary: 2 criticals | 2 | 0 | 0 | 0 | 9.70 | no | primary, same_secondary |
96
+ | Same secondary: 2 highs | 0 | 2 | 0 | 0 | 8.05 | no | primary, same_secondary |
97
+ | Same secondary: 2 mediums | 0 | 0 | 2 | 0 | 5.75 | no | primary, same_secondary |
98
+ | Same secondary: 2 lows | 0 | 0 | 0 | 2 | 2.96 | no | primary, same_secondary |
99
+ | Lower secondary: critical + high | 1 | 1 | 0 | 0 | 9.35 | no | primary, lower_secondary |
100
+ | Lower secondary: critical + medium | 1 | 0 | 1 | 0 | 9.25 | no | primary, lower_secondary |
101
+ | Lower secondary: critical + low | 1 | 0 | 0 | 1 | 9.15 | no | primary, lower_secondary |
102
+ | Lower secondary: high + medium | 0 | 1 | 1 | 0 | 7.25 | no | primary, lower_secondary |
103
+ | Lower secondary: high + low | 0 | 1 | 0 | 1 | 7.15 | no | primary, lower_secondary |
104
+ | Lower secondary: medium + low | 0 | 0 | 1 | 1 | 4.15 | no | primary, lower_secondary |
105
+ | Same-tier decay: 4 criticals | 4 | 0 | 0 | 0 | 9.99 | no | primary, same_secondary, same_tier_rest |
106
+ | Same-tier decay: 5 mediums | 0 | 0 | 5 | 0 | 6.50 | yes | primary, same_secondary, same_tier_rest |
107
+ | Cross-tier decay: critical + 3 highs | 1 | 3 | 0 | 0 | 9.46 | no | primary, lower_secondary, cross_tier_rest |
108
+ | Cross-tier decay: critical, 2 medium, 1 low | 1 | 0 | 2 | 1 | 9.33 | no | primary, lower_secondary, cross_tier_rest |
109
+ | Full mix, primary = critical ×2 | 2 | 1 | 1 | 1 | 9.81 | no | primary, same_secondary, cross_tier_rest |
110
+ | Capped: 50 criticals | 50 | 0 | 0 | 0 | 10.00 | yes | primary, same_secondary, same_tier_rest |
111
+ | Capped: 20 mediums | 0 | 0 | 20 | 0 | 6.50 | yes | primary, same_secondary, same_tier_rest |
112
+ | Just under the cap: 20 lows | 0 | 0 | 0 | 20 | 3.50 | no | primary, same_secondary, same_tier_rest |
113
+ | Realistic mixed cluster | 3 | 5 | 10 | 2 | 9.97 | no | primary, same_secondary, same_tier_rest, cross_tier_rest |
114
+
115
+ A couple of these in full, via `.explain()`:
116
+
117
+ ```
118
+ score_identity("x", critical=1, high=3)
119
+ # Score: 9.46 (critical)
120
+ # +9.00 1 critical finding (highest-severity present)
121
+ # +0.35 1 high finding (next tier down)
122
+ # +0.11 2 further high finding(s), diminishing weight
123
+ ```
124
+
125
+ ```
126
+ score_identity("x", critical=0, high=0, medium=20, low=0)
127
+ # Score: 6.50 (medium)
128
+ # +4.00 1 medium finding (highest-severity present)
129
+ # +1.75 a second medium finding
130
+ # +0.99 18 further medium finding(s), diminishing weight
131
+ ```
132
+
133
+ Note the "just under the cap" row: with enough low-tier findings the decaying
134
+ `same_tier_rest` bonus asymptotically approaches — but by design of the decay
135
+ formula never quite reaches — the tier's ceiling.
136
+
137
+ ## Configuring the weights
138
+
139
+ The weights, per-tier ceilings, and decay rate live in
140
+ [`src/rig_ores/data/v0_weights.json`](src/rig_ores/data/v0_weights.json) and are
141
+ loaded once as `DEFAULT_SPEC`. To score against a different set of weights
142
+ (e.g. while tuning), load your own spec file and pass it explicitly — nothing
143
+ in the package needs to change:
144
+
145
+ ```python
146
+ from rig_ores import load_spec, score_identity
147
+
148
+ spec = load_spec("my_weights.json")
149
+ result = score_identity("cluster-123", critical=1, spec=spec)
150
+ ```
151
+
152
+ A weights file must have the same shape as `v0_weights.json`: `tiers` (4 names),
153
+ `weights` (5x4 matrix, rows ordered `primary`, `same_secondary`,
154
+ `lower_secondary`, `same_tier_rest`, `cross_tier_rest`), `ceilings` (4 values),
155
+ and `decay_rate`.
156
+
157
+ ## Publishing to PyPI
158
+
159
+ Publishing is automated via [`.github/workflows/publish-python.yml`](../../.github/workflows/publish-python.yml):
160
+ pushing a tag matching `rig-ores-v*` builds the package and publishes it to
161
+ PyPI using [Trusted Publishing](https://docs.pypi.org/trusted-publishers/)
162
+ (OIDC — no stored API token).
163
+
164
+ One manual, one-time step is required before the first publish: a PyPI account
165
+ holder (maintainer: liad@rig.security) must create the `rig-ores` project on
166
+ PyPI and register this repo/workflow as a trusted publisher under
167
+ *Publishing* settings, pointing at:
168
+
169
+ - Repository: `rigsecurity/ores`
170
+ - Workflow: `publish-python.yml`
171
+ - Environment: (none)
172
+
173
+ Maintainers: liad@rig.security, hila@rig.security.
@@ -0,0 +1,161 @@
1
+ # rig-ores
2
+
3
+ Python client for [ORES](https://github.com/rigsecurity/ores)'s **V0** risk score:
4
+ a severity-count model for identities/assets, ported from rig-security/ml-services'
5
+ `rice/risk_score/risk_score.py`. It runs entirely locally — no `oresd` daemon or
6
+ network call required — so it's suited to notebooks and batch analysis as well as
7
+ services.
8
+
9
+ Given an identity's finding counts by severity (critical / high / medium / low),
10
+ it returns a 0–10 score capped by the identity's most severe tier, plus the named
11
+ contributions ("factors") that produced it.
12
+
13
+ ## Install
14
+
15
+ Not yet published to PyPI (see below). Until then, install directly from the repo:
16
+
17
+ ```bash
18
+ # editable install from a local clone
19
+ pip install -e /path/to/ores/clients/python
20
+
21
+ # or straight from GitHub, no clone needed
22
+ pip install "git+https://github.com/rigsecurity/ores.git#subdirectory=clients/python"
23
+ ```
24
+
25
+ ## Usage
26
+
27
+ ### Single identity
28
+
29
+ ```python
30
+ from rig_ores import score_identity
31
+
32
+ result = score_identity("cluster-123", critical=1, high=2)
33
+
34
+ result.score # 9.42
35
+ result.capped # False
36
+ result.factors # [Factor(feature="primary", tier="critical", contribution=9.0, reasoning="..."), ...]
37
+
38
+ print(result.explain())
39
+ # Score: 9.42 (critical)
40
+ # +9.00 1 critical finding (highest-severity present)
41
+ # +0.35 1 high finding (next tier down)
42
+ # +0.07 1 further high finding(s), diminishing weight
43
+ ```
44
+
45
+ `explain()` only lists non-zero contributions, ranked highest first — see
46
+ [Examples](#examples) below for the full range of shapes it can take.
47
+
48
+ ### Batch
49
+
50
+ `score_batch` takes any iterable of mappings with `id`, `critical`, `high`,
51
+ `medium`, `low` keys (case-insensitive; missing severities default to 0) — a list
52
+ of dicts, or a pandas DataFrame via `df.to_dict("records")`:
53
+
54
+ ```python
55
+ from rig_ores import score_batch
56
+
57
+ rows = [
58
+ {"id": "cluster-123", "critical": 1, "high": 2, "medium": 0, "low": 0},
59
+ {"id": "cluster-456", "critical": 0, "high": 0, "medium": 3, "low": 1},
60
+ ]
61
+
62
+ for result in score_batch(rows):
63
+ print(result.id, result.score, result.explain())
64
+
65
+ # from a DataFrame
66
+ # score_batch(findings_df.to_dict("records"))
67
+ ```
68
+
69
+ ## Examples
70
+
71
+ 24 finding-count combinations chosen to exercise every named contribution
72
+ (`primary`, `same_secondary`, `lower_secondary`, `same_tier_rest`,
73
+ `cross_tier_rest`) and both non-capped and capped scores, computed with the
74
+ default weights:
75
+
76
+ | Scenario | critical | high | medium | low | Score | Capped | Features exercised |
77
+ |---|---:|---:|---:|---:|---:|:---:|---|
78
+ | No findings | 0 | 0 | 0 | 0 | 0.00 | no | — |
79
+ | Single critical | 1 | 0 | 0 | 0 | 9.00 | no | primary |
80
+ | Single high | 0 | 1 | 0 | 0 | 7.00 | no | primary |
81
+ | Single medium | 0 | 0 | 1 | 0 | 4.00 | no | primary |
82
+ | Single low | 0 | 0 | 0 | 1 | 2.00 | no | primary |
83
+ | Same secondary: 2 criticals | 2 | 0 | 0 | 0 | 9.70 | no | primary, same_secondary |
84
+ | Same secondary: 2 highs | 0 | 2 | 0 | 0 | 8.05 | no | primary, same_secondary |
85
+ | Same secondary: 2 mediums | 0 | 0 | 2 | 0 | 5.75 | no | primary, same_secondary |
86
+ | Same secondary: 2 lows | 0 | 0 | 0 | 2 | 2.96 | no | primary, same_secondary |
87
+ | Lower secondary: critical + high | 1 | 1 | 0 | 0 | 9.35 | no | primary, lower_secondary |
88
+ | Lower secondary: critical + medium | 1 | 0 | 1 | 0 | 9.25 | no | primary, lower_secondary |
89
+ | Lower secondary: critical + low | 1 | 0 | 0 | 1 | 9.15 | no | primary, lower_secondary |
90
+ | Lower secondary: high + medium | 0 | 1 | 1 | 0 | 7.25 | no | primary, lower_secondary |
91
+ | Lower secondary: high + low | 0 | 1 | 0 | 1 | 7.15 | no | primary, lower_secondary |
92
+ | Lower secondary: medium + low | 0 | 0 | 1 | 1 | 4.15 | no | primary, lower_secondary |
93
+ | Same-tier decay: 4 criticals | 4 | 0 | 0 | 0 | 9.99 | no | primary, same_secondary, same_tier_rest |
94
+ | Same-tier decay: 5 mediums | 0 | 0 | 5 | 0 | 6.50 | yes | primary, same_secondary, same_tier_rest |
95
+ | Cross-tier decay: critical + 3 highs | 1 | 3 | 0 | 0 | 9.46 | no | primary, lower_secondary, cross_tier_rest |
96
+ | Cross-tier decay: critical, 2 medium, 1 low | 1 | 0 | 2 | 1 | 9.33 | no | primary, lower_secondary, cross_tier_rest |
97
+ | Full mix, primary = critical ×2 | 2 | 1 | 1 | 1 | 9.81 | no | primary, same_secondary, cross_tier_rest |
98
+ | Capped: 50 criticals | 50 | 0 | 0 | 0 | 10.00 | yes | primary, same_secondary, same_tier_rest |
99
+ | Capped: 20 mediums | 0 | 0 | 20 | 0 | 6.50 | yes | primary, same_secondary, same_tier_rest |
100
+ | Just under the cap: 20 lows | 0 | 0 | 0 | 20 | 3.50 | no | primary, same_secondary, same_tier_rest |
101
+ | Realistic mixed cluster | 3 | 5 | 10 | 2 | 9.97 | no | primary, same_secondary, same_tier_rest, cross_tier_rest |
102
+
103
+ A couple of these in full, via `.explain()`:
104
+
105
+ ```
106
+ score_identity("x", critical=1, high=3)
107
+ # Score: 9.46 (critical)
108
+ # +9.00 1 critical finding (highest-severity present)
109
+ # +0.35 1 high finding (next tier down)
110
+ # +0.11 2 further high finding(s), diminishing weight
111
+ ```
112
+
113
+ ```
114
+ score_identity("x", critical=0, high=0, medium=20, low=0)
115
+ # Score: 6.50 (medium)
116
+ # +4.00 1 medium finding (highest-severity present)
117
+ # +1.75 a second medium finding
118
+ # +0.99 18 further medium finding(s), diminishing weight
119
+ ```
120
+
121
+ Note the "just under the cap" row: with enough low-tier findings the decaying
122
+ `same_tier_rest` bonus asymptotically approaches — but by design of the decay
123
+ formula never quite reaches — the tier's ceiling.
124
+
125
+ ## Configuring the weights
126
+
127
+ The weights, per-tier ceilings, and decay rate live in
128
+ [`src/rig_ores/data/v0_weights.json`](src/rig_ores/data/v0_weights.json) and are
129
+ loaded once as `DEFAULT_SPEC`. To score against a different set of weights
130
+ (e.g. while tuning), load your own spec file and pass it explicitly — nothing
131
+ in the package needs to change:
132
+
133
+ ```python
134
+ from rig_ores import load_spec, score_identity
135
+
136
+ spec = load_spec("my_weights.json")
137
+ result = score_identity("cluster-123", critical=1, spec=spec)
138
+ ```
139
+
140
+ A weights file must have the same shape as `v0_weights.json`: `tiers` (4 names),
141
+ `weights` (5x4 matrix, rows ordered `primary`, `same_secondary`,
142
+ `lower_secondary`, `same_tier_rest`, `cross_tier_rest`), `ceilings` (4 values),
143
+ and `decay_rate`.
144
+
145
+ ## Publishing to PyPI
146
+
147
+ Publishing is automated via [`.github/workflows/publish-python.yml`](../../.github/workflows/publish-python.yml):
148
+ pushing a tag matching `rig-ores-v*` builds the package and publishes it to
149
+ PyPI using [Trusted Publishing](https://docs.pypi.org/trusted-publishers/)
150
+ (OIDC — no stored API token).
151
+
152
+ One manual, one-time step is required before the first publish: a PyPI account
153
+ holder (maintainer: liad@rig.security) must create the `rig-ores` project on
154
+ PyPI and register this repo/workflow as a trusted publisher under
155
+ *Publishing* settings, pointing at:
156
+
157
+ - Repository: `rigsecurity/ores`
158
+ - Workflow: `publish-python.yml`
159
+ - Environment: (none)
160
+
161
+ Maintainers: liad@rig.security, hila@rig.security.
@@ -0,0 +1,26 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "rig-ores"
7
+ version = "0.1.0"
8
+ description = "Python client for ORES: local V0 severity-count risk scoring"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "Apache-2.0"
12
+ authors = [
13
+ { email = "liad@rig.security" },
14
+ { email = "hila@rig.security" },
15
+ ]
16
+ maintainers = [
17
+ { email = "liad@rig.security" },
18
+ { email = "hila@rig.security" },
19
+ ]
20
+ dependencies = []
21
+
22
+ [project.optional-dependencies]
23
+ test = ["pytest"]
24
+
25
+ [tool.hatch.build.targets.wheel]
26
+ packages = ["src/rig_ores"]
@@ -0,0 +1,21 @@
1
+ from rig_ores.scoring import (
2
+ DEFAULT_SPEC,
3
+ Factor,
4
+ ScoreResult,
5
+ TIERS,
6
+ WeightsSpec,
7
+ load_spec,
8
+ score_batch,
9
+ score_identity,
10
+ )
11
+
12
+ __all__ = [
13
+ "DEFAULT_SPEC",
14
+ "Factor",
15
+ "ScoreResult",
16
+ "TIERS",
17
+ "WeightsSpec",
18
+ "load_spec",
19
+ "score_batch",
20
+ "score_identity",
21
+ ]
@@ -0,0 +1,13 @@
1
+ {
2
+ "tiers": ["critical", "high", "medium", "low"],
3
+ "feature_rows": ["primary", "same_secondary", "lower_secondary", "same_tier_rest", "cross_tier_rest"],
4
+ "weights": [
5
+ [9, 7, 4, 2],
6
+ [0.7, 1.05, 1.75, 0.96],
7
+ [0, 0.35, 0.25, 0.15],
8
+ [0.39, 0.59, 0.99, 0.54],
9
+ [0.18, 0.15, 0.12, 0.08]
10
+ ],
11
+ "ceilings": [10.0, 8.5, 6.5, 3.5],
12
+ "decay_rate": 0.5
13
+ }
@@ -0,0 +1,173 @@
1
+ """V0 severity-count risk scoring.
2
+
3
+ Ports the ORES weights matrix from rig-security/ml-services'
4
+ rice/risk_score/risk_score.py: one identity's Critical/High/Medium/Low finding
5
+ counts go in, a 0-10 score capped by the identity's most severe tier comes out,
6
+ along with the named contributions that produced it.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ from collections.abc import Iterable, Mapping
13
+ from dataclasses import dataclass
14
+ from importlib import resources
15
+ from pathlib import Path
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class WeightsSpec:
20
+ tiers: tuple[str, ...]
21
+ weights: list[list[float]]
22
+ ceilings: list[float]
23
+ decay_rate: float
24
+
25
+
26
+ def load_spec(path: str | Path) -> WeightsSpec:
27
+ text = Path(path).read_text()
28
+ spec = _spec_from_text(text)
29
+ return spec
30
+
31
+
32
+ def _spec_from_text(text: str) -> WeightsSpec:
33
+ raw = json.loads(text)
34
+ spec = WeightsSpec(
35
+ tiers=tuple(raw["tiers"]),
36
+ weights=raw["weights"],
37
+ ceilings=raw["ceilings"],
38
+ decay_rate=raw["decay_rate"],
39
+ )
40
+ return spec
41
+
42
+
43
+ DEFAULT_SPEC = _spec_from_text(resources.files("rig_ores").joinpath("data/v0_weights.json").read_text())
44
+ TIERS = DEFAULT_SPEC.tiers
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class Factor:
49
+ feature: str
50
+ tier: str
51
+ contribution: float
52
+ reasoning: str
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class ScoreResult:
57
+ id: str
58
+ score: float
59
+ capped: bool
60
+ factors: list[Factor]
61
+
62
+ def explain(self) -> str:
63
+ score_display = f"{self.score:.2f}"
64
+ if not self.factors:
65
+ no_findings_explanation = f"Score: {score_display} (no findings)"
66
+ return no_findings_explanation
67
+
68
+ primary_tier = next(factor.tier for factor in self.factors if factor.feature == "primary")
69
+ ranked_factors = sorted(self.factors, key=lambda factor: factor.contribution, reverse=True)
70
+ lines = [f"Score: {score_display} ({primary_tier})"]
71
+ lines += [f" +{factor.contribution:.2f} {factor.reasoning}" for factor in ranked_factors]
72
+ explanation = "\n".join(lines)
73
+ return explanation
74
+
75
+
76
+ def score_identity(
77
+ id: str,
78
+ critical: int = 0,
79
+ high: int = 0,
80
+ medium: int = 0,
81
+ low: int = 0,
82
+ spec: WeightsSpec = DEFAULT_SPEC,
83
+ ) -> ScoreResult:
84
+ result = _score(id, [critical, high, medium, low], spec)
85
+ return result
86
+
87
+
88
+ def score_batch(rows: Iterable[Mapping[str, object]], spec: WeightsSpec = DEFAULT_SPEC) -> list[ScoreResult]:
89
+ results = [_score_row(row, spec) for row in rows]
90
+ return results
91
+
92
+
93
+ def _score_row(row: Mapping[str, object], spec: WeightsSpec) -> ScoreResult:
94
+ normalized = {str(key).lower(): value for key, value in row.items()}
95
+ result = score_identity(
96
+ id=normalized["id"],
97
+ critical=int(normalized.get("critical", 0)),
98
+ high=int(normalized.get("high", 0)),
99
+ medium=int(normalized.get("medium", 0)),
100
+ low=int(normalized.get("low", 0)),
101
+ spec=spec,
102
+ )
103
+ return result
104
+
105
+
106
+ def _score(id: str, counts: list[int], spec: WeightsSpec) -> ScoreResult:
107
+ if sum(counts) == 0:
108
+ empty_result = ScoreResult(id=id, score=0.0, capped=False, factors=[])
109
+ return empty_result
110
+
111
+ primary_col = next(index for index, count in enumerate(counts) if count > 0)
112
+ primary_count = counts[primary_col]
113
+ same_secondary = primary_count >= 2
114
+ secondary_col = next((index for index in range(primary_col + 1, len(counts)) if counts[index] > 0), None)
115
+ lower_secondary = primary_count == 1 and secondary_col is not None
116
+
117
+ rest_counts = list(counts)
118
+ rest_counts[primary_col] -= 1
119
+ if same_secondary:
120
+ rest_counts[primary_col] -= 1
121
+ if lower_secondary:
122
+ rest_counts[secondary_col] -= 1
123
+ rest_counts = [max(count, 0) for count in rest_counts]
124
+
125
+ contributions = _contributions(primary_col, same_secondary, secondary_col, lower_secondary, rest_counts, spec)
126
+ raw_score = sum(value for _, _, value, _ in contributions)
127
+ ceiling = spec.ceilings[primary_col]
128
+ capped = raw_score > ceiling
129
+ score = round(min(raw_score, ceiling), 2)
130
+ factors = [
131
+ Factor(feature=feature, tier=spec.tiers[tier], contribution=round(value, 4), reasoning=reasoning)
132
+ for feature, tier, value, reasoning in contributions
133
+ if value != 0
134
+ ]
135
+
136
+ result = ScoreResult(id=id, score=score, capped=capped, factors=factors)
137
+ return result
138
+
139
+
140
+ def _contributions(
141
+ primary_col: int,
142
+ same_secondary: bool,
143
+ secondary_col: int | None,
144
+ lower_secondary: bool,
145
+ rest_counts: list[int],
146
+ spec: WeightsSpec,
147
+ ) -> list[tuple[str, int, float, str]]:
148
+ contributions: list[tuple[str, int, float, str]] = []
149
+
150
+ primary_reasoning = f"1 {spec.tiers[primary_col]} finding (highest-severity present)"
151
+ contributions.append(("primary", primary_col, spec.weights[0][primary_col], primary_reasoning))
152
+
153
+ if same_secondary:
154
+ same_secondary_reasoning = f"a second {spec.tiers[primary_col]} finding"
155
+ contributions.append(("same_secondary", primary_col, spec.weights[1][primary_col], same_secondary_reasoning))
156
+
157
+ if lower_secondary:
158
+ lower_secondary_reasoning = f"1 {spec.tiers[secondary_col]} finding (next tier down)"
159
+ contributions.append(
160
+ ("lower_secondary", secondary_col, spec.weights[2][secondary_col], lower_secondary_reasoning)
161
+ )
162
+
163
+ start = 0
164
+ for tier, rest in enumerate(rest_counts):
165
+ if rest > 0:
166
+ decay = (spec.decay_rate**start) * (1 - spec.decay_rate**rest)
167
+ row = "same_tier_rest" if tier == primary_col else "cross_tier_rest"
168
+ weight_row = 3 if row == "same_tier_rest" else 4
169
+ reasoning = f"{rest} further {spec.tiers[tier]} finding(s), diminishing weight"
170
+ contributions.append((row, tier, decay * spec.weights[weight_row][tier], reasoning))
171
+ start += rest
172
+
173
+ return contributions
@@ -0,0 +1,55 @@
1
+ from rig_ores import WeightsSpec, score_batch, score_identity
2
+
3
+
4
+ def test_no_findings_scores_zero() -> None:
5
+ result = score_identity("id-1")
6
+ assert result.score == 0.0
7
+ assert result.factors == []
8
+
9
+
10
+ def test_single_critical_finding() -> None:
11
+ result = score_identity("id-1", critical=1)
12
+ assert result.score == 9.0
13
+ assert result.factors[0].feature == "primary"
14
+ assert result.factors[0].tier == "critical"
15
+
16
+
17
+ def test_score_caps_at_primary_tier_ceiling() -> None:
18
+ result = score_identity("id-1", critical=50)
19
+ assert result.score == 10.0
20
+ assert result.capped is True
21
+
22
+
23
+ def test_custom_spec_overrides_default_weights() -> None:
24
+ custom_spec = WeightsSpec(
25
+ tiers=("critical", "high", "medium", "low"),
26
+ weights=[[5, 5, 5, 5], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0]],
27
+ ceilings=[10.0, 10.0, 10.0, 10.0],
28
+ decay_rate=0.5,
29
+ )
30
+ result = score_identity("id-1", critical=1, spec=custom_spec)
31
+ assert result.score == 5.0
32
+
33
+
34
+ def test_explain_lists_only_nonzero_contributions_highest_first() -> None:
35
+ result = score_identity("id-1", critical=1, high=2)
36
+ lines = result.explain().splitlines()
37
+ assert lines[0] == "Score: 9.42 (critical)"
38
+ assert len(lines) == 1 + len(result.factors)
39
+ assert lines[1].startswith(" +9.00")
40
+
41
+
42
+ def test_explain_with_no_findings() -> None:
43
+ result = score_identity("id-1")
44
+ assert result.explain() == "Score: 0.00 (no findings)"
45
+
46
+
47
+ def test_batch_preserves_id_and_order() -> None:
48
+ results = score_batch(
49
+ [
50
+ {"id": "id-1", "critical": 1, "high": 0, "medium": 0, "low": 0},
51
+ {"id": "id-2", "critical": 0, "high": 0, "medium": 3, "low": 0},
52
+ ]
53
+ )
54
+ assert [r.id for r in results] == ["id-1", "id-2"]
55
+ assert results[0].score == 9.0