skill-sentry-cli 2.0.0__py3-none-any.whl
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.
- skill_sentry_cli/__init__.py +5 -0
- skill_sentry_cli/__main__.py +4 -0
- skill_sentry_cli/cli.py +918 -0
- skill_sentry_cli/core/__init__.py +1 -0
- skill_sentry_cli/core/anchors/__init__.py +5 -0
- skill_sentry_cli/core/anchors/credential_flow.py +440 -0
- skill_sentry_cli/core/anchors/declared_hosts.py +78 -0
- skill_sentry_cli/core/cache/__init__.py +35 -0
- skill_sentry_cli/core/cache/hash_utils.py +206 -0
- skill_sentry_cli/core/cache/sqlite_store.py +925 -0
- skill_sentry_cli/core/config.py +299 -0
- skill_sentry_cli/core/contracts.py +551 -0
- skill_sentry_cli/core/decisions/__init__.py +44 -0
- skill_sentry_cli/core/decisions/base.py +174 -0
- skill_sentry_cli/core/decisions/calibration.py +325 -0
- skill_sentry_cli/core/decisions/laya.py +439 -0
- skill_sentry_cli/core/decisions/mapping.py +218 -0
- skill_sentry_cli/core/decisions/mock.py +118 -0
- skill_sentry_cli/core/decisions/questions.py +118 -0
- skill_sentry_cli/core/decisions/report.py +65 -0
- skill_sentry_cli/core/decisions/selector.py +151 -0
- skill_sentry_cli/core/engine.py +1412 -0
- skill_sentry_cli/core/evasion/__init__.py +27 -0
- skill_sentry_cli/core/evasion/detector.py +210 -0
- skill_sentry_cli/core/exit_codes.py +13 -0
- skill_sentry_cli/core/models/__init__.py +20 -0
- skill_sentry_cli/core/models/_json.py +147 -0
- skill_sentry_cli/core/models/base.py +50 -0
- skill_sentry_cli/core/models/gemma_cloud.py +240 -0
- skill_sentry_cli/core/models/gemma_local.py +273 -0
- skill_sentry_cli/core/models/image_utils.py +112 -0
- skill_sentry_cli/core/models/mock.py +152 -0
- skill_sentry_cli/core/models/selector.py +123 -0
- skill_sentry_cli/core/parser/__init__.py +29 -0
- skill_sentry_cli/core/parser/skill_parser.py +520 -0
- skill_sentry_cli/core/parser/typosquat.py +167 -0
- skill_sentry_cli/core/poisoned_well/__init__.py +52 -0
- skill_sentry_cli/core/poisoned_well/attacker_profile.py +230 -0
- skill_sentry_cli/core/poisoned_well/canary_gen.py +413 -0
- skill_sentry_cli/core/poisoned_well/exfil_tracker.py +470 -0
- skill_sentry_cli/core/reports.py +114 -0
- skill_sentry_cli/core/sandbox/__init__.py +74 -0
- skill_sentry_cli/core/sandbox/assets/Dockerfile.sandbox +39 -0
- skill_sentry_cli/core/sandbox/assets/masquerade.c +484 -0
- skill_sentry_cli/core/sandbox/assets/run_skill.sh +41 -0
- skill_sentry_cli/core/sandbox/assets/sinkhole_sidecar.py +171 -0
- skill_sentry_cli/core/sandbox/canary_fs.py +108 -0
- skill_sentry_cli/core/sandbox/context_honeypot.py +97 -0
- skill_sentry_cli/core/sandbox/dns_sinkhole.py +218 -0
- skill_sentry_cli/core/sandbox/fake_http.py +194 -0
- skill_sentry_cli/core/sandbox/instructions.py +170 -0
- skill_sentry_cli/core/sandbox/masquerade.py +265 -0
- skill_sentry_cli/core/sandbox/monitor.py +278 -0
- skill_sentry_cli/core/sandbox/runner.py +776 -0
- skill_sentry_cli/core/sandbox/sidecar.py +327 -0
- skill_sentry_cli/core/semantic/__init__.py +9 -0
- skill_sentry_cli/core/semantic/analyzer.py +443 -0
- skill_sentry_cli/core/semantic/ast_anchor.py +371 -0
- skill_sentry_cli/core/semantic/ast_python.py +156 -0
- skill_sentry_cli/core/semantic/candidates.py +78 -0
- skill_sentry_cli/core/semantic/laya_analyzer.py +241 -0
- skill_sentry_cli/core/semantic/prompts.py +61 -0
- skill_sentry_cli/core/verdict/__init__.py +24 -0
- skill_sentry_cli/core/verdict/fusion.py +392 -0
- skill_sentry_cli/core/verdict/markdown_report.py +211 -0
- skill_sentry_cli/core/verdict/sarif_report.py +139 -0
- skill_sentry_cli/core/verdict/status.py +35 -0
- skill_sentry_cli/core/verdict/stix_report.py +132 -0
- skill_sentry_cli/core/version.py +38 -0
- skill_sentry_cli/core/vision/__init__.py +8 -0
- skill_sentry_cli/core/vision/image_extractor.py +91 -0
- skill_sentry_cli/core/vision/vision_scanner.py +274 -0
- skill_sentry_cli/data/__init__.py +1 -0
- skill_sentry_cli/data/known_skills_v1.json +276 -0
- skill_sentry_cli/data/sandbox_image.json +4 -0
- skill_sentry_cli/rules/credential_rules.toml +94 -0
- skill_sentry_cli/server/__init__.py +5 -0
- skill_sentry_cli/server/app.py +129 -0
- skill_sentry_cli/server/backend.py +63 -0
- skill_sentry_cli/server/demo_assets.py +62 -0
- skill_sentry_cli/server/demo_backend.py +475 -0
- skill_sentry_cli/server/demo_data.py +814 -0
- skill_sentry_cli/server/demo_mode.py +40 -0
- skill_sentry_cli/server/errors.py +48 -0
- skill_sentry_cli/server/live_backend.py +576 -0
- skill_sentry_cli/server/routes.py +191 -0
- skill_sentry_cli/skill/SKILL.md +67 -0
- skill_sentry_cli/skill/scripts/audit.sh +48 -0
- skill_sentry_cli/ui/dist/assets/index-Bf0h24IX.css +1 -0
- skill_sentry_cli/ui/dist/assets/index-CrYBKSSB.js +8 -0
- skill_sentry_cli/ui/dist/index.html +14 -0
- skill_sentry_cli-2.0.0.dist-info/METADATA +76 -0
- skill_sentry_cli-2.0.0.dist-info/RECORD +96 -0
- skill_sentry_cli-2.0.0.dist-info/WHEEL +4 -0
- skill_sentry_cli-2.0.0.dist-info/entry_points.txt +2 -0
- skill_sentry_cli-2.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Typed decision contracts for System One semantic backends.
|
|
2
|
+
|
|
3
|
+
The models in this module are intentionally provider-neutral. A backend such
|
|
4
|
+
as Laya or TypeSafe Jev receives a state and a set of typed questions; it
|
|
5
|
+
returns answers and probabilities, never free-form text.
|
|
6
|
+
|
|
7
|
+
The final verdict is still produced by deterministic fusion. These models only
|
|
8
|
+
describe advisory semantic decisions.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Literal, Protocol, runtime_checkable
|
|
15
|
+
|
|
16
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class DecisionError(RuntimeError):
|
|
20
|
+
"""Base class for decision-backend failures."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class DecisionUnavailable(DecisionError):
|
|
24
|
+
"""No decision backend is installed, reachable, or healthy."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class DecisionModel(BaseModel):
|
|
28
|
+
"""Base model for decision contracts."""
|
|
29
|
+
|
|
30
|
+
model_config = ConfigDict(extra="forbid", populate_by_name=True)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class DecisionQuestion(DecisionModel):
|
|
34
|
+
"""One typed question sent to a decision backend.
|
|
35
|
+
|
|
36
|
+
``choice`` requires a mapping of option key to description. ``score``
|
|
37
|
+
requires an ordered list of levels. ``noul`` is a yes/no question and must
|
|
38
|
+
not carry criteria.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
type: Literal["choice", "score", "noul"]
|
|
42
|
+
instructions: str = Field(min_length=1, max_length=4096)
|
|
43
|
+
criteria: dict[str, str] | list[str] | None = None
|
|
44
|
+
min_confidence: float | None = Field(default=None, ge=0.0, le=1.0)
|
|
45
|
+
|
|
46
|
+
@model_validator(mode="after")
|
|
47
|
+
def _validate_criteria(self) -> DecisionQuestion:
|
|
48
|
+
if self.type == "choice":
|
|
49
|
+
if not isinstance(self.criteria, dict) or not self.criteria:
|
|
50
|
+
raise ValueError("choice questions require a non-empty criteria mapping")
|
|
51
|
+
if len(self.criteria) > 255:
|
|
52
|
+
raise ValueError("choice questions support at most 255 options")
|
|
53
|
+
if any(not key.strip() for key in self.criteria):
|
|
54
|
+
raise ValueError("choice option keys must not be empty")
|
|
55
|
+
if any(not value.strip() for value in self.criteria.values()):
|
|
56
|
+
raise ValueError("choice option descriptions must not be empty")
|
|
57
|
+
elif self.type == "score":
|
|
58
|
+
if not isinstance(self.criteria, list) or not 2 <= len(self.criteria) <= 10:
|
|
59
|
+
raise ValueError("score questions require between 2 and 10 ordered levels")
|
|
60
|
+
if any(not level.strip() for level in self.criteria):
|
|
61
|
+
raise ValueError("score levels must not be empty")
|
|
62
|
+
elif self.criteria is not None:
|
|
63
|
+
raise ValueError("noul questions must not define criteria")
|
|
64
|
+
return self
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class DecisionAnswer(DecisionModel):
|
|
68
|
+
"""One answer returned for a :class:`DecisionQuestion`."""
|
|
69
|
+
|
|
70
|
+
choice: str | None = None
|
|
71
|
+
score: float | None = None
|
|
72
|
+
noul: float | None = Field(default=None, ge=0.0, le=1.0)
|
|
73
|
+
probabilities: dict[str, float] | list[float] | None = None
|
|
74
|
+
confidence: float | None = Field(default=None, ge=0.0, le=1.0)
|
|
75
|
+
abstention: bool = False
|
|
76
|
+
|
|
77
|
+
@model_validator(mode="after")
|
|
78
|
+
def _validate_answer(self) -> DecisionAnswer:
|
|
79
|
+
present = [self.choice is not None, self.score is not None, self.noul is not None]
|
|
80
|
+
if sum(present) > 1:
|
|
81
|
+
raise ValueError("an answer must contain at most one of choice, score, or noul")
|
|
82
|
+
if not self.abstention and sum(present) != 1:
|
|
83
|
+
raise ValueError(
|
|
84
|
+
"a non-abstaining answer must contain exactly one of choice, score, or noul"
|
|
85
|
+
)
|
|
86
|
+
if self.choice is not None and not self.choice.strip():
|
|
87
|
+
raise ValueError("choice answer must not be empty")
|
|
88
|
+
if self.probabilities is not None:
|
|
89
|
+
values = (
|
|
90
|
+
self.probabilities.values()
|
|
91
|
+
if isinstance(self.probabilities, dict)
|
|
92
|
+
else self.probabilities
|
|
93
|
+
)
|
|
94
|
+
if any(not 0.0 <= float(value) <= 1.0 for value in values):
|
|
95
|
+
raise ValueError("probabilities must be between 0 and 1")
|
|
96
|
+
return self
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class DecisionResult(DecisionModel):
|
|
100
|
+
"""Complete response from a decision backend."""
|
|
101
|
+
|
|
102
|
+
answers: dict[str, DecisionAnswer]
|
|
103
|
+
backend: str = Field(min_length=1)
|
|
104
|
+
checkpoint: str = ""
|
|
105
|
+
model_version: str = ""
|
|
106
|
+
routing: dict[str, Any] = Field(default_factory=dict)
|
|
107
|
+
usage: dict[str, Any] = Field(default_factory=dict)
|
|
108
|
+
|
|
109
|
+
@field_validator("answers")
|
|
110
|
+
@classmethod
|
|
111
|
+
def _validate_answer_ids(cls, value: dict[str, DecisionAnswer]) -> dict[str, DecisionAnswer]:
|
|
112
|
+
if not value:
|
|
113
|
+
raise ValueError("a decision result must contain at least one answer")
|
|
114
|
+
if any(not key.strip() for key in value):
|
|
115
|
+
raise ValueError("answer IDs must not be empty")
|
|
116
|
+
return value
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class EvidenceCandidate(DecisionModel):
|
|
120
|
+
"""Code-owned evidence that a decision backend may classify.
|
|
121
|
+
|
|
122
|
+
The backend never supplies or invents the file, line, or code fields; those
|
|
123
|
+
remain deterministic evidence used in findings and reports.
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
candidate_id: str = Field(min_length=1, max_length=120)
|
|
127
|
+
file: str = Field(min_length=1, max_length=1024)
|
|
128
|
+
line: int = Field(ge=1)
|
|
129
|
+
code: str = Field(min_length=1, max_length=4000)
|
|
130
|
+
anchor_type: str = Field(min_length=1, max_length=120)
|
|
131
|
+
|
|
132
|
+
@field_validator("file")
|
|
133
|
+
@classmethod
|
|
134
|
+
def _validate_relative_file(cls, value: str) -> str:
|
|
135
|
+
normalised = value.replace("\\", "/")
|
|
136
|
+
while normalised.startswith("./"):
|
|
137
|
+
normalised = normalised[2:]
|
|
138
|
+
path = Path(normalised)
|
|
139
|
+
if path.is_absolute() or ".." in path.parts:
|
|
140
|
+
raise ValueError("evidence file must be a relative path inside the skill package")
|
|
141
|
+
return normalised
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@runtime_checkable
|
|
145
|
+
class DecisionClient(Protocol):
|
|
146
|
+
"""Provider-neutral interface for typed semantic decisions."""
|
|
147
|
+
|
|
148
|
+
backend: str
|
|
149
|
+
model_version: str
|
|
150
|
+
|
|
151
|
+
def decide(
|
|
152
|
+
self,
|
|
153
|
+
state: str | dict[str, Any],
|
|
154
|
+
questions: dict[str, DecisionQuestion],
|
|
155
|
+
*,
|
|
156
|
+
timeout_s: float | None = None,
|
|
157
|
+
) -> DecisionResult:
|
|
158
|
+
"""Answer ``questions`` about ``state`` or raise :class:`DecisionError`."""
|
|
159
|
+
...
|
|
160
|
+
|
|
161
|
+
def health(self) -> bool:
|
|
162
|
+
"""Cheap liveness probe; must not raise."""
|
|
163
|
+
...
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
__all__ = [
|
|
167
|
+
"DecisionAnswer",
|
|
168
|
+
"DecisionClient",
|
|
169
|
+
"DecisionError",
|
|
170
|
+
"DecisionQuestion",
|
|
171
|
+
"DecisionResult",
|
|
172
|
+
"DecisionUnavailable",
|
|
173
|
+
"EvidenceCandidate",
|
|
174
|
+
]
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
"""Versioned calibration for typed decision thresholds.
|
|
2
|
+
|
|
3
|
+
A calibration file records the capability threshold chosen from a labelled
|
|
4
|
+
corpus, the checkpoint it was fitted against, and the error counts that
|
|
5
|
+
justified it. Thresholds are never assumed; without a file the provisional
|
|
6
|
+
``CAPABILITY_THRESHOLD`` applies and the report says ``uncalibrated``.
|
|
7
|
+
|
|
8
|
+
Temperature scaling works on the logit of each reported probability. The
|
|
9
|
+
adapter exposes probabilities rather than raw logits, and ``logit(p)`` recovers
|
|
10
|
+
the same ordering and scale, so fitting ``T`` on those values is temperature
|
|
11
|
+
scaling in the usual sense. Only the ``noul`` question type is calibrated here,
|
|
12
|
+
because every capability question is ``noul``.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import math
|
|
19
|
+
from collections.abc import Iterable, Sequence
|
|
20
|
+
from dataclasses import asdict, dataclass
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
SCHEMA = "skillsentry.decision-calibration/v1"
|
|
25
|
+
_EPS = 1e-6
|
|
26
|
+
_TEMPERATURE_GRID = [round(0.05 * 1.05**step, 6) for step in range(0, 120)]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class CalibrationError(ValueError):
|
|
30
|
+
"""A calibration file is missing required fields or is malformed."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class Calibration:
|
|
35
|
+
version: str
|
|
36
|
+
checkpoint: str
|
|
37
|
+
capability_threshold: float
|
|
38
|
+
evaluated_cases: int
|
|
39
|
+
malicious_cases: int
|
|
40
|
+
false_negatives: int
|
|
41
|
+
benign_flagged: int
|
|
42
|
+
temperature: float = 1.0
|
|
43
|
+
expected_calibration_error: float | None = None
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def label(self) -> str:
|
|
47
|
+
return self.version or "uncalibrated"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def fit_capability_threshold(
|
|
51
|
+
scored_cases: Iterable[tuple[float, bool]],
|
|
52
|
+
) -> tuple[float, int, int]:
|
|
53
|
+
"""Fit one threshold from ``(max_capability_probability, is_malicious)`` pairs.
|
|
54
|
+
|
|
55
|
+
The threshold is the lowest malicious score, so no malicious case is missed
|
|
56
|
+
on the fitted corpus. Returns ``(threshold, false_negatives, benign_flagged)``.
|
|
57
|
+
A corpus with no malicious cases cannot be fitted and raises.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
cases = list(scored_cases)
|
|
61
|
+
malicious = [score for score, is_malicious in cases if is_malicious]
|
|
62
|
+
benign = [score for score, is_malicious in cases if not is_malicious]
|
|
63
|
+
if not malicious:
|
|
64
|
+
raise CalibrationError("calibration needs at least one malicious case")
|
|
65
|
+
# Round down, never to nearest: a rounded-up threshold would miss a malicious case
|
|
66
|
+
# that the fit itself saw.
|
|
67
|
+
threshold = max(math.floor(min(malicious) * 10_000) / 10_000, 0.0001)
|
|
68
|
+
false_negatives = sum(1 for score in malicious if score < threshold)
|
|
69
|
+
benign_flagged = sum(1 for score in benign if score >= threshold)
|
|
70
|
+
return threshold, false_negatives, benign_flagged
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _clamp(probability: float) -> float:
|
|
74
|
+
return min(1.0 - _EPS, max(_EPS, float(probability)))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def calibrate_probability(probability: float, temperature: float) -> float:
|
|
78
|
+
"""Return ``sigmoid(logit(p) / T)``; ``T = 1`` leaves ``p`` unchanged."""
|
|
79
|
+
|
|
80
|
+
if temperature <= 0:
|
|
81
|
+
raise CalibrationError("temperature must be positive")
|
|
82
|
+
p = _clamp(probability)
|
|
83
|
+
logit = math.log(p / (1.0 - p))
|
|
84
|
+
return _clamp(1.0 / (1.0 + math.exp(-logit / temperature)))
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def fit_temperature(samples: Iterable[tuple[float, bool]]) -> float:
|
|
88
|
+
"""Fit ``T`` by minimising negative log-likelihood over a fixed grid.
|
|
89
|
+
|
|
90
|
+
``samples`` are ``(raw_probability, is_positive)`` pairs for one question
|
|
91
|
+
type. The grid is deterministic, so refitting the same corpus gives the same
|
|
92
|
+
temperature. With no samples the identity temperature ``1.0`` is returned.
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
pairs = list(samples)
|
|
96
|
+
if not pairs:
|
|
97
|
+
return 1.0
|
|
98
|
+
|
|
99
|
+
def negative_log_likelihood(temperature: float) -> float:
|
|
100
|
+
total = 0.0
|
|
101
|
+
for probability, positive in pairs:
|
|
102
|
+
q = calibrate_probability(probability, temperature)
|
|
103
|
+
total -= math.log(q if positive else 1.0 - q)
|
|
104
|
+
return total
|
|
105
|
+
|
|
106
|
+
return min(_TEMPERATURE_GRID, key=negative_log_likelihood)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def expected_calibration_error(
|
|
110
|
+
samples: Sequence[tuple[float, bool]],
|
|
111
|
+
*,
|
|
112
|
+
bins: int = 10,
|
|
113
|
+
) -> float:
|
|
114
|
+
"""Expected calibration error over ``bins`` equal-width probability bins."""
|
|
115
|
+
|
|
116
|
+
if not samples:
|
|
117
|
+
return 0.0
|
|
118
|
+
buckets: list[list[tuple[float, bool]]] = [[] for _ in range(bins)]
|
|
119
|
+
for probability, positive in samples:
|
|
120
|
+
index = min(bins - 1, int(_clamp(probability) * bins))
|
|
121
|
+
buckets[index].append((probability, positive))
|
|
122
|
+
total = len(samples)
|
|
123
|
+
error = 0.0
|
|
124
|
+
for bucket in buckets:
|
|
125
|
+
if not bucket:
|
|
126
|
+
continue
|
|
127
|
+
confidence = sum(p for p, _ in bucket) / len(bucket)
|
|
128
|
+
accuracy = sum(1 for _, positive in bucket if positive) / len(bucket)
|
|
129
|
+
error += abs(confidence - accuracy) * len(bucket) / total
|
|
130
|
+
return round(error, 4)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def split_held_out(
|
|
134
|
+
items: Sequence[Any],
|
|
135
|
+
*,
|
|
136
|
+
every: int = 4,
|
|
137
|
+
) -> tuple[list[Any], list[Any]]:
|
|
138
|
+
"""Deterministic ``(fit, held_out)`` split: every ``every``-th item is held out."""
|
|
139
|
+
|
|
140
|
+
if every < 2:
|
|
141
|
+
raise CalibrationError("held-out split needs every >= 2")
|
|
142
|
+
fit = [item for index, item in enumerate(items) if index % every != 0]
|
|
143
|
+
held_out = [item for index, item in enumerate(items) if index % every == 0]
|
|
144
|
+
return fit, held_out
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def load_calibration(path: str | Path | None) -> Calibration | None:
|
|
148
|
+
"""Return the calibration at ``path``, or ``None`` when no path is configured."""
|
|
149
|
+
|
|
150
|
+
if path is None or not str(path).strip():
|
|
151
|
+
return None
|
|
152
|
+
target = Path(path)
|
|
153
|
+
try:
|
|
154
|
+
raw = json.loads(target.read_text(encoding="utf-8"))
|
|
155
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
156
|
+
raise CalibrationError(f"cannot read calibration {target}: {exc}") from exc
|
|
157
|
+
if not isinstance(raw, dict) or raw.get("schema") != SCHEMA:
|
|
158
|
+
raise CalibrationError(f"{target} is not a {SCHEMA} file")
|
|
159
|
+
try:
|
|
160
|
+
threshold = float(raw["capability_threshold"])
|
|
161
|
+
except (KeyError, TypeError, ValueError) as exc:
|
|
162
|
+
raise CalibrationError("calibration is missing a numeric capability_threshold") from exc
|
|
163
|
+
if not 0.0 < threshold <= 1.0:
|
|
164
|
+
raise CalibrationError("capability_threshold must be in (0, 1]")
|
|
165
|
+
try:
|
|
166
|
+
temperature = float(raw.get("temperature", 1.0))
|
|
167
|
+
except (TypeError, ValueError) as exc:
|
|
168
|
+
raise CalibrationError("temperature must be a number") from exc
|
|
169
|
+
if temperature <= 0:
|
|
170
|
+
raise CalibrationError("temperature must be positive")
|
|
171
|
+
ece = raw.get("expected_calibration_error")
|
|
172
|
+
return Calibration(
|
|
173
|
+
version=str(raw.get("version") or ""),
|
|
174
|
+
checkpoint=str(raw.get("checkpoint") or ""),
|
|
175
|
+
capability_threshold=threshold,
|
|
176
|
+
evaluated_cases=int(raw.get("evaluated_cases", 0)),
|
|
177
|
+
malicious_cases=int(raw.get("malicious_cases", 0)),
|
|
178
|
+
false_negatives=int(raw.get("false_negatives", 0)),
|
|
179
|
+
benign_flagged=int(raw.get("benign_flagged", 0)),
|
|
180
|
+
temperature=temperature,
|
|
181
|
+
expected_calibration_error=float(ece) if ece is not None else None,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def write_calibration(path: str | Path, calibration: Calibration) -> None:
|
|
186
|
+
"""Write ``calibration`` as a schema-tagged JSON file."""
|
|
187
|
+
|
|
188
|
+
payload: dict[str, Any] = {"schema": SCHEMA, **asdict(calibration)}
|
|
189
|
+
target = Path(path)
|
|
190
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
191
|
+
target.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
__all__ = [
|
|
195
|
+
"SCHEMA",
|
|
196
|
+
"Calibration",
|
|
197
|
+
"CalibrationError",
|
|
198
|
+
"CorpusRecord",
|
|
199
|
+
"calibrate_probability",
|
|
200
|
+
"expected_calibration_error",
|
|
201
|
+
"fit_calibration",
|
|
202
|
+
"fit_capability_threshold",
|
|
203
|
+
"fit_temperature",
|
|
204
|
+
"load_calibration",
|
|
205
|
+
"split_held_out",
|
|
206
|
+
"write_calibration",
|
|
207
|
+
]
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
@dataclass(frozen=True)
|
|
211
|
+
class CorpusRecord:
|
|
212
|
+
"""One scored corpus case.
|
|
213
|
+
|
|
214
|
+
``capabilities`` is the set of capability truths, or ``None`` when the case
|
|
215
|
+
has none recorded (shipped fixtures). ``raw`` maps capability question IDs to
|
|
216
|
+
the backend's raw ``noul`` probabilities.
|
|
217
|
+
"""
|
|
218
|
+
|
|
219
|
+
case_id: str
|
|
220
|
+
label: str
|
|
221
|
+
capabilities: frozenset[str] | None
|
|
222
|
+
raw: dict[str, float]
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _case_score(record: CorpusRecord, temperature: float) -> float:
|
|
226
|
+
return max(
|
|
227
|
+
(calibrate_probability(probability, temperature) for probability in record.raw.values()),
|
|
228
|
+
default=0.0,
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _capability_pairs(
|
|
233
|
+
records: Sequence[CorpusRecord],
|
|
234
|
+
temperature: float | None = None,
|
|
235
|
+
) -> list[tuple[float, bool]]:
|
|
236
|
+
pairs: list[tuple[float, bool]] = []
|
|
237
|
+
for record in records:
|
|
238
|
+
if record.capabilities is None:
|
|
239
|
+
continue
|
|
240
|
+
for question_id, probability in record.raw.items():
|
|
241
|
+
value = (
|
|
242
|
+
probability
|
|
243
|
+
if temperature is None
|
|
244
|
+
else calibrate_probability(probability, temperature)
|
|
245
|
+
)
|
|
246
|
+
pairs.append((value, question_id in record.capabilities))
|
|
247
|
+
return pairs
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _evaluate(
|
|
251
|
+
records: Sequence[CorpusRecord],
|
|
252
|
+
threshold: float,
|
|
253
|
+
temperature: float,
|
|
254
|
+
) -> dict[str, Any]:
|
|
255
|
+
malicious = [r for r in records if r.label == "malicious"]
|
|
256
|
+
benign = [r for r in records if r.label == "benign"]
|
|
257
|
+
scores = {r.case_id: _case_score(r, temperature) for r in records}
|
|
258
|
+
return {
|
|
259
|
+
"cases": len(records),
|
|
260
|
+
"malicious_cases": len(malicious),
|
|
261
|
+
"false_negatives": sum(1 for r in malicious if scores[r.case_id] < threshold),
|
|
262
|
+
"benign_flagged": sum(1 for r in benign if scores[r.case_id] >= threshold),
|
|
263
|
+
"expected_calibration_error": expected_calibration_error(
|
|
264
|
+
_capability_pairs(records, temperature)
|
|
265
|
+
),
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def fit_calibration(
|
|
270
|
+
records: Sequence[CorpusRecord],
|
|
271
|
+
*,
|
|
272
|
+
version: str,
|
|
273
|
+
checkpoint: str,
|
|
274
|
+
every: int = 4,
|
|
275
|
+
) -> tuple[Calibration, dict[str, Any]]:
|
|
276
|
+
"""Fit temperature and threshold, then report on a held-out split.
|
|
277
|
+
|
|
278
|
+
``review`` cases are excluded from fitting and listed in the report. The
|
|
279
|
+
procedure fits on the non-held-out cases, evaluates on the held-out cases,
|
|
280
|
+
and then refits on every fitting case for the file that gets written. The
|
|
281
|
+
report carries both the held-out result and the final fit so the difference
|
|
282
|
+
is visible.
|
|
283
|
+
"""
|
|
284
|
+
|
|
285
|
+
fitting = [r for r in records if r.label != "review"]
|
|
286
|
+
review = [r.case_id for r in records if r.label == "review"]
|
|
287
|
+
if not any(r.label == "malicious" for r in fitting):
|
|
288
|
+
raise CalibrationError("calibration needs at least one malicious case")
|
|
289
|
+
|
|
290
|
+
fit_set, held_out = split_held_out(fitting, every=every)
|
|
291
|
+
temperature_fit = fit_temperature(_capability_pairs(fit_set))
|
|
292
|
+
threshold_fit, _, _ = fit_capability_threshold(
|
|
293
|
+
[(_case_score(r, temperature_fit), r.label == "malicious") for r in fit_set]
|
|
294
|
+
)
|
|
295
|
+
held_out_metrics = _evaluate(held_out, threshold_fit, temperature_fit) if held_out else None
|
|
296
|
+
|
|
297
|
+
temperature = fit_temperature(_capability_pairs(fitting))
|
|
298
|
+
threshold, false_negatives, benign_flagged = fit_capability_threshold(
|
|
299
|
+
[(_case_score(r, temperature), r.label == "malicious") for r in fitting]
|
|
300
|
+
)
|
|
301
|
+
final = _evaluate(fitting, threshold, temperature)
|
|
302
|
+
calibration = Calibration(
|
|
303
|
+
version=version,
|
|
304
|
+
checkpoint=checkpoint,
|
|
305
|
+
capability_threshold=threshold,
|
|
306
|
+
evaluated_cases=len(fitting),
|
|
307
|
+
malicious_cases=final["malicious_cases"],
|
|
308
|
+
false_negatives=false_negatives,
|
|
309
|
+
benign_flagged=benign_flagged,
|
|
310
|
+
temperature=temperature,
|
|
311
|
+
expected_calibration_error=final["expected_calibration_error"],
|
|
312
|
+
)
|
|
313
|
+
report: dict[str, Any] = {
|
|
314
|
+
"schema": "skillsentry.decision-calibration-report/v1",
|
|
315
|
+
"version": version,
|
|
316
|
+
"checkpoint": checkpoint,
|
|
317
|
+
"review_cases_excluded": review,
|
|
318
|
+
"fit_cases": len(fit_set),
|
|
319
|
+
"held_out_cases": len(held_out),
|
|
320
|
+
"temperature_on_fit_split": temperature_fit,
|
|
321
|
+
"threshold_on_fit_split": threshold_fit,
|
|
322
|
+
"held_out": held_out_metrics,
|
|
323
|
+
"final": {**final, "temperature": temperature, "threshold": threshold},
|
|
324
|
+
}
|
|
325
|
+
return calibration, report
|