sourcebound 1.2.1__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.
- clean_docs/__init__.py +26 -0
- clean_docs/__main__.py +3 -0
- clean_docs/accessibility.py +182 -0
- clean_docs/adapters/__init__.py +1 -0
- clean_docs/adapters/event_capture.py +56 -0
- clean_docs/adapters/mdx_dependencies.json +714 -0
- clean_docs/adapters/mdx_parser.mjs +29992 -0
- clean_docs/applicability.py +330 -0
- clean_docs/audit.py +1120 -0
- clean_docs/bootstrap.py +507 -0
- clean_docs/capabilities.py +200 -0
- clean_docs/changed.py +452 -0
- clean_docs/claims.py +840 -0
- clean_docs/cli.py +1612 -0
- clean_docs/context.py +307 -0
- clean_docs/corpus.py +377 -0
- clean_docs/demo.py +369 -0
- clean_docs/doctor.py +184 -0
- clean_docs/emit/__init__.py +4 -0
- clean_docs/emit/llms_txt.py +102 -0
- clean_docs/emit/stepwise.py +168 -0
- clean_docs/engine.py +324 -0
- clean_docs/errors.py +20 -0
- clean_docs/evaluation.py +867 -0
- clean_docs/execution.py +138 -0
- clean_docs/explain.py +123 -0
- clean_docs/extractors/__init__.py +19 -0
- clean_docs/extractors/command.py +51 -0
- clean_docs/extractors/inventory.py +176 -0
- clean_docs/extractors/json_pointer.py +84 -0
- clean_docs/extractors/python_literal.py +104 -0
- clean_docs/extractors/static.py +111 -0
- clean_docs/feedback.py +1390 -0
- clean_docs/impact.py +1624 -0
- clean_docs/improvements.py +1178 -0
- clean_docs/inventory.py +474 -0
- clean_docs/isolation.py +157 -0
- clean_docs/manifest.py +898 -0
- clean_docs/mdx.py +272 -0
- clean_docs/migration.py +121 -0
- clean_docs/models.py +194 -0
- clean_docs/outcomes.py +296 -0
- clean_docs/performance.py +123 -0
- clean_docs/phrasing.py +448 -0
- clean_docs/plugins.py +249 -0
- clean_docs/policy.py +536 -0
- clean_docs/projections.py +255 -0
- clean_docs/regions.py +75 -0
- clean_docs/release.py +232 -0
- clean_docs/renderers.py +57 -0
- clean_docs/residue.py +311 -0
- clean_docs/review_contracts.py +862 -0
- clean_docs/review_ledger.py +297 -0
- clean_docs/review_limits.py +9 -0
- clean_docs/sensitivity.py +602 -0
- clean_docs/snapshot.py +212 -0
- clean_docs/standard.py +281 -0
- clean_docs/standards/default.json +309 -0
- clean_docs/standards/exemplars.md +87 -0
- clean_docs/standards/v0-migrations.json +26 -0
- clean_docs/symbols.py +47 -0
- clean_docs/templates.py +96 -0
- clean_docs/verdict.py +1397 -0
- clean_docs/visuals.py +346 -0
- clean_docs/write_gate.py +170 -0
- sourcebound-1.2.1.dist-info/LICENSE +21 -0
- sourcebound-1.2.1.dist-info/METADATA +109 -0
- sourcebound-1.2.1.dist-info/RECORD +71 -0
- sourcebound-1.2.1.dist-info/WHEEL +5 -0
- sourcebound-1.2.1.dist-info/entry_points.txt +2 -0
- sourcebound-1.2.1.dist-info/top_level.txt +1 -0
clean_docs/feedback.py
ADDED
|
@@ -0,0 +1,1390 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import shutil
|
|
8
|
+
import time
|
|
9
|
+
import uuid
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Mapping
|
|
14
|
+
|
|
15
|
+
from clean_docs import __version__
|
|
16
|
+
from clean_docs.adapters.event_capture import deliver_event
|
|
17
|
+
from clean_docs.errors import ConfigurationError
|
|
18
|
+
from clean_docs.regions import atomic_write
|
|
19
|
+
from clean_docs.verdict import validate_verdict_payload
|
|
20
|
+
|
|
21
|
+
CONFIG_PATH = Path(".sourcebound/feedback.json")
|
|
22
|
+
STATE_DIR = Path(".sourcebound/feedback")
|
|
23
|
+
OUTBOX_DIR = STATE_DIR / "outbox"
|
|
24
|
+
DEAD_LETTER_DIR = STATE_DIR / "dead-letter"
|
|
25
|
+
ATTEMPTS_DIR = STATE_DIR / "attempts"
|
|
26
|
+
SIGNALS_DIR = STATE_DIR / "signals"
|
|
27
|
+
CASES_DIR = STATE_DIR / "cases"
|
|
28
|
+
RECEIPTS_DIR = STATE_DIR / "receipts"
|
|
29
|
+
|
|
30
|
+
CONFIG_SCHEMA = "sourcebound.feedback-config.v1"
|
|
31
|
+
ENVELOPE_SCHEMA = "sourcebound.feedback.v1"
|
|
32
|
+
SIGNAL_SCHEMA = "sourcebound.behavior-signal.v1"
|
|
33
|
+
CASE_SCHEMA = "sourcebound.improvement-case.v1"
|
|
34
|
+
|
|
35
|
+
DEFAULT_RETENTION_DAYS = 30
|
|
36
|
+
DEFAULT_MAX_RECORDS = 1_000
|
|
37
|
+
DEFAULT_MAX_BYTES = 5 * 1024 * 1024
|
|
38
|
+
MAX_DELIVERY_ATTEMPTS = 3
|
|
39
|
+
|
|
40
|
+
_CONFIG_KEYS = {
|
|
41
|
+
"schema",
|
|
42
|
+
"enabled",
|
|
43
|
+
"installation_id",
|
|
44
|
+
"sink",
|
|
45
|
+
"retention_days",
|
|
46
|
+
"max_records",
|
|
47
|
+
"max_bytes",
|
|
48
|
+
}
|
|
49
|
+
_ENVELOPE_KEYS = {
|
|
50
|
+
"schema",
|
|
51
|
+
"event_id",
|
|
52
|
+
"run_id",
|
|
53
|
+
"outcome_id",
|
|
54
|
+
"occurred_at",
|
|
55
|
+
"product_version",
|
|
56
|
+
"installation_id",
|
|
57
|
+
"command",
|
|
58
|
+
"exit_code",
|
|
59
|
+
"result_class",
|
|
60
|
+
"execution_policy",
|
|
61
|
+
"adapter",
|
|
62
|
+
"repository_size_class",
|
|
63
|
+
"outcome",
|
|
64
|
+
}
|
|
65
|
+
_SIGNAL_KEYS = {
|
|
66
|
+
"schema",
|
|
67
|
+
"signal_id",
|
|
68
|
+
"metric",
|
|
69
|
+
"window",
|
|
70
|
+
"counts",
|
|
71
|
+
"segment",
|
|
72
|
+
"data_quality",
|
|
73
|
+
"privacy_class",
|
|
74
|
+
"source_receipt",
|
|
75
|
+
"contradictory_evidence",
|
|
76
|
+
"evidence_class",
|
|
77
|
+
}
|
|
78
|
+
_CASE_STATES = (
|
|
79
|
+
"observed",
|
|
80
|
+
"reproduced",
|
|
81
|
+
"root-cause-classified",
|
|
82
|
+
"evaluation-proposed",
|
|
83
|
+
"regression-added",
|
|
84
|
+
"shadow-measured",
|
|
85
|
+
"candidate-change",
|
|
86
|
+
"ordinary-verified-pr",
|
|
87
|
+
)
|
|
88
|
+
_METRIC_KEYS = {"name", "version", "direction"}
|
|
89
|
+
_WINDOW_KEYS = {"start", "end"}
|
|
90
|
+
_COUNT_KEYS = {"numerator", "denominator"}
|
|
91
|
+
_SEGMENT_KEYS = {
|
|
92
|
+
"scope",
|
|
93
|
+
"installations",
|
|
94
|
+
"contributing_installations",
|
|
95
|
+
"product_versions",
|
|
96
|
+
"adapters",
|
|
97
|
+
"execution_policies",
|
|
98
|
+
"repository_size_classes",
|
|
99
|
+
}
|
|
100
|
+
_SOURCE_RECEIPT_KEYS = {"schema", "sha256"}
|
|
101
|
+
_TRANSITION_RECEIPT_SCHEMAS = {
|
|
102
|
+
"reproduced": "sourcebound.reproduction.v1",
|
|
103
|
+
"root-cause-classified": "sourcebound.root-cause.v1",
|
|
104
|
+
"evaluation-proposed": "sourcebound.evaluation-proposal.v1",
|
|
105
|
+
"regression-added": "sourcebound.regression-receipt.v1",
|
|
106
|
+
"shadow-measured": "sourcebound.shadow-evaluation.v1",
|
|
107
|
+
"candidate-change": "sourcebound.candidate-change.v1",
|
|
108
|
+
"ordinary-verified-pr": "sourcebound.pr-verdict.v1",
|
|
109
|
+
}
|
|
110
|
+
_SHADOW_KEYS = {
|
|
111
|
+
"schema",
|
|
112
|
+
"prior_receipt_sha256",
|
|
113
|
+
"cohort_version",
|
|
114
|
+
"direction",
|
|
115
|
+
"baseline",
|
|
116
|
+
"candidate",
|
|
117
|
+
"protected_segments",
|
|
118
|
+
"baseline_metric_digest",
|
|
119
|
+
"candidate_metric_digest",
|
|
120
|
+
"baseline_scorer_digest",
|
|
121
|
+
"candidate_scorer_digest",
|
|
122
|
+
"baseline_threshold_digest",
|
|
123
|
+
"candidate_threshold_digest",
|
|
124
|
+
}
|
|
125
|
+
_PROTECTED_SEGMENT_KEYS = {"name", "baseline", "candidate"}
|
|
126
|
+
_REPRODUCTION_KEYS = {
|
|
127
|
+
"schema",
|
|
128
|
+
"signal_id",
|
|
129
|
+
"fixture_sha256",
|
|
130
|
+
"baseline_outcome_id",
|
|
131
|
+
"reproduced",
|
|
132
|
+
"failure_class",
|
|
133
|
+
}
|
|
134
|
+
_ROOT_CAUSE_KEYS = {
|
|
135
|
+
"schema",
|
|
136
|
+
"prior_receipt_sha256",
|
|
137
|
+
"classification",
|
|
138
|
+
"evidence_sha256",
|
|
139
|
+
}
|
|
140
|
+
_EVALUATION_PROPOSAL_KEYS = {
|
|
141
|
+
"schema",
|
|
142
|
+
"prior_receipt_sha256",
|
|
143
|
+
"metric_sha256",
|
|
144
|
+
"scorer_sha256",
|
|
145
|
+
"threshold_sha256",
|
|
146
|
+
"protected_segments",
|
|
147
|
+
}
|
|
148
|
+
_REGRESSION_KEYS = {
|
|
149
|
+
"schema",
|
|
150
|
+
"prior_receipt_sha256",
|
|
151
|
+
"fixture_sha256",
|
|
152
|
+
"demonstrated_red",
|
|
153
|
+
}
|
|
154
|
+
_CANDIDATE_CHANGE_KEYS = {
|
|
155
|
+
"schema",
|
|
156
|
+
"prior_receipt_sha256",
|
|
157
|
+
"change_sha256",
|
|
158
|
+
"regression_suite_sha256",
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _canonical_json(payload: Mapping[str, Any]) -> str:
|
|
163
|
+
return json.dumps(
|
|
164
|
+
payload,
|
|
165
|
+
ensure_ascii=False,
|
|
166
|
+
separators=(",", ":"),
|
|
167
|
+
sort_keys=True,
|
|
168
|
+
) + "\n"
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _sha256(value: bytes) -> str:
|
|
172
|
+
return hashlib.sha256(value).hexdigest()
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _utc_now() -> str:
|
|
176
|
+
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _parse_utc(value: object, where: str) -> datetime:
|
|
180
|
+
text = _require_string(value, where)
|
|
181
|
+
if not text.endswith("Z"):
|
|
182
|
+
raise ConfigurationError(f"{where} must be an ISO 8601 UTC timestamp")
|
|
183
|
+
try:
|
|
184
|
+
parsed = datetime.fromisoformat(text.removesuffix("Z") + "+00:00")
|
|
185
|
+
except ValueError as exc:
|
|
186
|
+
raise ConfigurationError(f"{where} must be an ISO 8601 UTC timestamp") from exc
|
|
187
|
+
return parsed
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _require_mapping(value: object, where: str) -> Mapping[str, Any]:
|
|
191
|
+
if not isinstance(value, dict):
|
|
192
|
+
raise ConfigurationError(f"{where} must be an object")
|
|
193
|
+
return value
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _require_exact_keys(
|
|
197
|
+
value: Mapping[str, Any],
|
|
198
|
+
allowed: set[str],
|
|
199
|
+
where: str,
|
|
200
|
+
) -> None:
|
|
201
|
+
unknown = sorted(set(value) - allowed)
|
|
202
|
+
if unknown:
|
|
203
|
+
raise ConfigurationError(f"{where} has unknown field(s): {', '.join(unknown)}")
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _require_string(value: object, where: str) -> str:
|
|
207
|
+
if not isinstance(value, str) or not value:
|
|
208
|
+
raise ConfigurationError(f"{where} must be a non-empty string")
|
|
209
|
+
return value
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _require_digest(value: object, where: str) -> str:
|
|
213
|
+
digest = _require_string(value, where)
|
|
214
|
+
if len(digest) != 64 or any(char not in "0123456789abcdef" for char in digest):
|
|
215
|
+
raise ConfigurationError(f"{where} must be a lowercase SHA-256 digest")
|
|
216
|
+
return digest
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _require_token(
|
|
220
|
+
value: object,
|
|
221
|
+
where: str,
|
|
222
|
+
*,
|
|
223
|
+
pattern: str = r"[a-z][a-z0-9-]{0,63}",
|
|
224
|
+
) -> str:
|
|
225
|
+
token = _require_string(value, where)
|
|
226
|
+
if re.fullmatch(pattern, token) is None:
|
|
227
|
+
raise ConfigurationError(f"{where} has an invalid bounded value")
|
|
228
|
+
return token
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _relative_path(value: str, where: str) -> str:
|
|
232
|
+
path = Path(value)
|
|
233
|
+
if path.is_absolute() or ".." in path.parts:
|
|
234
|
+
raise ConfigurationError(f"{where} must stay inside the repository")
|
|
235
|
+
return path.as_posix()
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _confined_path(root: Path, relative: Path, where: str) -> Path:
|
|
239
|
+
candidate = root / relative
|
|
240
|
+
root_resolved = root.resolve()
|
|
241
|
+
try:
|
|
242
|
+
resolved_parent = candidate.parent.resolve()
|
|
243
|
+
except OSError as exc:
|
|
244
|
+
raise ConfigurationError(f"cannot resolve {where}") from exc
|
|
245
|
+
if not resolved_parent.is_relative_to(root_resolved) or candidate.is_symlink():
|
|
246
|
+
raise ConfigurationError(f"{where} must stay inside the repository")
|
|
247
|
+
return candidate
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _require_string_list(value: object, where: str) -> list[str]:
|
|
251
|
+
if not isinstance(value, list) or not value:
|
|
252
|
+
raise ConfigurationError(f"{where} must be a non-empty array")
|
|
253
|
+
return [
|
|
254
|
+
_require_string(item, f"{where}[{index}]")
|
|
255
|
+
for index, item in enumerate(value)
|
|
256
|
+
]
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _require_count_pair(value: object, where: str) -> Mapping[str, int]:
|
|
260
|
+
pair = _require_mapping(value, where)
|
|
261
|
+
_require_exact_keys(pair, _COUNT_KEYS, where)
|
|
262
|
+
numerator = pair.get("numerator")
|
|
263
|
+
denominator = pair.get("denominator")
|
|
264
|
+
if not isinstance(numerator, int) or numerator < 0:
|
|
265
|
+
raise ConfigurationError(f"{where}.numerator must be a non-negative integer")
|
|
266
|
+
if not isinstance(denominator, int) or denominator <= 0:
|
|
267
|
+
raise ConfigurationError(f"{where}.denominator must be a positive integer")
|
|
268
|
+
if numerator > denominator:
|
|
269
|
+
raise ConfigurationError(f"{where}.numerator cannot exceed denominator")
|
|
270
|
+
return {"numerator": numerator, "denominator": denominator}
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _rate(pair: Mapping[str, int]) -> float:
|
|
274
|
+
return pair["numerator"] / pair["denominator"]
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
@dataclass(frozen=True)
|
|
278
|
+
class FeedbackConfig:
|
|
279
|
+
enabled: bool
|
|
280
|
+
installation_id: str
|
|
281
|
+
sink: Mapping[str, str]
|
|
282
|
+
retention_days: int
|
|
283
|
+
max_records: int
|
|
284
|
+
max_bytes: int
|
|
285
|
+
|
|
286
|
+
def as_dict(self) -> dict[str, object]:
|
|
287
|
+
return {
|
|
288
|
+
"schema": CONFIG_SCHEMA,
|
|
289
|
+
"enabled": self.enabled,
|
|
290
|
+
"installation_id": self.installation_id,
|
|
291
|
+
"sink": dict(self.sink),
|
|
292
|
+
"retention_days": self.retention_days,
|
|
293
|
+
"max_records": self.max_records,
|
|
294
|
+
"max_bytes": self.max_bytes,
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def load_feedback_config(root: Path) -> FeedbackConfig | None:
|
|
299
|
+
path = _confined_path(root, CONFIG_PATH, "feedback configuration")
|
|
300
|
+
if not path.is_file():
|
|
301
|
+
return None
|
|
302
|
+
try:
|
|
303
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
304
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
305
|
+
raise ConfigurationError(f"cannot read feedback configuration {path}") from exc
|
|
306
|
+
data = _require_mapping(raw, "feedback configuration")
|
|
307
|
+
_require_exact_keys(data, _CONFIG_KEYS, "feedback configuration")
|
|
308
|
+
if data.get("schema") != CONFIG_SCHEMA:
|
|
309
|
+
raise ConfigurationError(f"feedback configuration schema must be {CONFIG_SCHEMA}")
|
|
310
|
+
if not isinstance(data.get("enabled"), bool):
|
|
311
|
+
raise ConfigurationError("feedback configuration enabled must be a boolean")
|
|
312
|
+
installation_id = _require_string(
|
|
313
|
+
data.get("installation_id"),
|
|
314
|
+
"feedback configuration installation_id",
|
|
315
|
+
)
|
|
316
|
+
try:
|
|
317
|
+
uuid.UUID(installation_id)
|
|
318
|
+
except ValueError as exc:
|
|
319
|
+
raise ConfigurationError(
|
|
320
|
+
"feedback configuration installation_id must be a UUID"
|
|
321
|
+
) from exc
|
|
322
|
+
sink_raw = _require_mapping(data.get("sink"), "feedback configuration sink")
|
|
323
|
+
kind = _require_string(sink_raw.get("kind"), "feedback configuration sink.kind")
|
|
324
|
+
if kind not in {"local", "connected"}:
|
|
325
|
+
raise ConfigurationError("feedback sink kind must be local or connected")
|
|
326
|
+
sink: dict[str, str] = {"kind": kind}
|
|
327
|
+
if kind == "local":
|
|
328
|
+
_require_exact_keys(
|
|
329
|
+
sink_raw,
|
|
330
|
+
{"kind", "target"},
|
|
331
|
+
"feedback configuration sink",
|
|
332
|
+
)
|
|
333
|
+
sink["target"] = _relative_path(
|
|
334
|
+
_require_string(
|
|
335
|
+
sink_raw.get("target"),
|
|
336
|
+
"feedback configuration sink.target",
|
|
337
|
+
),
|
|
338
|
+
"feedback configuration sink.target",
|
|
339
|
+
)
|
|
340
|
+
else:
|
|
341
|
+
_require_exact_keys(
|
|
342
|
+
sink_raw,
|
|
343
|
+
{"kind", "endpoint", "token_env"},
|
|
344
|
+
"feedback configuration sink",
|
|
345
|
+
)
|
|
346
|
+
endpoint = _require_string(
|
|
347
|
+
sink_raw.get("endpoint"),
|
|
348
|
+
"feedback configuration sink.endpoint",
|
|
349
|
+
)
|
|
350
|
+
if not endpoint.startswith("https://"):
|
|
351
|
+
raise ConfigurationError("connected feedback endpoint must use HTTPS")
|
|
352
|
+
sink["endpoint"] = endpoint
|
|
353
|
+
sink["token_env"] = _require_string(
|
|
354
|
+
sink_raw.get("token_env"),
|
|
355
|
+
"feedback configuration sink.token_env",
|
|
356
|
+
)
|
|
357
|
+
if re.fullmatch(r"[A-Z_][A-Z0-9_]{0,127}", sink["token_env"]) is None:
|
|
358
|
+
raise ConfigurationError(
|
|
359
|
+
"feedback configuration sink.token_env is invalid"
|
|
360
|
+
)
|
|
361
|
+
retention_days = data.get("retention_days")
|
|
362
|
+
max_records = data.get("max_records")
|
|
363
|
+
max_bytes = data.get("max_bytes")
|
|
364
|
+
if not isinstance(retention_days, int) or not 1 <= retention_days <= 365:
|
|
365
|
+
raise ConfigurationError("feedback retention_days must be between 1 and 365")
|
|
366
|
+
if not isinstance(max_records, int) or not 1 <= max_records <= 100_000:
|
|
367
|
+
raise ConfigurationError("feedback max_records must be between 1 and 100000")
|
|
368
|
+
if not isinstance(max_bytes, int) or not 1_024 <= max_bytes <= 100 * 1024 * 1024:
|
|
369
|
+
raise ConfigurationError(
|
|
370
|
+
"feedback max_bytes must be between 1024 and 104857600"
|
|
371
|
+
)
|
|
372
|
+
return FeedbackConfig(
|
|
373
|
+
enabled=bool(data["enabled"]),
|
|
374
|
+
installation_id=installation_id,
|
|
375
|
+
sink=sink,
|
|
376
|
+
retention_days=retention_days,
|
|
377
|
+
max_records=max_records,
|
|
378
|
+
max_bytes=max_bytes,
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def enable_feedback(
|
|
383
|
+
root: Path,
|
|
384
|
+
*,
|
|
385
|
+
sink: str,
|
|
386
|
+
target: str | None = None,
|
|
387
|
+
endpoint: str | None = None,
|
|
388
|
+
token_env: str | None = None,
|
|
389
|
+
) -> FeedbackConfig:
|
|
390
|
+
previous = load_feedback_config(root)
|
|
391
|
+
installation_id = (
|
|
392
|
+
previous.installation_id if previous is not None else str(uuid.uuid4())
|
|
393
|
+
)
|
|
394
|
+
if sink == "local":
|
|
395
|
+
resolved_target = _relative_path(
|
|
396
|
+
target or ".sourcebound/feedback/delivered",
|
|
397
|
+
"feedback sink target",
|
|
398
|
+
)
|
|
399
|
+
sink_config = {
|
|
400
|
+
"kind": "local",
|
|
401
|
+
"target": resolved_target,
|
|
402
|
+
}
|
|
403
|
+
elif sink == "connected":
|
|
404
|
+
if endpoint is None or token_env is None:
|
|
405
|
+
raise ConfigurationError(
|
|
406
|
+
"connected feedback requires --endpoint and --token-env"
|
|
407
|
+
)
|
|
408
|
+
if not endpoint.startswith("https://"):
|
|
409
|
+
raise ConfigurationError("connected feedback endpoint must use HTTPS")
|
|
410
|
+
sink_config = {
|
|
411
|
+
"kind": "connected",
|
|
412
|
+
"endpoint": endpoint,
|
|
413
|
+
"token_env": token_env,
|
|
414
|
+
}
|
|
415
|
+
else:
|
|
416
|
+
raise ConfigurationError("feedback sink must be local or connected")
|
|
417
|
+
config = FeedbackConfig(
|
|
418
|
+
enabled=True,
|
|
419
|
+
installation_id=installation_id,
|
|
420
|
+
sink=sink_config,
|
|
421
|
+
retention_days=DEFAULT_RETENTION_DAYS,
|
|
422
|
+
max_records=DEFAULT_MAX_RECORDS,
|
|
423
|
+
max_bytes=DEFAULT_MAX_BYTES,
|
|
424
|
+
)
|
|
425
|
+
# Round-trip validation keeps the writer and reader on the same closed schema.
|
|
426
|
+
atomic_write(
|
|
427
|
+
_confined_path(root, CONFIG_PATH, "feedback configuration"),
|
|
428
|
+
json.dumps(config.as_dict(), indent=2) + "\n",
|
|
429
|
+
)
|
|
430
|
+
return load_feedback_config(root) or config
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def disable_feedback(root: Path) -> FeedbackConfig:
|
|
434
|
+
config = load_feedback_config(root)
|
|
435
|
+
if config is None:
|
|
436
|
+
raise ConfigurationError("feedback is not configured")
|
|
437
|
+
disabled = FeedbackConfig(
|
|
438
|
+
enabled=False,
|
|
439
|
+
installation_id=config.installation_id,
|
|
440
|
+
sink=config.sink,
|
|
441
|
+
retention_days=config.retention_days,
|
|
442
|
+
max_records=config.max_records,
|
|
443
|
+
max_bytes=config.max_bytes,
|
|
444
|
+
)
|
|
445
|
+
atomic_write(
|
|
446
|
+
_confined_path(root, CONFIG_PATH, "feedback configuration"),
|
|
447
|
+
json.dumps(disabled.as_dict(), indent=2) + "\n",
|
|
448
|
+
)
|
|
449
|
+
return disabled
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def rotate_feedback_identity(root: Path) -> FeedbackConfig:
|
|
453
|
+
config = load_feedback_config(root)
|
|
454
|
+
if config is None:
|
|
455
|
+
raise ConfigurationError("feedback is not configured")
|
|
456
|
+
rotated = FeedbackConfig(
|
|
457
|
+
enabled=config.enabled,
|
|
458
|
+
installation_id=str(uuid.uuid4()),
|
|
459
|
+
sink=config.sink,
|
|
460
|
+
retention_days=config.retention_days,
|
|
461
|
+
max_records=config.max_records,
|
|
462
|
+
max_bytes=config.max_bytes,
|
|
463
|
+
)
|
|
464
|
+
atomic_write(
|
|
465
|
+
_confined_path(root, CONFIG_PATH, "feedback configuration"),
|
|
466
|
+
json.dumps(rotated.as_dict(), indent=2) + "\n",
|
|
467
|
+
)
|
|
468
|
+
return rotated
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def _result_class(exit_code: int) -> str:
|
|
472
|
+
return {
|
|
473
|
+
0: "success",
|
|
474
|
+
1: "drift",
|
|
475
|
+
2: "invalid",
|
|
476
|
+
3: "extraction-failed",
|
|
477
|
+
}.get(exit_code, "failure")
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def _repository_ref(root: Path) -> str:
|
|
481
|
+
head = root / ".git/HEAD"
|
|
482
|
+
try:
|
|
483
|
+
value = head.read_text(encoding="utf-8").strip()
|
|
484
|
+
if value.startswith("ref: "):
|
|
485
|
+
ref_path = root / ".git" / value.removeprefix("ref: ")
|
|
486
|
+
value = ref_path.read_text(encoding="utf-8").strip()
|
|
487
|
+
except OSError:
|
|
488
|
+
return "WORKTREE"
|
|
489
|
+
if len(value) == 40 and all(char in "0123456789abcdef" for char in value):
|
|
490
|
+
return value
|
|
491
|
+
return "WORKTREE"
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _repository_classes(root: Path) -> tuple[str, str]:
|
|
495
|
+
file_count = 0
|
|
496
|
+
markdown = False
|
|
497
|
+
mdx = False
|
|
498
|
+
excluded = {".git", ".sourcebound", ".venv", "node_modules"}
|
|
499
|
+
try:
|
|
500
|
+
for directory, directories, files in os.walk(root):
|
|
501
|
+
directories[:] = [name for name in directories if name not in excluded]
|
|
502
|
+
file_count += len(files)
|
|
503
|
+
markdown = markdown or any(name.endswith(".md") for name in files)
|
|
504
|
+
mdx = mdx or any(name.endswith(".mdx") for name in files)
|
|
505
|
+
if file_count > 10_000:
|
|
506
|
+
break
|
|
507
|
+
except OSError:
|
|
508
|
+
return "unknown", "unknown"
|
|
509
|
+
size_class = (
|
|
510
|
+
"small"
|
|
511
|
+
if file_count < 100
|
|
512
|
+
else "medium"
|
|
513
|
+
if file_count < 1_000
|
|
514
|
+
else "large"
|
|
515
|
+
if file_count <= 10_000
|
|
516
|
+
else "very-large"
|
|
517
|
+
)
|
|
518
|
+
adapter = (
|
|
519
|
+
"mixed"
|
|
520
|
+
if markdown and mdx
|
|
521
|
+
else "mdx"
|
|
522
|
+
if mdx
|
|
523
|
+
else "markdown"
|
|
524
|
+
if markdown
|
|
525
|
+
else "none"
|
|
526
|
+
)
|
|
527
|
+
return adapter, size_class
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def build_feedback_envelope(
|
|
531
|
+
root: Path,
|
|
532
|
+
config: FeedbackConfig,
|
|
533
|
+
*,
|
|
534
|
+
command: str,
|
|
535
|
+
exit_code: int,
|
|
536
|
+
execution_policy: str,
|
|
537
|
+
outcome: str | None = None,
|
|
538
|
+
) -> dict[str, object]:
|
|
539
|
+
adapter, repository_size_class = _repository_classes(root)
|
|
540
|
+
run_id = _sha256(uuid.uuid4().bytes)
|
|
541
|
+
outcome_material = {
|
|
542
|
+
"run_id": run_id,
|
|
543
|
+
"product_version": __version__,
|
|
544
|
+
"repository_ref": _repository_ref(root),
|
|
545
|
+
"command": command,
|
|
546
|
+
"exit_code": exit_code,
|
|
547
|
+
"execution_policy": execution_policy,
|
|
548
|
+
}
|
|
549
|
+
outcome_id = _sha256(_canonical_json(outcome_material).encode())
|
|
550
|
+
event_id = _sha256(f"{config.installation_id}:{run_id}".encode())
|
|
551
|
+
envelope: dict[str, object] = {
|
|
552
|
+
"schema": ENVELOPE_SCHEMA,
|
|
553
|
+
"event_id": event_id,
|
|
554
|
+
"run_id": run_id,
|
|
555
|
+
"outcome_id": outcome_id,
|
|
556
|
+
"occurred_at": _utc_now(),
|
|
557
|
+
"product_version": __version__,
|
|
558
|
+
"installation_id": config.installation_id,
|
|
559
|
+
"command": command,
|
|
560
|
+
"exit_code": exit_code,
|
|
561
|
+
"result_class": _result_class(exit_code),
|
|
562
|
+
"execution_policy": execution_policy,
|
|
563
|
+
"adapter": adapter,
|
|
564
|
+
"repository_size_class": repository_size_class,
|
|
565
|
+
}
|
|
566
|
+
if outcome is not None:
|
|
567
|
+
envelope["outcome"] = outcome
|
|
568
|
+
return envelope
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def _outbox_records(root: Path) -> list[Path]:
|
|
572
|
+
directory = _confined_path(root, OUTBOX_DIR, "feedback outbox")
|
|
573
|
+
if not directory.is_dir():
|
|
574
|
+
return []
|
|
575
|
+
return sorted(path for path in directory.iterdir() if path.suffix == ".json")
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def _prune_feedback_state(root: Path, config: FeedbackConfig) -> None:
|
|
579
|
+
cutoff = time.time() - config.retention_days * 86_400
|
|
580
|
+
directories = [
|
|
581
|
+
_confined_path(root, OUTBOX_DIR, "feedback outbox"),
|
|
582
|
+
_confined_path(root, DEAD_LETTER_DIR, "feedback dead-letter directory"),
|
|
583
|
+
_confined_path(root, ATTEMPTS_DIR, "feedback attempts directory"),
|
|
584
|
+
]
|
|
585
|
+
if config.sink["kind"] == "local":
|
|
586
|
+
directories.append(
|
|
587
|
+
_confined_path(
|
|
588
|
+
root,
|
|
589
|
+
Path(config.sink["target"]),
|
|
590
|
+
"local feedback sink",
|
|
591
|
+
)
|
|
592
|
+
)
|
|
593
|
+
for directory in directories:
|
|
594
|
+
if not directory.is_dir():
|
|
595
|
+
continue
|
|
596
|
+
for path in directory.iterdir():
|
|
597
|
+
try:
|
|
598
|
+
if path.is_file() and path.stat().st_mtime < cutoff:
|
|
599
|
+
path.unlink()
|
|
600
|
+
except OSError:
|
|
601
|
+
continue
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def enqueue_feedback(
|
|
605
|
+
root: Path,
|
|
606
|
+
*,
|
|
607
|
+
command: str,
|
|
608
|
+
exit_code: int,
|
|
609
|
+
execution_policy: str,
|
|
610
|
+
outcome: str | None = None,
|
|
611
|
+
) -> Path | None:
|
|
612
|
+
config = load_feedback_config(root)
|
|
613
|
+
if config is None or not config.enabled:
|
|
614
|
+
return None
|
|
615
|
+
_prune_feedback_state(root, config)
|
|
616
|
+
envelope = build_feedback_envelope(
|
|
617
|
+
root,
|
|
618
|
+
config,
|
|
619
|
+
command=command,
|
|
620
|
+
exit_code=exit_code,
|
|
621
|
+
execution_policy=execution_policy,
|
|
622
|
+
outcome=outcome,
|
|
623
|
+
)
|
|
624
|
+
event_id = str(envelope["event_id"])
|
|
625
|
+
path = _confined_path(
|
|
626
|
+
root,
|
|
627
|
+
OUTBOX_DIR / f"{event_id}.json",
|
|
628
|
+
"feedback outbox record",
|
|
629
|
+
)
|
|
630
|
+
if path.is_file():
|
|
631
|
+
return path
|
|
632
|
+
payload = _canonical_json(envelope)
|
|
633
|
+
records = [
|
|
634
|
+
*_outbox_records(root),
|
|
635
|
+
*sorted(
|
|
636
|
+
_confined_path(
|
|
637
|
+
root,
|
|
638
|
+
DEAD_LETTER_DIR,
|
|
639
|
+
"feedback dead-letter directory",
|
|
640
|
+
).glob("*.json")
|
|
641
|
+
),
|
|
642
|
+
]
|
|
643
|
+
total_bytes = sum(record.stat().st_size for record in records)
|
|
644
|
+
if len(records) >= config.max_records:
|
|
645
|
+
raise ConfigurationError("feedback outbox record quota reached")
|
|
646
|
+
if total_bytes + len(payload.encode()) > config.max_bytes:
|
|
647
|
+
raise ConfigurationError("feedback outbox byte quota reached")
|
|
648
|
+
atomic_write(path, payload)
|
|
649
|
+
return path
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def preview_feedback(root: Path) -> bytes:
|
|
653
|
+
records = _outbox_records(root)
|
|
654
|
+
return b"".join(record.read_bytes() for record in records)
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
def _validate_envelope_bytes(payload: bytes) -> Mapping[str, Any]:
|
|
658
|
+
try:
|
|
659
|
+
raw = json.loads(payload)
|
|
660
|
+
except json.JSONDecodeError as exc:
|
|
661
|
+
raise ConfigurationError("feedback envelope is invalid JSON") from exc
|
|
662
|
+
envelope = _require_mapping(raw, "feedback envelope")
|
|
663
|
+
allowed_keys = _ENVELOPE_KEYS - {"outcome"}
|
|
664
|
+
if "outcome" in envelope:
|
|
665
|
+
allowed_keys = _ENVELOPE_KEYS
|
|
666
|
+
_require_exact_keys(envelope, allowed_keys, "feedback envelope")
|
|
667
|
+
if envelope.get("schema") != ENVELOPE_SCHEMA:
|
|
668
|
+
raise ConfigurationError(f"feedback envelope schema must be {ENVELOPE_SCHEMA}")
|
|
669
|
+
event_id = _require_digest(envelope.get("event_id"), "feedback envelope event_id")
|
|
670
|
+
run_id = _require_digest(envelope.get("run_id"), "feedback envelope run_id")
|
|
671
|
+
_require_digest(envelope.get("outcome_id"), "feedback envelope outcome_id")
|
|
672
|
+
installation_id = _require_string(
|
|
673
|
+
envelope.get("installation_id"),
|
|
674
|
+
"feedback envelope installation_id",
|
|
675
|
+
)
|
|
676
|
+
try:
|
|
677
|
+
uuid.UUID(installation_id)
|
|
678
|
+
except ValueError as exc:
|
|
679
|
+
raise ConfigurationError(
|
|
680
|
+
"feedback envelope installation_id must be a UUID"
|
|
681
|
+
) from exc
|
|
682
|
+
if event_id != _sha256(f"{installation_id}:{run_id}".encode()):
|
|
683
|
+
raise ConfigurationError("feedback envelope event_id does not match its run")
|
|
684
|
+
_parse_utc(envelope.get("occurred_at"), "feedback envelope occurred_at")
|
|
685
|
+
_require_token(
|
|
686
|
+
envelope.get("product_version"),
|
|
687
|
+
"feedback envelope product_version",
|
|
688
|
+
pattern=r"[0-9A-Za-z.+-]{1,32}",
|
|
689
|
+
)
|
|
690
|
+
_require_token(envelope.get("command"), "feedback envelope command")
|
|
691
|
+
exit_code = envelope.get("exit_code")
|
|
692
|
+
if isinstance(exit_code, bool) or not isinstance(exit_code, int):
|
|
693
|
+
raise ConfigurationError("feedback envelope exit_code must be an integer")
|
|
694
|
+
if envelope.get("result_class") != _result_class(exit_code):
|
|
695
|
+
raise ConfigurationError("feedback envelope result_class does not match exit_code")
|
|
696
|
+
if "outcome" in envelope and envelope["outcome"] not in {
|
|
697
|
+
"accept", "parser-reject", "provider-failed",
|
|
698
|
+
}:
|
|
699
|
+
raise ConfigurationError("feedback envelope outcome is invalid")
|
|
700
|
+
if envelope.get("execution_policy") not in {"trusted", "static-only"}:
|
|
701
|
+
raise ConfigurationError("feedback envelope execution_policy is invalid")
|
|
702
|
+
if envelope.get("adapter") not in {"none", "markdown", "mdx", "mixed", "unknown"}:
|
|
703
|
+
raise ConfigurationError("feedback envelope adapter is invalid")
|
|
704
|
+
if envelope.get("repository_size_class") not in {
|
|
705
|
+
"small",
|
|
706
|
+
"medium",
|
|
707
|
+
"large",
|
|
708
|
+
"very-large",
|
|
709
|
+
"unknown",
|
|
710
|
+
}:
|
|
711
|
+
raise ConfigurationError("feedback envelope repository_size_class is invalid")
|
|
712
|
+
return envelope
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
def _deliver_local(root: Path, config: FeedbackConfig, payload: bytes) -> None:
|
|
716
|
+
envelope = _validate_envelope_bytes(payload)
|
|
717
|
+
path = _confined_path(
|
|
718
|
+
root,
|
|
719
|
+
Path(config.sink["target"]) / f"{envelope['event_id']}.json",
|
|
720
|
+
"local feedback event",
|
|
721
|
+
)
|
|
722
|
+
if path.is_file():
|
|
723
|
+
return
|
|
724
|
+
atomic_write(path, payload.decode())
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
def _deliver_connected(config: FeedbackConfig, payload: bytes) -> None:
|
|
728
|
+
envelope = _validate_envelope_bytes(payload)
|
|
729
|
+
deliver_event(
|
|
730
|
+
endpoint=config.sink["endpoint"],
|
|
731
|
+
token_env=config.sink["token_env"],
|
|
732
|
+
envelope=envelope,
|
|
733
|
+
)
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
def flush_feedback(root: Path) -> dict[str, int]:
|
|
737
|
+
config = load_feedback_config(root)
|
|
738
|
+
if config is None or not config.enabled:
|
|
739
|
+
raise ConfigurationError("feedback delivery is disabled")
|
|
740
|
+
_prune_feedback_state(root, config)
|
|
741
|
+
delivered = 0
|
|
742
|
+
duplicate = 0
|
|
743
|
+
failed = 0
|
|
744
|
+
for record in _outbox_records(root):
|
|
745
|
+
payload = record.read_bytes()
|
|
746
|
+
delivery_failed = False
|
|
747
|
+
try:
|
|
748
|
+
if config.sink["kind"] == "local":
|
|
749
|
+
target = _confined_path(
|
|
750
|
+
root,
|
|
751
|
+
Path(config.sink["target"]) / record.name,
|
|
752
|
+
"local feedback event",
|
|
753
|
+
)
|
|
754
|
+
existed = target.is_file()
|
|
755
|
+
_deliver_local(root, config, payload)
|
|
756
|
+
duplicate += int(existed)
|
|
757
|
+
else:
|
|
758
|
+
_deliver_connected(config, payload)
|
|
759
|
+
record.unlink()
|
|
760
|
+
_confined_path(
|
|
761
|
+
root,
|
|
762
|
+
ATTEMPTS_DIR / record.name,
|
|
763
|
+
"feedback attempt record",
|
|
764
|
+
).unlink(missing_ok=True)
|
|
765
|
+
delivered += 1
|
|
766
|
+
except ConfigurationError:
|
|
767
|
+
failed += 1
|
|
768
|
+
delivery_failed = True
|
|
769
|
+
if delivery_failed:
|
|
770
|
+
attempts_path = _confined_path(
|
|
771
|
+
root,
|
|
772
|
+
ATTEMPTS_DIR / record.name,
|
|
773
|
+
"feedback attempt record",
|
|
774
|
+
)
|
|
775
|
+
attempts = 0
|
|
776
|
+
if attempts_path.is_file():
|
|
777
|
+
try:
|
|
778
|
+
attempts = int(attempts_path.read_text(encoding="utf-8"))
|
|
779
|
+
except (OSError, ValueError):
|
|
780
|
+
attempts = MAX_DELIVERY_ATTEMPTS
|
|
781
|
+
attempts += 1
|
|
782
|
+
if attempts >= MAX_DELIVERY_ATTEMPTS:
|
|
783
|
+
dead_letter = _confined_path(
|
|
784
|
+
root,
|
|
785
|
+
DEAD_LETTER_DIR / record.name,
|
|
786
|
+
"feedback dead-letter record",
|
|
787
|
+
)
|
|
788
|
+
dead_letter.parent.mkdir(parents=True, exist_ok=True)
|
|
789
|
+
record.replace(dead_letter)
|
|
790
|
+
attempts_path.unlink(missing_ok=True)
|
|
791
|
+
else:
|
|
792
|
+
atomic_write(attempts_path, f"{attempts}\n")
|
|
793
|
+
return {"delivered": delivered, "duplicates": duplicate, "failed": failed}
|
|
794
|
+
|
|
795
|
+
|
|
796
|
+
def validate_behavior_signal(payload: Mapping[str, Any]) -> dict[str, object]:
|
|
797
|
+
_require_exact_keys(payload, _SIGNAL_KEYS, "behavior signal")
|
|
798
|
+
if payload.get("schema") != SIGNAL_SCHEMA:
|
|
799
|
+
raise ConfigurationError(f"behavior signal schema must be {SIGNAL_SCHEMA}")
|
|
800
|
+
signal_id = _require_digest(payload.get("signal_id"), "behavior signal signal_id")
|
|
801
|
+
metric = _require_mapping(payload.get("metric"), "behavior signal metric")
|
|
802
|
+
_require_exact_keys(metric, _METRIC_KEYS, "behavior signal metric")
|
|
803
|
+
direction = _require_string(
|
|
804
|
+
metric.get("direction"),
|
|
805
|
+
"behavior signal metric.direction",
|
|
806
|
+
)
|
|
807
|
+
if direction not in {"higher-is-better", "lower-is-better"}:
|
|
808
|
+
raise ConfigurationError(
|
|
809
|
+
"behavior signal metric.direction must be higher-is-better or lower-is-better"
|
|
810
|
+
)
|
|
811
|
+
normalized_metric = {
|
|
812
|
+
"name": _require_string(metric.get("name"), "behavior signal metric.name"),
|
|
813
|
+
"version": _require_string(
|
|
814
|
+
metric.get("version"),
|
|
815
|
+
"behavior signal metric.version",
|
|
816
|
+
),
|
|
817
|
+
"direction": direction,
|
|
818
|
+
}
|
|
819
|
+
window = _require_mapping(payload.get("window"), "behavior signal window")
|
|
820
|
+
_require_exact_keys(window, _WINDOW_KEYS, "behavior signal window")
|
|
821
|
+
start = _parse_utc(window.get("start"), "behavior signal window.start")
|
|
822
|
+
end = _parse_utc(window.get("end"), "behavior signal window.end")
|
|
823
|
+
if start >= end:
|
|
824
|
+
raise ConfigurationError("behavior signal window.start must precede window.end")
|
|
825
|
+
normalized_window = {
|
|
826
|
+
"start": str(window["start"]),
|
|
827
|
+
"end": str(window["end"]),
|
|
828
|
+
}
|
|
829
|
+
counts = dict(_require_count_pair(payload.get("counts"), "behavior signal counts"))
|
|
830
|
+
segment = _require_mapping(payload.get("segment"), "behavior signal segment")
|
|
831
|
+
_require_exact_keys(segment, _SEGMENT_KEYS, "behavior signal segment")
|
|
832
|
+
scope = _require_string(segment.get("scope"), "behavior signal segment.scope")
|
|
833
|
+
if scope not in {"installation", "cross-installation"}:
|
|
834
|
+
raise ConfigurationError(
|
|
835
|
+
"behavior signal segment.scope must be installation or cross-installation"
|
|
836
|
+
)
|
|
837
|
+
installations = segment.get("installations")
|
|
838
|
+
contributing = segment.get("contributing_installations")
|
|
839
|
+
if not isinstance(installations, int) or installations <= 0:
|
|
840
|
+
raise ConfigurationError(
|
|
841
|
+
"behavior signal segment.installations must be a positive integer"
|
|
842
|
+
)
|
|
843
|
+
if (
|
|
844
|
+
not isinstance(contributing, int)
|
|
845
|
+
or contributing <= 0
|
|
846
|
+
or contributing > installations
|
|
847
|
+
):
|
|
848
|
+
raise ConfigurationError(
|
|
849
|
+
"behavior signal segment.contributing_installations must be positive "
|
|
850
|
+
"and cannot exceed installations"
|
|
851
|
+
)
|
|
852
|
+
if scope == "cross-installation" and contributing < 2:
|
|
853
|
+
raise ConfigurationError(
|
|
854
|
+
"cross-installation signals require at least two contributing installations"
|
|
855
|
+
)
|
|
856
|
+
normalized_segment = {
|
|
857
|
+
"scope": scope,
|
|
858
|
+
"installations": installations,
|
|
859
|
+
"contributing_installations": contributing,
|
|
860
|
+
"product_versions": _require_string_list(
|
|
861
|
+
segment.get("product_versions"),
|
|
862
|
+
"behavior signal segment.product_versions",
|
|
863
|
+
),
|
|
864
|
+
"adapters": _require_string_list(
|
|
865
|
+
segment.get("adapters"),
|
|
866
|
+
"behavior signal segment.adapters",
|
|
867
|
+
),
|
|
868
|
+
"execution_policies": _require_string_list(
|
|
869
|
+
segment.get("execution_policies"),
|
|
870
|
+
"behavior signal segment.execution_policies",
|
|
871
|
+
),
|
|
872
|
+
"repository_size_classes": _require_string_list(
|
|
873
|
+
segment.get("repository_size_classes"),
|
|
874
|
+
"behavior signal segment.repository_size_classes",
|
|
875
|
+
),
|
|
876
|
+
}
|
|
877
|
+
data_quality = _require_string(
|
|
878
|
+
payload.get("data_quality"),
|
|
879
|
+
"behavior signal data_quality",
|
|
880
|
+
)
|
|
881
|
+
if data_quality not in {"complete", "partial"}:
|
|
882
|
+
raise ConfigurationError(
|
|
883
|
+
"behavior signal data_quality must be complete or partial"
|
|
884
|
+
)
|
|
885
|
+
privacy_class = _require_string(
|
|
886
|
+
payload.get("privacy_class"),
|
|
887
|
+
"behavior signal privacy_class",
|
|
888
|
+
)
|
|
889
|
+
if privacy_class != "aggregate":
|
|
890
|
+
raise ConfigurationError("behavior signal privacy_class must be aggregate")
|
|
891
|
+
evidence_class = _require_string(
|
|
892
|
+
payload.get("evidence_class"),
|
|
893
|
+
"behavior signal evidence_class",
|
|
894
|
+
)
|
|
895
|
+
if evidence_class not in {"independent-observation", "internal-regression"}:
|
|
896
|
+
raise ConfigurationError(
|
|
897
|
+
"behavior signal evidence_class must be independent-observation "
|
|
898
|
+
"or internal-regression"
|
|
899
|
+
)
|
|
900
|
+
source_receipt = _require_mapping(
|
|
901
|
+
payload.get("source_receipt"),
|
|
902
|
+
"behavior signal source_receipt",
|
|
903
|
+
)
|
|
904
|
+
_require_exact_keys(
|
|
905
|
+
source_receipt,
|
|
906
|
+
_SOURCE_RECEIPT_KEYS,
|
|
907
|
+
"behavior signal source_receipt",
|
|
908
|
+
)
|
|
909
|
+
normalized_source = {
|
|
910
|
+
"schema": _require_string(
|
|
911
|
+
source_receipt.get("schema"),
|
|
912
|
+
"behavior signal source_receipt.schema",
|
|
913
|
+
),
|
|
914
|
+
"sha256": _require_digest(
|
|
915
|
+
source_receipt.get("sha256"),
|
|
916
|
+
"behavior signal source_receipt.sha256",
|
|
917
|
+
),
|
|
918
|
+
}
|
|
919
|
+
contradictions_raw = payload.get("contradictory_evidence")
|
|
920
|
+
if not isinstance(contradictions_raw, list):
|
|
921
|
+
raise ConfigurationError(
|
|
922
|
+
"behavior signal contradictory_evidence must be an array"
|
|
923
|
+
)
|
|
924
|
+
contradictions = [
|
|
925
|
+
_require_digest(
|
|
926
|
+
item,
|
|
927
|
+
f"behavior signal contradictory_evidence[{index}]",
|
|
928
|
+
)
|
|
929
|
+
for index, item in enumerate(contradictions_raw)
|
|
930
|
+
]
|
|
931
|
+
normalized_without_id: dict[str, object] = {
|
|
932
|
+
"schema": SIGNAL_SCHEMA,
|
|
933
|
+
"metric": normalized_metric,
|
|
934
|
+
"window": normalized_window,
|
|
935
|
+
"counts": counts,
|
|
936
|
+
"segment": normalized_segment,
|
|
937
|
+
"data_quality": data_quality,
|
|
938
|
+
"privacy_class": privacy_class,
|
|
939
|
+
"source_receipt": normalized_source,
|
|
940
|
+
"contradictory_evidence": contradictions,
|
|
941
|
+
"evidence_class": evidence_class,
|
|
942
|
+
}
|
|
943
|
+
expected_id = _sha256(_canonical_json(normalized_without_id).encode())
|
|
944
|
+
if signal_id != expected_id:
|
|
945
|
+
raise ConfigurationError(
|
|
946
|
+
"behavior signal signal_id does not match its canonical content"
|
|
947
|
+
)
|
|
948
|
+
return {"signal_id": signal_id, **normalized_without_id}
|
|
949
|
+
|
|
950
|
+
|
|
951
|
+
def prepare_behavior_signal(payload: Mapping[str, Any]) -> dict[str, object]:
|
|
952
|
+
body_keys = _SIGNAL_KEYS - {"signal_id"}
|
|
953
|
+
_require_exact_keys(payload, body_keys, "behavior signal body")
|
|
954
|
+
missing = sorted(body_keys - set(payload))
|
|
955
|
+
if missing:
|
|
956
|
+
raise ConfigurationError(
|
|
957
|
+
f"behavior signal body is missing field(s): {', '.join(missing)}"
|
|
958
|
+
)
|
|
959
|
+
signal_id = _sha256(_canonical_json(payload).encode())
|
|
960
|
+
return validate_behavior_signal({"signal_id": signal_id, **payload})
|
|
961
|
+
|
|
962
|
+
|
|
963
|
+
def load_behavior_signal(path: Path) -> tuple[dict[str, object], bytes]:
|
|
964
|
+
try:
|
|
965
|
+
payload_bytes = path.read_bytes()
|
|
966
|
+
raw = json.loads(payload_bytes)
|
|
967
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
968
|
+
raise ConfigurationError(f"cannot read behavior signal {path}") from exc
|
|
969
|
+
payload = _require_mapping(raw, "behavior signal")
|
|
970
|
+
return validate_behavior_signal(payload), payload_bytes
|
|
971
|
+
|
|
972
|
+
|
|
973
|
+
def ingest_behavior_signal(root: Path, path: Path) -> dict[str, object]:
|
|
974
|
+
signal, _source_bytes = load_behavior_signal(path)
|
|
975
|
+
signal_id = str(signal["signal_id"])
|
|
976
|
+
stored_signal = _confined_path(
|
|
977
|
+
root,
|
|
978
|
+
SIGNALS_DIR / f"{signal_id}.json",
|
|
979
|
+
"behavior signal record",
|
|
980
|
+
)
|
|
981
|
+
canonical_signal = _canonical_json(signal)
|
|
982
|
+
if stored_signal.is_file() and stored_signal.read_text(encoding="utf-8") != canonical_signal:
|
|
983
|
+
raise ConfigurationError("behavior signal id conflicts with stored bytes")
|
|
984
|
+
atomic_write(stored_signal, canonical_signal)
|
|
985
|
+
case_path = _confined_path(
|
|
986
|
+
root,
|
|
987
|
+
CASES_DIR / f"{signal_id}.json",
|
|
988
|
+
"improvement case",
|
|
989
|
+
)
|
|
990
|
+
case: dict[str, object] = {
|
|
991
|
+
"schema": CASE_SCHEMA,
|
|
992
|
+
"case_id": signal_id,
|
|
993
|
+
"signal_id": signal_id,
|
|
994
|
+
"state": "observed",
|
|
995
|
+
"evidence_class": signal["evidence_class"],
|
|
996
|
+
"history": [{
|
|
997
|
+
"state": "observed",
|
|
998
|
+
"receipt_schema": SIGNAL_SCHEMA,
|
|
999
|
+
"receipt_sha256": _sha256(canonical_signal.encode()),
|
|
1000
|
+
}],
|
|
1001
|
+
}
|
|
1002
|
+
if case_path.is_file():
|
|
1003
|
+
existing = json.loads(case_path.read_text(encoding="utf-8"))
|
|
1004
|
+
if not isinstance(existing, dict) or existing.get("signal_id") != signal_id:
|
|
1005
|
+
raise ConfigurationError("improvement case conflicts with stored signal")
|
|
1006
|
+
return existing
|
|
1007
|
+
atomic_write(case_path, json.dumps(case, indent=2) + "\n")
|
|
1008
|
+
return case
|
|
1009
|
+
|
|
1010
|
+
|
|
1011
|
+
def _stored_receipt_path(root: Path, case_id: str, state: str) -> Path:
|
|
1012
|
+
if state == "observed":
|
|
1013
|
+
relative = SIGNALS_DIR / f"{case_id}.json"
|
|
1014
|
+
else:
|
|
1015
|
+
relative = RECEIPTS_DIR / case_id / f"{state}.json"
|
|
1016
|
+
return _confined_path(root, relative, "improvement receipt")
|
|
1017
|
+
|
|
1018
|
+
|
|
1019
|
+
def _validated_case_history(
|
|
1020
|
+
root: Path,
|
|
1021
|
+
case_id: str,
|
|
1022
|
+
current_state: str,
|
|
1023
|
+
history_raw: object,
|
|
1024
|
+
) -> tuple[list[object], str]:
|
|
1025
|
+
if not isinstance(history_raw, list) or not history_raw:
|
|
1026
|
+
raise ConfigurationError("improvement case history must be a non-empty array")
|
|
1027
|
+
current_index = _CASE_STATES.index(current_state)
|
|
1028
|
+
expected_states = list(_CASE_STATES[: current_index + 1])
|
|
1029
|
+
observed_states: list[str] = []
|
|
1030
|
+
for index, entry_raw in enumerate(history_raw):
|
|
1031
|
+
entry = _require_mapping(
|
|
1032
|
+
entry_raw,
|
|
1033
|
+
f"improvement case history[{index}]",
|
|
1034
|
+
)
|
|
1035
|
+
_require_exact_keys(
|
|
1036
|
+
entry,
|
|
1037
|
+
{"state", "receipt_schema", "receipt_sha256"},
|
|
1038
|
+
f"improvement case history[{index}]",
|
|
1039
|
+
)
|
|
1040
|
+
observed_states.append(
|
|
1041
|
+
_require_string(
|
|
1042
|
+
entry.get("state"),
|
|
1043
|
+
f"improvement case history[{index}].state",
|
|
1044
|
+
)
|
|
1045
|
+
)
|
|
1046
|
+
receipt_schema = _require_string(
|
|
1047
|
+
entry.get("receipt_schema"),
|
|
1048
|
+
f"improvement case history[{index}].receipt_schema",
|
|
1049
|
+
)
|
|
1050
|
+
expected_schema = (
|
|
1051
|
+
SIGNAL_SCHEMA
|
|
1052
|
+
if observed_states[-1] == "observed"
|
|
1053
|
+
else _TRANSITION_RECEIPT_SCHEMAS.get(observed_states[-1])
|
|
1054
|
+
)
|
|
1055
|
+
if receipt_schema != expected_schema:
|
|
1056
|
+
raise ConfigurationError(
|
|
1057
|
+
f"improvement case history[{index}] has the wrong receipt schema"
|
|
1058
|
+
)
|
|
1059
|
+
receipt_digest = _require_digest(
|
|
1060
|
+
entry.get("receipt_sha256"),
|
|
1061
|
+
f"improvement case history[{index}].receipt_sha256",
|
|
1062
|
+
)
|
|
1063
|
+
receipt_path = _stored_receipt_path(root, case_id, observed_states[-1])
|
|
1064
|
+
try:
|
|
1065
|
+
stored_digest = _sha256(receipt_path.read_bytes())
|
|
1066
|
+
except OSError as exc:
|
|
1067
|
+
raise ConfigurationError(
|
|
1068
|
+
f"improvement case is missing receipt for {observed_states[-1]}"
|
|
1069
|
+
) from exc
|
|
1070
|
+
if receipt_digest != stored_digest:
|
|
1071
|
+
raise ConfigurationError(
|
|
1072
|
+
f"improvement case receipt changed for {observed_states[-1]}"
|
|
1073
|
+
)
|
|
1074
|
+
if observed_states != expected_states:
|
|
1075
|
+
raise ConfigurationError(
|
|
1076
|
+
"improvement case history does not match its adjacent state sequence"
|
|
1077
|
+
)
|
|
1078
|
+
last = _require_mapping(history_raw[-1], "improvement case prior history")
|
|
1079
|
+
return history_raw, _require_digest(
|
|
1080
|
+
last.get("receipt_sha256"),
|
|
1081
|
+
"improvement case prior receipt_sha256",
|
|
1082
|
+
)
|
|
1083
|
+
|
|
1084
|
+
|
|
1085
|
+
def _validate_prior_digest(
|
|
1086
|
+
receipt: Mapping[str, Any],
|
|
1087
|
+
expected: str,
|
|
1088
|
+
where: str,
|
|
1089
|
+
) -> None:
|
|
1090
|
+
observed = _require_digest(
|
|
1091
|
+
receipt.get("prior_receipt_sha256"),
|
|
1092
|
+
f"{where} prior_receipt_sha256",
|
|
1093
|
+
)
|
|
1094
|
+
if observed != expected:
|
|
1095
|
+
raise ConfigurationError(f"{where} does not chain to the prior receipt")
|
|
1096
|
+
|
|
1097
|
+
|
|
1098
|
+
def _validate_transition_receipt(
|
|
1099
|
+
receipt: Mapping[str, Any],
|
|
1100
|
+
*,
|
|
1101
|
+
target_state: str,
|
|
1102
|
+
signal_id: str,
|
|
1103
|
+
prior_receipt_sha256: str,
|
|
1104
|
+
) -> None:
|
|
1105
|
+
if target_state == "reproduced":
|
|
1106
|
+
_require_exact_keys(receipt, _REPRODUCTION_KEYS, "reproduction receipt")
|
|
1107
|
+
if receipt.get("signal_id") != signal_id:
|
|
1108
|
+
raise ConfigurationError("reproduction receipt signal_id does not match the case")
|
|
1109
|
+
_require_digest(
|
|
1110
|
+
receipt.get("fixture_sha256"),
|
|
1111
|
+
"reproduction receipt fixture_sha256",
|
|
1112
|
+
)
|
|
1113
|
+
_require_digest(
|
|
1114
|
+
receipt.get("baseline_outcome_id"),
|
|
1115
|
+
"reproduction receipt baseline_outcome_id",
|
|
1116
|
+
)
|
|
1117
|
+
if receipt.get("reproduced") is not True:
|
|
1118
|
+
raise ConfigurationError("reproduction receipt must record reproduced: true")
|
|
1119
|
+
failure_class = _require_string(
|
|
1120
|
+
receipt.get("failure_class"),
|
|
1121
|
+
"reproduction receipt failure_class",
|
|
1122
|
+
)
|
|
1123
|
+
if failure_class not in {
|
|
1124
|
+
"tool-defect",
|
|
1125
|
+
"unsupported-surface",
|
|
1126
|
+
"configuration-defect",
|
|
1127
|
+
"proposal-defect",
|
|
1128
|
+
"user-abandonment",
|
|
1129
|
+
}:
|
|
1130
|
+
raise ConfigurationError("reproduction receipt failure_class is invalid")
|
|
1131
|
+
return
|
|
1132
|
+
if target_state == "root-cause-classified":
|
|
1133
|
+
_require_exact_keys(receipt, _ROOT_CAUSE_KEYS, "root-cause receipt")
|
|
1134
|
+
_validate_prior_digest(receipt, prior_receipt_sha256, "root-cause receipt")
|
|
1135
|
+
classification = _require_string(
|
|
1136
|
+
receipt.get("classification"),
|
|
1137
|
+
"root-cause receipt classification",
|
|
1138
|
+
)
|
|
1139
|
+
if classification not in {
|
|
1140
|
+
"tool-defect",
|
|
1141
|
+
"unsupported-surface",
|
|
1142
|
+
"configuration-defect",
|
|
1143
|
+
"proposal-defect",
|
|
1144
|
+
"user-abandonment",
|
|
1145
|
+
}:
|
|
1146
|
+
raise ConfigurationError("root-cause receipt classification is invalid")
|
|
1147
|
+
_require_digest(
|
|
1148
|
+
receipt.get("evidence_sha256"),
|
|
1149
|
+
"root-cause receipt evidence_sha256",
|
|
1150
|
+
)
|
|
1151
|
+
return
|
|
1152
|
+
if target_state == "evaluation-proposed":
|
|
1153
|
+
_require_exact_keys(
|
|
1154
|
+
receipt,
|
|
1155
|
+
_EVALUATION_PROPOSAL_KEYS,
|
|
1156
|
+
"evaluation proposal receipt",
|
|
1157
|
+
)
|
|
1158
|
+
_validate_prior_digest(
|
|
1159
|
+
receipt,
|
|
1160
|
+
prior_receipt_sha256,
|
|
1161
|
+
"evaluation proposal receipt",
|
|
1162
|
+
)
|
|
1163
|
+
for field in ("metric_sha256", "scorer_sha256", "threshold_sha256"):
|
|
1164
|
+
_require_digest(
|
|
1165
|
+
receipt.get(field),
|
|
1166
|
+
f"evaluation proposal receipt {field}",
|
|
1167
|
+
)
|
|
1168
|
+
_require_string_list(
|
|
1169
|
+
receipt.get("protected_segments"),
|
|
1170
|
+
"evaluation proposal receipt protected_segments",
|
|
1171
|
+
)
|
|
1172
|
+
return
|
|
1173
|
+
if target_state == "regression-added":
|
|
1174
|
+
_require_exact_keys(receipt, _REGRESSION_KEYS, "regression receipt")
|
|
1175
|
+
_validate_prior_digest(receipt, prior_receipt_sha256, "regression receipt")
|
|
1176
|
+
_require_digest(
|
|
1177
|
+
receipt.get("fixture_sha256"),
|
|
1178
|
+
"regression receipt fixture_sha256",
|
|
1179
|
+
)
|
|
1180
|
+
if receipt.get("demonstrated_red") is not True:
|
|
1181
|
+
raise ConfigurationError(
|
|
1182
|
+
"regression receipt must record demonstrated_red: true"
|
|
1183
|
+
)
|
|
1184
|
+
return
|
|
1185
|
+
if target_state == "shadow-measured":
|
|
1186
|
+
_validate_prior_digest(receipt, prior_receipt_sha256, "shadow evaluation")
|
|
1187
|
+
_validate_shadow_receipt(receipt)
|
|
1188
|
+
return
|
|
1189
|
+
if target_state == "candidate-change":
|
|
1190
|
+
_require_exact_keys(
|
|
1191
|
+
receipt,
|
|
1192
|
+
_CANDIDATE_CHANGE_KEYS,
|
|
1193
|
+
"candidate change receipt",
|
|
1194
|
+
)
|
|
1195
|
+
_validate_prior_digest(
|
|
1196
|
+
receipt,
|
|
1197
|
+
prior_receipt_sha256,
|
|
1198
|
+
"candidate change receipt",
|
|
1199
|
+
)
|
|
1200
|
+
_require_digest(
|
|
1201
|
+
receipt.get("change_sha256"),
|
|
1202
|
+
"candidate change receipt change_sha256",
|
|
1203
|
+
)
|
|
1204
|
+
_require_digest(
|
|
1205
|
+
receipt.get("regression_suite_sha256"),
|
|
1206
|
+
"candidate change receipt regression_suite_sha256",
|
|
1207
|
+
)
|
|
1208
|
+
return
|
|
1209
|
+
validate_verdict_payload(receipt)
|
|
1210
|
+
if receipt.get("state") != "ready" or receipt.get("ready") is not True:
|
|
1211
|
+
raise ConfigurationError("ordinary verified PR requires a ready verdict")
|
|
1212
|
+
|
|
1213
|
+
|
|
1214
|
+
def _validate_shadow_receipt(receipt: Mapping[str, Any]) -> None:
|
|
1215
|
+
_require_exact_keys(receipt, _SHADOW_KEYS, "shadow evaluation receipt")
|
|
1216
|
+
if receipt.get("schema") != "sourcebound.shadow-evaluation.v1":
|
|
1217
|
+
raise ConfigurationError(
|
|
1218
|
+
"shadow evaluation receipt schema must be "
|
|
1219
|
+
"sourcebound.shadow-evaluation.v1"
|
|
1220
|
+
)
|
|
1221
|
+
_require_string(receipt.get("cohort_version"), "shadow evaluation cohort_version")
|
|
1222
|
+
direction = _require_string(
|
|
1223
|
+
receipt.get("direction"),
|
|
1224
|
+
"shadow evaluation direction",
|
|
1225
|
+
)
|
|
1226
|
+
if direction not in {"higher-is-better", "lower-is-better"}:
|
|
1227
|
+
raise ConfigurationError(
|
|
1228
|
+
"shadow evaluation direction must be higher-is-better or lower-is-better"
|
|
1229
|
+
)
|
|
1230
|
+
digest_pairs = (
|
|
1231
|
+
("baseline_metric_digest", "candidate_metric_digest"),
|
|
1232
|
+
("baseline_scorer_digest", "candidate_scorer_digest"),
|
|
1233
|
+
("baseline_threshold_digest", "candidate_threshold_digest"),
|
|
1234
|
+
)
|
|
1235
|
+
for baseline_key, candidate_key in digest_pairs:
|
|
1236
|
+
baseline_digest = _require_digest(
|
|
1237
|
+
receipt.get(baseline_key),
|
|
1238
|
+
f"shadow evaluation {baseline_key}",
|
|
1239
|
+
)
|
|
1240
|
+
candidate_digest = _require_digest(
|
|
1241
|
+
receipt.get(candidate_key),
|
|
1242
|
+
f"shadow evaluation {candidate_key}",
|
|
1243
|
+
)
|
|
1244
|
+
if baseline_digest != candidate_digest:
|
|
1245
|
+
raise ConfigurationError(
|
|
1246
|
+
"shadow comparison changed its metric, scorer, or threshold"
|
|
1247
|
+
)
|
|
1248
|
+
baseline = _require_count_pair(
|
|
1249
|
+
receipt.get("baseline"),
|
|
1250
|
+
"shadow evaluation baseline",
|
|
1251
|
+
)
|
|
1252
|
+
candidate = _require_count_pair(
|
|
1253
|
+
receipt.get("candidate"),
|
|
1254
|
+
"shadow evaluation candidate",
|
|
1255
|
+
)
|
|
1256
|
+
improved = (
|
|
1257
|
+
_rate(candidate) > _rate(baseline)
|
|
1258
|
+
if direction == "higher-is-better"
|
|
1259
|
+
else _rate(candidate) < _rate(baseline)
|
|
1260
|
+
)
|
|
1261
|
+
if not improved:
|
|
1262
|
+
raise ConfigurationError("shadow candidate did not improve the aggregate metric")
|
|
1263
|
+
protected = receipt.get("protected_segments")
|
|
1264
|
+
if not isinstance(protected, list) or not protected:
|
|
1265
|
+
raise ConfigurationError(
|
|
1266
|
+
"shadow evaluation protected_segments must be a non-empty array"
|
|
1267
|
+
)
|
|
1268
|
+
for index, raw_segment in enumerate(protected):
|
|
1269
|
+
segment = _require_mapping(
|
|
1270
|
+
raw_segment,
|
|
1271
|
+
f"shadow evaluation protected_segments[{index}]",
|
|
1272
|
+
)
|
|
1273
|
+
_require_exact_keys(
|
|
1274
|
+
segment,
|
|
1275
|
+
_PROTECTED_SEGMENT_KEYS,
|
|
1276
|
+
f"shadow evaluation protected_segments[{index}]",
|
|
1277
|
+
)
|
|
1278
|
+
_require_string(
|
|
1279
|
+
segment.get("name"),
|
|
1280
|
+
f"shadow evaluation protected_segments[{index}].name",
|
|
1281
|
+
)
|
|
1282
|
+
segment_baseline = _require_count_pair(
|
|
1283
|
+
segment.get("baseline"),
|
|
1284
|
+
f"shadow evaluation protected_segments[{index}].baseline",
|
|
1285
|
+
)
|
|
1286
|
+
segment_candidate = _require_count_pair(
|
|
1287
|
+
segment.get("candidate"),
|
|
1288
|
+
f"shadow evaluation protected_segments[{index}].candidate",
|
|
1289
|
+
)
|
|
1290
|
+
regressed = (
|
|
1291
|
+
_rate(segment_candidate) < _rate(segment_baseline)
|
|
1292
|
+
if direction == "higher-is-better"
|
|
1293
|
+
else _rate(segment_candidate) > _rate(segment_baseline)
|
|
1294
|
+
)
|
|
1295
|
+
if regressed:
|
|
1296
|
+
raise ConfigurationError(
|
|
1297
|
+
"shadow candidate regressed a protected segment"
|
|
1298
|
+
)
|
|
1299
|
+
|
|
1300
|
+
|
|
1301
|
+
def transition_improvement_case(
|
|
1302
|
+
root: Path,
|
|
1303
|
+
*,
|
|
1304
|
+
case_id: str,
|
|
1305
|
+
target_state: str,
|
|
1306
|
+
receipt_path: Path,
|
|
1307
|
+
) -> dict[str, object]:
|
|
1308
|
+
_require_digest(case_id, "improvement case id")
|
|
1309
|
+
if target_state not in _CASE_STATES:
|
|
1310
|
+
raise ConfigurationError(f"unknown improvement state: {target_state}")
|
|
1311
|
+
path = _confined_path(
|
|
1312
|
+
root,
|
|
1313
|
+
CASES_DIR / f"{case_id}.json",
|
|
1314
|
+
"improvement case",
|
|
1315
|
+
)
|
|
1316
|
+
try:
|
|
1317
|
+
case_raw = json.loads(path.read_text(encoding="utf-8"))
|
|
1318
|
+
receipt_bytes = receipt_path.read_bytes()
|
|
1319
|
+
receipt_raw = json.loads(receipt_bytes)
|
|
1320
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
1321
|
+
raise ConfigurationError("cannot read improvement case or transition receipt") from exc
|
|
1322
|
+
case = _require_mapping(case_raw, "improvement case")
|
|
1323
|
+
receipt = _require_mapping(receipt_raw, "transition receipt")
|
|
1324
|
+
current_state = _require_string(case.get("state"), "improvement case state")
|
|
1325
|
+
history_raw, prior_receipt_sha256 = _validated_case_history(
|
|
1326
|
+
root,
|
|
1327
|
+
case_id,
|
|
1328
|
+
current_state,
|
|
1329
|
+
case.get("history"),
|
|
1330
|
+
)
|
|
1331
|
+
receipt_sha256 = _sha256(receipt_bytes)
|
|
1332
|
+
if current_state == target_state:
|
|
1333
|
+
last_history = _require_mapping(
|
|
1334
|
+
history_raw[-1],
|
|
1335
|
+
"improvement case current history",
|
|
1336
|
+
)
|
|
1337
|
+
if last_history.get("receipt_sha256") != receipt_sha256:
|
|
1338
|
+
raise ConfigurationError(
|
|
1339
|
+
"improvement case state already has a different receipt"
|
|
1340
|
+
)
|
|
1341
|
+
return dict(case)
|
|
1342
|
+
expected_index = _CASE_STATES.index(current_state) + 1
|
|
1343
|
+
if expected_index >= len(_CASE_STATES) or _CASE_STATES[expected_index] != target_state:
|
|
1344
|
+
raise ConfigurationError(
|
|
1345
|
+
f"improvement case must transition from {current_state} "
|
|
1346
|
+
f"to {_CASE_STATES[expected_index] if expected_index < len(_CASE_STATES) else 'none'}"
|
|
1347
|
+
)
|
|
1348
|
+
expected_schema = _TRANSITION_RECEIPT_SCHEMAS[target_state]
|
|
1349
|
+
if receipt.get("schema") != expected_schema:
|
|
1350
|
+
raise ConfigurationError(
|
|
1351
|
+
f"{target_state} requires receipt schema {expected_schema}"
|
|
1352
|
+
)
|
|
1353
|
+
signal_id = _require_digest(
|
|
1354
|
+
case.get("signal_id"),
|
|
1355
|
+
"improvement case signal_id",
|
|
1356
|
+
)
|
|
1357
|
+
_validate_transition_receipt(
|
|
1358
|
+
receipt,
|
|
1359
|
+
target_state=target_state,
|
|
1360
|
+
signal_id=signal_id,
|
|
1361
|
+
prior_receipt_sha256=prior_receipt_sha256,
|
|
1362
|
+
)
|
|
1363
|
+
stored_receipt = _stored_receipt_path(root, case_id, target_state)
|
|
1364
|
+
if stored_receipt.is_file() and stored_receipt.read_bytes() != receipt_bytes:
|
|
1365
|
+
raise ConfigurationError(
|
|
1366
|
+
f"improvement case already has conflicting {target_state} receipt bytes"
|
|
1367
|
+
)
|
|
1368
|
+
atomic_write(stored_receipt, receipt_bytes.decode())
|
|
1369
|
+
updated = dict(case)
|
|
1370
|
+
updated["state"] = target_state
|
|
1371
|
+
updated["history"] = [
|
|
1372
|
+
*history_raw,
|
|
1373
|
+
{
|
|
1374
|
+
"state": target_state,
|
|
1375
|
+
"receipt_schema": expected_schema,
|
|
1376
|
+
"receipt_sha256": receipt_sha256,
|
|
1377
|
+
},
|
|
1378
|
+
]
|
|
1379
|
+
atomic_write(path, json.dumps(updated, indent=2) + "\n")
|
|
1380
|
+
return updated
|
|
1381
|
+
|
|
1382
|
+
|
|
1383
|
+
def purge_feedback(root: Path) -> None:
|
|
1384
|
+
state_path = root / STATE_DIR
|
|
1385
|
+
if state_path.is_symlink():
|
|
1386
|
+
state_path.unlink(missing_ok=True)
|
|
1387
|
+
else:
|
|
1388
|
+
shutil.rmtree(state_path, ignore_errors=True)
|
|
1389
|
+
config_path = root / CONFIG_PATH
|
|
1390
|
+
config_path.unlink(missing_ok=True)
|