sable-sphynx 0.7.0.dev0__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.
Files changed (174) hide show
  1. sable_sphynx-0.7.0.dev0.dist-info/METADATA +152 -0
  2. sable_sphynx-0.7.0.dev0.dist-info/RECORD +174 -0
  3. sable_sphynx-0.7.0.dev0.dist-info/WHEEL +5 -0
  4. sable_sphynx-0.7.0.dev0.dist-info/top_level.txt +1 -0
  5. sphynx/__init__.py +469 -0
  6. sphynx/__init__.pyi +376 -0
  7. sphynx/_analytical_client.py +77 -0
  8. sphynx/_analytical_values.py +252 -0
  9. sphynx/_audit_client.py +173 -0
  10. sphynx/_comparison_adapters.py +113 -0
  11. sphynx/_componentized_full_rag.py +34 -0
  12. sphynx/_composed_pipeline.py +109 -0
  13. sphynx/_full_rag_grading.py +218 -0
  14. sphynx/_full_rag_trace.py +221 -0
  15. sphynx/_local_eval.py +268 -0
  16. sphynx/_pipeline_core.py +243 -0
  17. sphynx/_pr_client.py +237 -0
  18. sphynx/_rag_diagnostics_calibration.py +285 -0
  19. sphynx/_rag_diagnostics_catalog.py +39 -0
  20. sphynx/_rag_diagnostics_common.py +26 -0
  21. sphynx/_rag_diagnostics_contracts.py +186 -0
  22. sphynx/_rag_diagnostics_engine.py +243 -0
  23. sphynx/_rag_diagnostics_importers.py +242 -0
  24. sphynx/_rag_diagnostics_ranking.py +81 -0
  25. sphynx/_rag_diagnostics_semantic.py +325 -0
  26. sphynx/_rag_ranking.py +149 -0
  27. sphynx/_speech_benchmark_scoring.py +140 -0
  28. sphynx/_speech_benchmark_sources.py +275 -0
  29. sphynx/_speech_complete_scoring.py +218 -0
  30. sphynx/_speech_complete_source.py +118 -0
  31. sphynx/_speech_kido.py +294 -0
  32. sphynx/_speech_scoring.py +77 -0
  33. sphynx/_track_capture.py +213 -0
  34. sphynx/analytical_views.py +515 -0
  35. sphynx/assessment_contracts.py +378 -0
  36. sphynx/assessment_selection.py +57 -0
  37. sphynx/audit_access.py +245 -0
  38. sphynx/audit_contracts.py +204 -0
  39. sphynx/audit_requests.py +180 -0
  40. sphynx/authoring.py +188 -0
  41. sphynx/benchmarks.py +568 -0
  42. sphynx/candidates.py +62 -0
  43. sphynx/catalog.py +272 -0
  44. sphynx/chunking.py +372 -0
  45. sphynx/clickhouse_reader.py +169 -0
  46. sphynx/client.py +486 -0
  47. sphynx/comparison_core.py +149 -0
  48. sphynx/comparison_views.py +223 -0
  49. sphynx/components.py +1661 -0
  50. sphynx/contracts.py +514 -0
  51. sphynx/customer_rerank.py +299 -0
  52. sphynx/customer_retrieval.py +146 -0
  53. sphynx/data/benchmark-releases.v1.json +164 -0
  54. sphynx/data/public-benchmark-release.v1.json +354 -0
  55. sphynx/data/rag-diagnostics-synthetic.v1.json +6982 -0
  56. sphynx/data/rag-paper-references.v1.json +176 -0
  57. sphynx/data/rag-paper-schemas.v1.json +1069 -0
  58. sphynx/data/rag-rerank-v1.json +507 -0
  59. sphynx/dataset_catalog_data.py +1214 -0
  60. sphynx/dataset_readiness.py +138 -0
  61. sphynx/dataset_v3.py +999 -0
  62. sphynx/demo.py +130 -0
  63. sphynx/demo_candidates/__init__.py +1 -0
  64. sphynx/demo_candidates/dense.py +11 -0
  65. sphynx/demo_candidates/lexical.py +21 -0
  66. sphynx/diagnostics.py +207 -0
  67. sphynx/documentation.py +737 -0
  68. sphynx/documentation_candidate.py +939 -0
  69. sphynx/evals.py +538 -0
  70. sphynx/evaluation_protocols.py +124 -0
  71. sphynx/evaluator_bindings.py +370 -0
  72. sphynx/evidence.py +352 -0
  73. sphynx/execution_capabilities.py +109 -0
  74. sphynx/fast_search.py +285 -0
  75. sphynx/full_rag.py +341 -0
  76. sphynx/hosted/__init__.py +18 -0
  77. sphynx/hosted/benchmark_intake.py +218 -0
  78. sphynx/hosted/contracts.py +361 -0
  79. sphynx/hosted/model_admission.py +130 -0
  80. sphynx/hosted/model_artifacts.py +298 -0
  81. sphynx/hosted/model_contracts.py +63 -0
  82. sphynx/hosted/model_gcs.py +181 -0
  83. sphynx/hosted/model_history.py +84 -0
  84. sphynx/hosted/model_session.py +233 -0
  85. sphynx/hosted/public_suite.py +74 -0
  86. sphynx/hosted/registry.py +97 -0
  87. sphynx/hosted/revisions.py +126 -0
  88. sphynx/hosted/sandbox.py +105 -0
  89. sphynx/hosted/submission.py +287 -0
  90. sphynx/hosted_workflow.py +451 -0
  91. sphynx/http_transport.py +86 -0
  92. sphynx/integrated_rag.py +352 -0
  93. sphynx/integrated_workflow.py +552 -0
  94. sphynx/integration_contracts.py +474 -0
  95. sphynx/invocation.py +791 -0
  96. sphynx/invocation_candidates.py +392 -0
  97. sphynx/invocation_catalog_data.py +40 -0
  98. sphynx/learned_catalog_data.py +30 -0
  99. sphynx/learned_rerank.py +267 -0
  100. sphynx/legacy_full_rag.py +67 -0
  101. sphynx/lifecycle.py +885 -0
  102. sphynx/lifecycle_workflow.py +490 -0
  103. sphynx/metric_views.py +474 -0
  104. sphynx/metrics.py +282 -0
  105. sphynx/model_adapters.py +438 -0
  106. sphynx/model_catalog_data.py +30 -0
  107. sphynx/official_benchmarks.py +385 -0
  108. sphynx/packs.py +258 -0
  109. sphynx/pipeline.py +313 -0
  110. sphynx/pipeline_workflow.py +551 -0
  111. sphynx/pr_evaluations.py +560 -0
  112. sphynx/progress.py +89 -0
  113. sphynx/public_bundle.py +173 -0
  114. sphynx/public_eval.py +62 -0
  115. sphynx/public_rerank.py +355 -0
  116. sphynx/py.typed +0 -0
  117. sphynx/pytest_plugin.py +62 -0
  118. sphynx/question_robustness.py +437 -0
  119. sphynx/rag.py +329 -0
  120. sphynx/rag_diagnostics.py +309 -0
  121. sphynx/rag_papers/__init__.py +29 -0
  122. sphynx/rag_papers/__main__.py +45 -0
  123. sphynx/rag_papers/candidates.py +19 -0
  124. sphynx/rag_papers/contracts.py +282 -0
  125. sphynx/rag_papers/datasets.py +291 -0
  126. sphynx/rag_papers/experiments.py +116 -0
  127. sphynx/rag_papers/judges.py +308 -0
  128. sphynx/rag_papers/registry.py +33 -0
  129. sphynx/rag_papers/runner.py +176 -0
  130. sphynx/rag_papers/sable.py +135 -0
  131. sphynx/rag_papers/schemas.py +12 -0
  132. sphynx/rag_papers/scoring.py +249 -0
  133. sphynx/rag_papers/semantic.py +288 -0
  134. sphynx/rag_papers/trec.py +80 -0
  135. sphynx/recorded_scores.py +352 -0
  136. sphynx/records.py +124 -0
  137. sphynx/reflex_traces.py +193 -0
  138. sphynx/report_rendering.py +71 -0
  139. sphynx/rerank_family_contracts.py +150 -0
  140. sphynx/rerank_workflow.py +424 -0
  141. sphynx/research_candidate.py +190 -0
  142. sphynx/results.py +852 -0
  143. sphynx/retrieval.py +750 -0
  144. sphynx/retrieval_candidates.py +295 -0
  145. sphynx/retrieval_catalog_data.py +19 -0
  146. sphynx/retrieval_recordings.py +125 -0
  147. sphynx/reviewer_audit.py +266 -0
  148. sphynx/routing_policies.py +375 -0
  149. sphynx/runner.py +544 -0
  150. sphynx/sable_rerank.py +489 -0
  151. sphynx/score_reliability.py +393 -0
  152. sphynx/speech_benchmarks.py +204 -0
  153. sphynx/speech_campaigns.py +378 -0
  154. sphynx/speech_complete.py +236 -0
  155. sphynx/speech_contracts.py +225 -0
  156. sphynx/speech_evidence.py +429 -0
  157. sphynx/speech_listening.py +115 -0
  158. sphynx/speech_multilingual.py +71 -0
  159. sphynx/speech_tts.py +479 -0
  160. sphynx/sql/001_sync_v1.sql +30 -0
  161. sphynx/stage_contracts.py +199 -0
  162. sphynx/statistical_catalog_data.py +53 -0
  163. sphynx/statistical_rerank.py +232 -0
  164. sphynx/sync.py +797 -0
  165. sphynx/workbench.py +378 -0
  166. sphynx/workbench_catalog_data.py +13 -0
  167. sphynx/workbench_tracks.py +78 -0
  168. sphynx/workflow_async.py +197 -0
  169. sphynx/workflow_contracts.py +594 -0
  170. sphynx/workflow_decisions.py +211 -0
  171. sphynx/workflow_defaults.py +16 -0
  172. sphynx/workflow_extensions.py +212 -0
  173. sphynx/workflow_registry.py +513 -0
  174. sphynx/workflows.py +792 -0
sphynx/dataset_v3.py ADDED
@@ -0,0 +1,999 @@
1
+ """Sphynx-owned port of the source contracts; no sibling runtime dependency."""
2
+ from __future__ import annotations
3
+ from typing import Any
4
+ JsonValue = Any
5
+ """Stable public types and enumerations for the offline RAG DatasetV3 contract."""
6
+ from dataclasses import dataclass, field
7
+ from typing import Literal
8
+ DATASET_V3_SCHEMA_VERSION = 'sable.rag.dataset.v3'
9
+ CUSTOMER_REGISTRY_SCHEMA_VERSION = 'sable.rag.customer-registry.v1'
10
+ Partition = Literal['calibration', 'development', 'protected_holdout', 'validation']
11
+ Answerability = Literal['answerable', 'unsupported']
12
+ ExpectedBehavior = Literal['answer', 'abstain', 'no_rag']
13
+ Severity = Literal['critical', 'high', 'low', 'medium']
14
+ Requirement = Literal['optional', 'required']
15
+ JudgmentStatus = Literal['judged', 'unknown']
16
+ Authority = Literal['primary', 'secondary', 'unknown']
17
+ Currentness = Literal['current', 'stale', 'superseded', 'unknown']
18
+ HardNegativeType = Literal['authority_conflict', 'lexical_overlap', 'near_duplicate', 'negation_flip', 'scope_mismatch', 'sibling_section', 'stale', 'unsupported_distractor']
19
+ PARTITIONS = frozenset({'calibration', 'development', 'protected_holdout', 'validation'})
20
+ ANSWERABILITY = frozenset({'answerable', 'unsupported'})
21
+ EXPECTED_BEHAVIORS = frozenset({'answer', 'abstain', 'no_rag'})
22
+ SEVERITIES = frozenset({'critical', 'high', 'low', 'medium'})
23
+ REQUIREMENTS = frozenset({'optional', 'required'})
24
+ JUDGMENT_STATUSES = frozenset({'judged', 'unknown'})
25
+ AUTHORITIES = frozenset({'primary', 'secondary', 'unknown'})
26
+ CURRENTNESS = frozenset({'current', 'stale', 'superseded', 'unknown'})
27
+ HARD_NEGATIVE_TYPES = frozenset({'authority_conflict', 'lexical_overlap', 'near_duplicate', 'negation_flip', 'scope_mismatch', 'sibling_section', 'stale', 'unsupported_distractor'})
28
+
29
+ class DatasetV3Error(ValueError):
30
+ """Raised when DatasetV3 violates a structural or truth invariant."""
31
+
32
+ class CustomerRegistryError(ValueError):
33
+ """Raised when the customer registry is incomplete or inconsistent."""
34
+
35
+ @dataclass(frozen=True, slots=True)
36
+ class TaskIdentity:
37
+ """Minimal task identity used by split and report routing."""
38
+ task_id: str
39
+ customer_id: str
40
+ partition: Partition
41
+ cluster_id: str
42
+ def __post_init__(self) -> None:
43
+ v3_id(self.task_id, 'task ID')
44
+ v3_id(self.customer_id, 'customer ID')
45
+ v3_enum(self.partition, PARTITIONS, 'partition')
46
+ v3_id(self.cluster_id, 'cluster ID')
47
+
48
+
49
+ @dataclass(frozen=True, slots=True)
50
+ class DatasetV3:
51
+ """Validated normalized dataset and its immutable task identities."""
52
+ _value_bytes: bytes
53
+ dataset_id: str
54
+ version: str
55
+ default_relevance_threshold: int
56
+ tasks: tuple[TaskIdentity, ...]
57
+
58
+ def __post_init__(self) -> None:
59
+ from ._track_capture import strict_json
60
+ if not isinstance(self._value_bytes, (bytes, bytearray, memoryview)):
61
+ raise DatasetV3Error('dataset content must be bytes')
62
+ encoded = bytes(self._value_bytes)
63
+ try:
64
+ expected = _validate_dataset_v3_fields(strict_json(encoded))
65
+ except ValueError as error:
66
+ raise DatasetV3Error('invalid committed dataset content') from error
67
+ if not isinstance(self.tasks, (list, tuple)) or any(type(item) is not TaskIdentity for item in self.tasks):
68
+ raise DatasetV3Error('dataset tasks must be task identities')
69
+ tasks = tuple(self.tasks)
70
+ v3_integer(self.default_relevance_threshold, 'default relevance threshold', minimum=1)
71
+ if (self.dataset_id, self.version, self.default_relevance_threshold, tasks) != expected[1:]:
72
+ raise DatasetV3Error('exposed dataset records differ from committed dataset bytes')
73
+ object.__setattr__(self, '_value_bytes', encoded)
74
+ object.__setattr__(self, 'tasks', tasks)
75
+
76
+ @property
77
+ def value(self):
78
+ return json.loads(self._value_bytes)
79
+
80
+ @dataclass(frozen=True, slots=True)
81
+ class CustomerRecord:
82
+ """One privacy-safe customer registry record."""
83
+ customer_id: str
84
+ display_name: str
85
+ pack_status: Literal['available', 'missing']
86
+ pack_path: str | None
87
+ pack_manifest_sha256: str | None
88
+ blockers: tuple[str, ...]
89
+ def __post_init__(self) -> None:
90
+ registry_id(self.customer_id, 'customer ID')
91
+ registry_text(self.display_name, 'display name')
92
+ if not isinstance(self.blockers, (list, tuple)):
93
+ raise CustomerRegistryError('blockers must be a list or tuple')
94
+ _, _, _, blockers = registry_validate_pack({'status': self.pack_status, 'path': self.pack_path, 'manifest_sha256': self.pack_manifest_sha256, 'blockers': list(self.blockers)}, self.customer_id, 'customer pack')
95
+ object.__setattr__(self, 'blockers', blockers)
96
+
97
+
98
+ @dataclass(frozen=True, slots=True, init=False)
99
+ class CustomerRegistry:
100
+ """Validated registry with detached reads of an immutable source snapshot."""
101
+ _value_bytes: bytes
102
+ customers: tuple[CustomerRecord, ...]
103
+
104
+ def __init__(self, value=None, customers=(), *, _value_bytes=None):
105
+ from ._track_capture import strict_json
106
+ if _value_bytes is not None:
107
+ if not isinstance(_value_bytes, (bytes, bytearray, memoryview)):
108
+ raise CustomerRegistryError('committed registry content must be bytes')
109
+ encoded = bytes(_value_bytes)
110
+ try:
111
+ committed = strict_json(encoded)
112
+ if value is not None:
113
+ _validate_customer_registry_fields(value)
114
+ supplied_json = json.dumps(registry_clone(value), allow_nan=False, ensure_ascii=False, sort_keys=True)
115
+ committed_json = json.dumps(committed, allow_nan=False, ensure_ascii=False, sort_keys=True)
116
+ if supplied_json != committed_json:
117
+ raise CustomerRegistryError('registry mapping differs from committed bytes')
118
+ value = committed
119
+ except ValueError as error:
120
+ raise CustomerRegistryError('invalid committed registry JSON') from error
121
+ else:
122
+ encoded = json.dumps(registry_clone(value), allow_nan=False, ensure_ascii=False, sort_keys=True).encode('utf-8')
123
+ value = strict_json(encoded)
124
+ _, expected = _validate_customer_registry_fields(value)
125
+ if not isinstance(customers, (list, tuple)) or any(type(item) is not CustomerRecord for item in customers):
126
+ raise CustomerRegistryError('customers must be customer records')
127
+ records = tuple(customers)
128
+ if records != expected:
129
+ raise CustomerRegistryError('exposed customer records differ from committed registry bytes')
130
+ object.__setattr__(self, '_value_bytes', encoded)
131
+ object.__setattr__(self, 'customers', records)
132
+
133
+ @property
134
+ def value(self):
135
+ return json.loads(self._value_bytes)
136
+
137
+ @property
138
+ def customer_ids(self) -> tuple[str, ...]:
139
+ return tuple(customer.customer_id for customer in self.customers)
140
+
141
+ @dataclass(frozen=True, slots=True)
142
+ class PrivacyFinding:
143
+ """A redacted prohibited-data finding; the matched value is never retained."""
144
+ category: str
145
+ path: str
146
+ value_sha256: str
147
+
148
+ @dataclass(frozen=True, slots=True)
149
+ class ReadinessBlocker:
150
+ """One named reason an evaluation or level cannot be scored."""
151
+ code: str
152
+ scope: Literal['data', 'level1', 'level2', 'level3']
153
+ detail: str
154
+
155
+ @dataclass(frozen=True, slots=True)
156
+ class ReadinessReport:
157
+ """Objective data maturity plus execution blockers that do not fabricate metrics."""
158
+ status: Literal['blocked', 'decision_grade', 'draft', 'pilot_ready']
159
+ independent_clusters: int
160
+ double_labeled_cluster_rate: float
161
+ blockers: tuple[ReadinessBlocker, ...]
162
+ coverage: dict[str, bool]
163
+ coverage_counts: dict[str, int] = field(default_factory=dict)
164
+
165
+ @property
166
+ def quality_metrics_allowed(self) -> bool:
167
+ return not any((blocker.scope == 'data' for blocker in self.blockers))
168
+
169
+ @dataclass(frozen=True, slots=True)
170
+ class PackIntake:
171
+ """Validated, content-bound pack intake result without raw customer content."""
172
+ customer_id: str
173
+ pack_id: str
174
+ manifest_sha256: str
175
+ dataset_sha256: str
176
+ dataset: DatasetV3
177
+ readiness: ReadinessReport
178
+
179
+ """Strict source-grounded DatasetV3 validation and split-leakage enforcement."""
180
+ import hashlib
181
+ import json
182
+ import math
183
+ import re
184
+ from collections.abc import Mapping
185
+ from datetime import datetime
186
+ from pathlib import Path
187
+ from typing import Any, cast
188
+ v3_ID = re.compile('[a-z0-9][a-z0-9._:-]{0,127}')
189
+ v3_SHA256 = re.compile('[0-9a-f]{64}')
190
+
191
+ def v3_object(value: object, context: str) -> Mapping[str, object]:
192
+ if not isinstance(value, Mapping) or any((not isinstance(key, str) for key in value)):
193
+ raise DatasetV3Error(f'{context} must be an object with string keys')
194
+ return cast('Mapping[str, object]', value)
195
+
196
+ def v3_exact(value: Mapping[str, object], fields: frozenset[str], context: str) -> None:
197
+ missing = sorted(fields - value.keys())
198
+ extra = sorted(value.keys() - fields)
199
+ if missing or extra:
200
+ raise DatasetV3Error(f'{context} keys differ; missing={missing}, extra={extra}')
201
+
202
+ def v3_array(value: object, context: str, *, nonempty: bool=False) -> list[object]:
203
+ if not isinstance(value, list) or (nonempty and (not value)):
204
+ qualifier = 'non-empty ' if nonempty else ''
205
+ raise DatasetV3Error(f'{context} must be a {qualifier}array')
206
+ return cast('list[object]', value)
207
+
208
+ def v3_text(value: object, context: str) -> str:
209
+ if not isinstance(value, str) or not value.strip():
210
+ raise DatasetV3Error(f'{context} must be non-empty text')
211
+ return value
212
+
213
+ def v3_optional_text(value: object, context: str) -> str | None:
214
+ return None if value is None else v3_text(value, context)
215
+
216
+ def v3_id(value: object, context: str) -> str:
217
+ result = v3_text(value, context)
218
+ if v3_ID.fullmatch(result) is None:
219
+ raise DatasetV3Error(f'{context} must be a privacy-safe stable identifier')
220
+ return result
221
+
222
+ def v3_digest(value: object, context: str) -> str:
223
+ result = v3_text(value, context)
224
+ if v3_SHA256.fullmatch(result) is None:
225
+ raise DatasetV3Error(f'{context} must be a lowercase SHA-256 digest')
226
+ return result
227
+
228
+ def v3_enum(value: object, choices: frozenset[str], context: str) -> str:
229
+ result = v3_text(value, context)
230
+ if result not in choices:
231
+ raise DatasetV3Error(f"{context} must be one of {', '.join(sorted(choices))}")
232
+ return result
233
+
234
+ def v3_boolean(value: object, context: str) -> bool:
235
+ if not isinstance(value, bool):
236
+ raise DatasetV3Error(f'{context} must be boolean')
237
+ return value
238
+
239
+ def v3_integer(value: object, context: str, *, minimum: int=0) -> int:
240
+ if isinstance(value, bool) or not isinstance(value, int) or value < minimum:
241
+ raise DatasetV3Error(f'{context} must be an integer >= {minimum}')
242
+ return value
243
+
244
+ def v3_finite(value: object, context: str) -> float:
245
+ if isinstance(value, bool) or not isinstance(value, int | float):
246
+ raise DatasetV3Error(f'{context} must be numeric')
247
+ result = float(value)
248
+ if not math.isfinite(result):
249
+ raise DatasetV3Error(f'{context} must be finite')
250
+ return result
251
+
252
+ def v3_timestamp(value: object, context: str) -> str:
253
+ result = v3_text(value, context)
254
+ try:
255
+ parsed = datetime.fromisoformat(result.replace('Z', '+00:00'))
256
+ except ValueError as error:
257
+ raise DatasetV3Error(f'{context} must be an ISO-8601 timestamp') from error
258
+ if parsed.tzinfo is None:
259
+ raise DatasetV3Error(f'{context} must include a timezone')
260
+ return result
261
+
262
+ def v3_strings(value: object, context: str, *, nonempty: bool=False, identifiers: bool=False) -> tuple[str, ...]:
263
+ values = v3_array(value, context, nonempty=nonempty)
264
+ converted = tuple((v3_id(item, f'{context}[{index}]') if identifiers else v3_text(item, f'{context}[{index}]') for index, item in enumerate(values)))
265
+ if len(converted) != len(set(converted)):
266
+ raise DatasetV3Error(f'{context} must contain unique values')
267
+ return converted
268
+
269
+ def v3_clone_json(value: object) -> dict[str, JsonValue]:
270
+ try:
271
+ encoded = json.dumps(value, allow_nan=False, ensure_ascii=False, sort_keys=True)
272
+ loaded: Any = json.loads(encoded)
273
+ except (TypeError, ValueError) as error:
274
+ raise DatasetV3Error('dataset must contain only finite JSON values') from error
275
+ if not isinstance(loaded, dict):
276
+ raise DatasetV3Error('dataset must be an object')
277
+ return cast('dict[str, JsonValue]', loaded)
278
+
279
+ def v3_validate_versions(raw: object, context: str) -> None:
280
+ value = v3_object(raw, context)
281
+ v3_exact(value, frozenset({'corpus', 'index', 'chunking', 'embedding'}), context)
282
+ for field in ('corpus', 'index', 'chunking', 'embedding'):
283
+ v3_text(value[field], f'{context}.{field}')
284
+
285
+ def v3_validate_context(raw: object, context: str) -> None:
286
+ for index, turn_raw in enumerate(v3_array(raw, context)):
287
+ turn_context = f'{context}[{index}]'
288
+ turn = v3_object(turn_raw, turn_context)
289
+ v3_exact(turn, frozenset({'role', 'text'}), turn_context)
290
+ v3_enum(turn['role'], frozenset({'assistant', 'user'}), f'{turn_context}.role')
291
+ v3_text(turn['text'], f'{turn_context}.text')
292
+
293
+ def v3_validate_leakage_keys(raw: object, context: str) -> dict[str, tuple[str, ...]]:
294
+ value = v3_object(raw, context)
295
+ fields = frozenset({'source_family_ids', 'document_revision_family_ids', 'conversation_case_id', 'query_family_id', 'paraphrase_family_id', 'near_duplicate_family_ids'})
296
+ v3_exact(value, fields, context)
297
+ return {'source_family': v3_strings(value['source_family_ids'], f'{context}.source_family_ids', nonempty=True, identifiers=True), 'document_revision_family': v3_strings(value['document_revision_family_ids'], f'{context}.document_revision_family_ids', nonempty=True, identifiers=True), 'conversation_case': (v3_id(value['conversation_case_id'], f'{context}.conversation_case_id'),), 'query_family': (v3_id(value['query_family_id'], f'{context}.query_family_id'),), 'paraphrase_family': (v3_id(value['paraphrase_family_id'], f'{context}.paraphrase_family_id'),), 'near_duplicate_family': v3_strings(value['near_duplicate_family_ids'], f'{context}.near_duplicate_family_ids', identifiers=True)}
298
+
299
+ def v3_validate_source_spans(raw: object, context: str, leakage_keys: dict[str, tuple[str, ...]]) -> set[str]:
300
+ spans = v3_array(raw, context, nonempty=True)
301
+ span_ids: set[str] = set()
302
+ for index, span_raw in enumerate(spans):
303
+ span_context = f'{context}[{index}]'
304
+ span = v3_object(span_raw, span_context)
305
+ v3_exact(span, frozenset({'source_span_id', 'document_id', 'source_family_id', 'document_revision_family_id', 'content', 'content_sha256', 'start', 'end', 'exact_text', 'authority', 'currentness', 'scope_tags'}), span_context)
306
+ span_id = v3_id(span['source_span_id'], f'{span_context}.source_span_id')
307
+ if span_id in span_ids:
308
+ raise DatasetV3Error(f'{context} contains duplicate source_span_id {span_id}')
309
+ span_ids.add(span_id)
310
+ v3_id(span['document_id'], f'{span_context}.document_id')
311
+ source_family = v3_id(span['source_family_id'], f'{span_context}.source_family_id')
312
+ revision_family = v3_id(span['document_revision_family_id'], f'{span_context}.document_revision_family_id')
313
+ if source_family not in leakage_keys['source_family']:
314
+ raise DatasetV3Error(f'{span_context}.source_family_id is absent from leakage_keys')
315
+ if revision_family not in leakage_keys['document_revision_family']:
316
+ raise DatasetV3Error(f'{span_context}.document_revision_family_id is absent from leakage_keys')
317
+ content = v3_text(span['content'], f'{span_context}.content')
318
+ expected_hash = hashlib.sha256(content.encode('utf-8')).hexdigest()
319
+ if v3_digest(span['content_sha256'], f'{span_context}.content_sha256') != expected_hash:
320
+ raise DatasetV3Error(f'{span_context}.content_sha256 does not bind content')
321
+ start = v3_integer(span['start'], f'{span_context}.start')
322
+ end = v3_integer(span['end'], f'{span_context}.end', minimum=1)
323
+ exact_text = v3_text(span['exact_text'], f'{span_context}.exact_text')
324
+ if end <= start or end > len(content) or content[start:end] != exact_text:
325
+ raise DatasetV3Error(f'{span_context} source-span bounds do not match exact_text')
326
+ v3_enum(span['authority'], AUTHORITIES, f'{span_context}.authority')
327
+ v3_enum(span['currentness'], CURRENTNESS, f'{span_context}.currentness')
328
+ v3_strings(span['scope_tags'], f'{span_context}.scope_tags', identifiers=True)
329
+ return span_ids
330
+
331
+ def v3_validate_judgments(raw: object, context: str) -> tuple[int, ...]:
332
+ judgments = v3_array(raw, context)
333
+ annotators: set[str] = set()
334
+ grades: list[int] = []
335
+ for index, judgment_raw in enumerate(judgments):
336
+ judgment_context = f'{context}[{index}]'
337
+ judgment = v3_object(judgment_raw, judgment_context)
338
+ v3_exact(judgment, frozenset({'annotator_id', 'grade', 'rationale', 'created_at'}), judgment_context)
339
+ annotator = v3_id(judgment['annotator_id'], f'{judgment_context}.annotator_id')
340
+ if annotator in annotators:
341
+ raise DatasetV3Error(f'{context} contains a duplicate annotator')
342
+ annotators.add(annotator)
343
+ grade = v3_integer(judgment['grade'], f'{judgment_context}.grade')
344
+ if grade > 3:
345
+ raise DatasetV3Error(f'{judgment_context}.grade must be a qrel from 0 through 3')
346
+ grades.append(grade)
347
+ v3_text(judgment['rationale'], f'{judgment_context}.rationale')
348
+ v3_timestamp(judgment['created_at'], f'{judgment_context}.created_at')
349
+ return tuple(grades)
350
+
351
+ def v3_validate_adjudication(raw: object, context: str, qrel: int) -> None:
352
+ value = v3_object(raw, context)
353
+ v3_exact(value, frozenset({'adjudicator_id', 'grade', 'rationale'}), context)
354
+ v3_id(value['adjudicator_id'], f'{context}.adjudicator_id')
355
+ if v3_integer(value['grade'], f'{context}.grade') != qrel:
356
+ raise DatasetV3Error(f'{context}.grade must equal final qrel')
357
+ v3_text(value['rationale'], f'{context}.rationale')
358
+
359
+ def v3_validate_candidates(raw: object, context: str, source_span_ids: set[str]) -> tuple[set[str], dict[str, set[str]], dict[str, set[str]], tuple[int | None, ...]]:
360
+ candidates = v3_array(raw, context, nonempty=True)
361
+ candidate_ids: set[str] = set()
362
+ group_links: dict[str, set[str]] = {}
363
+ span_links: dict[str, set[str]] = {}
364
+ qrels: list[int | None] = []
365
+ for index, candidate_raw in enumerate(candidates):
366
+ candidate_context = f'{context}[{index}]'
367
+ candidate = v3_object(candidate_raw, candidate_context)
368
+ v3_exact(candidate, frozenset({'candidate_id', 'document_id', 'chunk_id', 'input', 'input_sha256', 'dense_rank', 'dense_score', 'judgment_status', 'qrel', 'qrel_rationale', 'judgment_blocker', 'judgments', 'adjudication', 'evidence_group_ids', 'source_span_ids', 'authority', 'currentness', 'scope_tags', 'hard_negative_types', 'duplicate_of_candidate_id'}), candidate_context)
369
+ candidate_id = v3_id(candidate['candidate_id'], f'{candidate_context}.candidate_id')
370
+ if candidate_id in candidate_ids:
371
+ raise DatasetV3Error(f'{context} contains duplicate candidate_id {candidate_id}')
372
+ candidate_ids.add(candidate_id)
373
+ v3_id(candidate['document_id'], f'{candidate_context}.document_id')
374
+ v3_id(candidate['chunk_id'], f'{candidate_context}.chunk_id')
375
+ candidate_input = v3_text(candidate['input'], f'{candidate_context}.input')
376
+ expected_hash = hashlib.sha256(candidate_input.encode('utf-8')).hexdigest()
377
+ if v3_digest(candidate['input_sha256'], f'{candidate_context}.input_sha256') != expected_hash:
378
+ raise DatasetV3Error(f'{candidate_context}.input_sha256 does not bind input')
379
+ if v3_integer(candidate['dense_rank'], f'{candidate_context}.dense_rank', minimum=1) != index + 1:
380
+ raise DatasetV3Error(f'{candidate_context}.dense_rank must equal {index + 1}')
381
+ v3_finite(candidate['dense_score'], f'{candidate_context}.dense_score')
382
+ status = v3_enum(candidate['judgment_status'], JUDGMENT_STATUSES, f'{candidate_context}.judgment_status')
383
+ grades = v3_validate_judgments(candidate['judgments'], f'{candidate_context}.judgments')
384
+ if status == 'judged':
385
+ qrel = v3_integer(candidate['qrel'], f'{candidate_context}.qrel')
386
+ if qrel > 3:
387
+ raise DatasetV3Error(f'{candidate_context}.qrel must be from 0 through 3')
388
+ v3_text(candidate['qrel_rationale'], f'{candidate_context}.qrel_rationale')
389
+ if candidate['judgment_blocker'] is not None or not grades:
390
+ raise DatasetV3Error(f'{candidate_context} judged qrel needs labels and cannot have a blocker')
391
+ if len(set(grades)) > 1 and candidate['adjudication'] is None:
392
+ raise DatasetV3Error(f'{candidate_context} label disagreement needs adjudication')
393
+ if candidate['adjudication'] is not None:
394
+ v3_validate_adjudication(candidate['adjudication'], f'{candidate_context}.adjudication', qrel)
395
+ elif any((grade != qrel for grade in grades)):
396
+ raise DatasetV3Error(f'{candidate_context}.qrel must equal its judgment')
397
+ qrels.append(qrel)
398
+ else:
399
+ if candidate['qrel'] is not None or candidate['qrel_rationale'] is not None or candidate['adjudication'] is not None or grades:
400
+ raise DatasetV3Error(f'{candidate_context} unknown qrel cannot imply a grade')
401
+ v3_text(candidate['judgment_blocker'], f'{candidate_context}.judgment_blocker')
402
+ qrels.append(None)
403
+ groups = set(v3_strings(candidate['evidence_group_ids'], f'{candidate_context}.evidence_group_ids', identifiers=True))
404
+ spans = set(v3_strings(candidate['source_span_ids'], f'{candidate_context}.source_span_ids', identifiers=True))
405
+ unknown_spans = spans - source_span_ids
406
+ if unknown_spans:
407
+ raise DatasetV3Error(f'{candidate_context} references unknown source spans')
408
+ group_links[candidate_id] = groups
409
+ span_links[candidate_id] = spans
410
+ v3_enum(candidate['authority'], AUTHORITIES, f'{candidate_context}.authority')
411
+ v3_enum(candidate['currentness'], CURRENTNESS, f'{candidate_context}.currentness')
412
+ v3_strings(candidate['scope_tags'], f'{candidate_context}.scope_tags', identifiers=True)
413
+ hard_negatives = v3_strings(candidate['hard_negative_types'], f'{candidate_context}.hard_negative_types', identifiers=True)
414
+ if any((item not in HARD_NEGATIVE_TYPES for item in hard_negatives)):
415
+ raise DatasetV3Error(f'{candidate_context}.hard_negative_types is unsupported')
416
+ duplicate = v3_optional_text(candidate['duplicate_of_candidate_id'], f'{candidate_context}.duplicate_of_candidate_id')
417
+ if duplicate == candidate_id:
418
+ raise DatasetV3Error(f'{candidate_context} cannot duplicate itself')
419
+ for index, candidate_raw in enumerate(candidates):
420
+ candidate = v3_object(candidate_raw, f'{context}[{index}]')
421
+ raw_duplicate = candidate['duplicate_of_candidate_id']
422
+ if raw_duplicate is not None and raw_duplicate not in candidate_ids:
423
+ raise DatasetV3Error(f'{context}[{index}] duplicate target is not in the frozen pool')
424
+ return (candidate_ids, group_links, span_links, tuple(qrels))
425
+
426
+ def v3_validate_evidence_groups(raw: object, context: str, candidate_ids: set[str], source_span_ids: set[str], candidate_group_links: dict[str, set[str]], candidate_span_links: dict[str, set[str]]) -> tuple[int, set[str]]:
427
+ groups = v3_array(raw, context)
428
+ group_ids: set[str] = set()
429
+ required_count = 0
430
+ for index, group_raw in enumerate(groups):
431
+ group_context = f'{context}[{index}]'
432
+ group = v3_object(group_raw, group_context)
433
+ v3_exact(group, frozenset({'evidence_group_id', 'description', 'requirement', 'weight', 'acceptable_candidate_ids', 'source_span_ids'}), group_context)
434
+ group_id = v3_id(group['evidence_group_id'], f'{group_context}.evidence_group_id')
435
+ if group_id in group_ids:
436
+ raise DatasetV3Error(f'{context} contains duplicate evidence_group_id {group_id}')
437
+ group_ids.add(group_id)
438
+ v3_text(group['description'], f'{group_context}.description')
439
+ requirement = v3_enum(group['requirement'], REQUIREMENTS, f'{group_context}.requirement')
440
+ required_count += int(requirement == 'required')
441
+ if v3_finite(group['weight'], f'{group_context}.weight') <= 0:
442
+ raise DatasetV3Error(f'{group_context}.weight must be positive')
443
+ acceptable = set(v3_strings(group['acceptable_candidate_ids'], f'{group_context}.acceptable_candidate_ids', nonempty=True, identifiers=True))
444
+ spans = set(v3_strings(group['source_span_ids'], f'{group_context}.source_span_ids', nonempty=True, identifiers=True))
445
+ if not acceptable <= candidate_ids or not spans <= source_span_ids:
446
+ raise DatasetV3Error(f'{group_context} references unknown candidate or source span')
447
+ for candidate_id in acceptable:
448
+ if group_id not in candidate_group_links[candidate_id]:
449
+ raise DatasetV3Error(f'{group_context} lacks reciprocal candidate evidence link')
450
+ if not spans & candidate_span_links[candidate_id]:
451
+ raise DatasetV3Error(f'{group_context} candidate lacks a supporting source span')
452
+ linked_groups = set().union(*candidate_group_links.values()) if candidate_group_links else set()
453
+ if linked_groups != group_ids:
454
+ raise DatasetV3Error(f'{context} candidate/group evidence links are not reciprocal')
455
+ return (required_count, group_ids)
456
+
457
+ def v3_validate_task(raw: object, index: int) -> tuple[TaskIdentity, dict[str, tuple[str, ...]]]:
458
+ context = f'tasks[{index}]'
459
+ task = v3_object(raw, context)
460
+ v3_exact(task, frozenset({'task_id', 'customer_id', 'query_id', 'cluster_id', 'partition', 'query', 'conversation_context', 'should_call_rag', 'answerability', 'expected_behavior', 'evaluation_time', 'query_categories', 'business_severity', 'versions', 'leakage_keys', 'source_spans', 'candidates', 'evidence_groups'}), context)
461
+ task_id = v3_id(task['task_id'], f'{context}.task_id')
462
+ customer_id = v3_id(task['customer_id'], f'{context}.customer_id')
463
+ v3_id(task['query_id'], f'{context}.query_id')
464
+ cluster_id = v3_id(task['cluster_id'], f'{context}.cluster_id')
465
+ partition = cast('Partition', v3_enum(task['partition'], PARTITIONS, f'{context}.partition'))
466
+ v3_text(task['query'], f'{context}.query')
467
+ v3_validate_context(task['conversation_context'], f'{context}.conversation_context')
468
+ should_call = v3_boolean(task['should_call_rag'], f'{context}.should_call_rag')
469
+ answerability = v3_enum(task['answerability'], ANSWERABILITY, f'{context}.answerability')
470
+ behavior = v3_enum(task['expected_behavior'], EXPECTED_BEHAVIORS, f'{context}.expected_behavior')
471
+ if answerability == 'answerable' and (not should_call or behavior != 'answer'):
472
+ raise DatasetV3Error(f'{context} answerable tasks must call RAG and answer')
473
+ if answerability == 'unsupported' and should_call != (behavior == 'abstain'):
474
+ raise DatasetV3Error(f'{context} unsupported tasks must abstain after RAG or use no_rag without a call')
475
+ if answerability == 'unsupported' and behavior not in {'abstain', 'no_rag'}:
476
+ raise DatasetV3Error(f'{context} unsupported task behavior is inconsistent')
477
+ v3_timestamp(task['evaluation_time'], f'{context}.evaluation_time')
478
+ v3_strings(task['query_categories'], f'{context}.query_categories', nonempty=True, identifiers=True)
479
+ v3_enum(task['business_severity'], SEVERITIES, f'{context}.business_severity')
480
+ v3_validate_versions(task['versions'], f'{context}.versions')
481
+ leakage_keys = v3_validate_leakage_keys(task['leakage_keys'], f'{context}.leakage_keys')
482
+ source_span_ids = v3_validate_source_spans(task['source_spans'], f'{context}.source_spans', leakage_keys)
483
+ candidate_ids, group_links, span_links, qrels = v3_validate_candidates(task['candidates'], f'{context}.candidates', source_span_ids)
484
+ required_count, group_ids = v3_validate_evidence_groups(task['evidence_groups'], f'{context}.evidence_groups', candidate_ids, source_span_ids, group_links, span_links)
485
+ if answerability == 'answerable' and (required_count == 0 or not group_ids):
486
+ raise DatasetV3Error(f'{context} answerable task needs required evidence')
487
+ if answerability == 'unsupported' and (group_ids or any((qrel not in {0, None} for qrel in qrels))):
488
+ raise DatasetV3Error(f'{context} unsupported task cannot claim relevant evidence')
489
+ return (TaskIdentity(task_id, customer_id, partition, cluster_id), leakage_keys)
490
+
491
+ def v3_validate_no_leakage(identities: tuple[TaskIdentity, ...], task_keys: tuple[dict[str, tuple[str, ...]], ...]) -> None:
492
+ owners: dict[tuple[str, str], Partition] = {}
493
+ for identity, leakage_keys in zip(identities, task_keys, strict=True):
494
+ dimensions = {'cluster': (identity.cluster_id,), **leakage_keys}
495
+ for dimension, keys in dimensions.items():
496
+ for key in keys:
497
+ owner_key = (dimension, key)
498
+ previous = owners.get(owner_key)
499
+ if previous is not None and previous != identity.partition:
500
+ raise DatasetV3Error(f"cross-partition leakage for {dimension} '{key}': {previous} vs {identity.partition}")
501
+ owners[owner_key] = identity.partition
502
+
503
+ def _validate_dataset_v3_fields(raw: object):
504
+ """Validate a complete dataset and reject every discoverable truth or split ambiguity."""
505
+ root = v3_object(raw, 'dataset')
506
+ v3_exact(root, frozenset({'schema_version', 'dataset_id', 'version', 'default_relevance_threshold', 'tasks'}), 'dataset')
507
+ if v3_text(root['schema_version'], 'dataset.schema_version') != DATASET_V3_SCHEMA_VERSION:
508
+ raise DatasetV3Error(f'dataset.schema_version must be {DATASET_V3_SCHEMA_VERSION}')
509
+ dataset_id = v3_id(root['dataset_id'], 'dataset.dataset_id')
510
+ version = v3_text(root['version'], 'dataset.version')
511
+ threshold = v3_integer(root['default_relevance_threshold'], 'dataset.default_relevance_threshold', minimum=1)
512
+ if threshold > 3:
513
+ raise DatasetV3Error('dataset.default_relevance_threshold must be from 1 through 3')
514
+ tasks_raw = v3_array(root['tasks'], 'dataset.tasks', nonempty=True)
515
+ validated = tuple((v3_validate_task(task, index) for index, task in enumerate(tasks_raw)))
516
+ identities = tuple((item[0] for item in validated))
517
+ if len({identity.task_id for identity in identities}) != len(identities):
518
+ raise DatasetV3Error('dataset.tasks must have unique task_id values')
519
+ task_keys = tuple((item[1] for item in validated))
520
+ v3_validate_no_leakage(identities, task_keys)
521
+ revisions = {}
522
+ for task in tasks_raw:
523
+ for span in task['source_spans']:
524
+ key = (task['customer_id'], task['versions']['corpus'], span['document_id'])
525
+ pin = (span['content_sha256'], span['document_revision_family_id'])
526
+ if key in revisions and revisions[key] != pin:
527
+ raise DatasetV3Error('document revision/content drift within corpus version')
528
+ revisions[key] = pin
529
+ return (json.dumps(v3_clone_json(root), allow_nan=False, ensure_ascii=False, sort_keys=True).encode('utf-8'), dataset_id, version, threshold, identities)
530
+
531
+ def validate_dataset_v3(raw: object) -> DatasetV3:
532
+ """Validate complete source truth and freeze its byte snapshot and identities."""
533
+ return DatasetV3(*_validate_dataset_v3_fields(raw))
534
+
535
+ def load_dataset_v3(path: Path) -> DatasetV3:
536
+ """Read and validate a local DatasetV3 file."""
537
+ try:
538
+ raw: Any = json.loads(path.read_text(encoding='utf-8'), parse_constant=v3_reject_constant)
539
+ except (OSError, json.JSONDecodeError) as error:
540
+ raise DatasetV3Error(f'cannot read DatasetV3: {error}') from error
541
+ return validate_dataset_v3(raw)
542
+
543
+ def v3_reject_constant(value: str) -> None:
544
+ raise DatasetV3Error(f'non-finite JSON constant is forbidden: {value}')
545
+
546
+ """Privacy-safe, data-driven registry for multi-customer offline RAG packs."""
547
+ import json
548
+ import re
549
+ from collections.abc import Mapping
550
+ from pathlib import Path, PurePosixPath
551
+ from typing import Any, Literal, cast
552
+ registry_ID = re.compile('[a-z0-9][a-z0-9_-]{0,63}')
553
+ registry_SHA256 = re.compile('[0-9a-f]{64}')
554
+ registry_GIT_SHA = re.compile('[0-9a-f]{40}')
555
+
556
+ def registry_object(value: object, context: str) -> Mapping[str, object]:
557
+ if not isinstance(value, Mapping) or any((not isinstance(key, str) for key in value)):
558
+ raise CustomerRegistryError(f'{context} must be an object with string keys')
559
+ return cast('Mapping[str, object]', value)
560
+
561
+ def registry_exact(value: Mapping[str, object], fields: frozenset[str], context: str) -> None:
562
+ missing = sorted(fields - value.keys())
563
+ extra = sorted(value.keys() - fields)
564
+ if missing or extra:
565
+ raise CustomerRegistryError(f'{context} keys differ; missing={missing}, extra={extra}')
566
+
567
+ def registry_text(value: object, context: str) -> str:
568
+ if not isinstance(value, str) or not value.strip():
569
+ raise CustomerRegistryError(f'{context} must be non-empty text')
570
+ return value
571
+
572
+ def registry_id(value: object, context: str) -> str:
573
+ result = registry_text(value, context)
574
+ if registry_ID.fullmatch(result) is None:
575
+ raise CustomerRegistryError(f'{context} must be a privacy-safe identifier')
576
+ return result
577
+
578
+ def registry_digest(value: object, context: str) -> str:
579
+ result = registry_text(value, context)
580
+ if registry_SHA256.fullmatch(result) is None:
581
+ raise CustomerRegistryError(f'{context} must be a lowercase SHA-256 digest')
582
+ return result
583
+
584
+ def registry_ids(value: object, context: str) -> tuple[str, ...]:
585
+ if not isinstance(value, list):
586
+ raise CustomerRegistryError(f'{context} must be an array')
587
+ result = tuple((registry_id(item, f'{context}[{index}]') for index, item in enumerate(value)))
588
+ if tuple(sorted(result)) != result or len(result) != len(set(result)):
589
+ raise CustomerRegistryError(f'{context} must be sorted and unique')
590
+ return result
591
+
592
+ def registry_blockers(value: object, context: str) -> tuple[str, ...]:
593
+ if not isinstance(value, list):
594
+ raise CustomerRegistryError(f'{context} must be an array')
595
+ result = tuple((registry_text(item, f'{context}[{index}]') for index, item in enumerate(value)))
596
+ if len(result) != len(set(result)):
597
+ raise CustomerRegistryError(f'{context} must be unique')
598
+ return result
599
+
600
+ def registry_clone(value: object) -> dict[str, JsonValue]:
601
+ try:
602
+ encoded = json.dumps(value, allow_nan=False, ensure_ascii=False, sort_keys=True)
603
+ loaded: Any = json.loads(encoded)
604
+ except (TypeError, ValueError) as error:
605
+ raise CustomerRegistryError('registry must contain only finite JSON values') from error
606
+ if not isinstance(loaded, dict):
607
+ raise CustomerRegistryError('registry must be an object')
608
+ return cast('dict[str, JsonValue]', loaded)
609
+
610
+ def registry_validate_pack(raw: object, customer_id: str, context: str) -> tuple[str, str | None, str | None, tuple[str, ...]]:
611
+ pack = registry_object(raw, context)
612
+ registry_exact(pack, frozenset({'status', 'path', 'manifest_sha256', 'blockers'}), context)
613
+ status = registry_text(pack['status'], f'{context}.status')
614
+ if status not in {'available', 'missing'}:
615
+ raise CustomerRegistryError(f'{context}.status must be available or missing')
616
+ blockers = registry_blockers(pack['blockers'], f'{context}.blockers')
617
+ if status == 'missing':
618
+ if pack['path'] is not None or pack['manifest_sha256'] is not None or (not blockers):
619
+ raise CustomerRegistryError(f'{context} missing pack needs blockers and cannot claim path or hash')
620
+ return (status, None, None, blockers)
621
+ if blockers:
622
+ raise CustomerRegistryError(f'{context} available pack cannot retain blockers')
623
+ path = registry_text(pack['path'], f'{context}.path')
624
+ pure = PurePosixPath(path)
625
+ expected = PurePosixPath('customer-packs') / customer_id / 'pack.v1.json'
626
+ if pure.is_absolute() or '..' in pure.parts or pure != expected:
627
+ raise CustomerRegistryError(f'{context}.path must equal {expected.as_posix()}')
628
+ manifest_sha256 = registry_digest(pack['manifest_sha256'], f'{context}.manifest_sha256')
629
+ return (status, path, manifest_sha256, blockers)
630
+
631
+ def _validate_customer_registry_fields(raw: object):
632
+ """Validate registry completeness without importing customer identities into code."""
633
+ root = registry_object(raw, 'customer registry')
634
+ registry_exact(root, frozenset({'schema_version', 'source_snapshot', 'supplemental_customer_ids', 'customers'}), 'customer registry')
635
+ if registry_text(root['schema_version'], 'schema_version') != CUSTOMER_REGISTRY_SCHEMA_VERSION:
636
+ raise CustomerRegistryError(f'schema_version must be {CUSTOMER_REGISTRY_SCHEMA_VERSION}')
637
+ snapshot = registry_object(root['source_snapshot'], 'source_snapshot')
638
+ registry_exact(snapshot, frozenset({'repository', 'revision', 'content_sha256', 'observed_at', 'active_customer_ids'}), 'source_snapshot')
639
+ registry_text(snapshot['repository'], 'source_snapshot.repository')
640
+ revision = registry_text(snapshot['revision'], 'source_snapshot.revision')
641
+ if registry_GIT_SHA.fullmatch(revision) is None:
642
+ raise CustomerRegistryError('source_snapshot.revision must be a full git SHA')
643
+ registry_digest(snapshot['content_sha256'], 'source_snapshot.content_sha256')
644
+ registry_text(snapshot['observed_at'], 'source_snapshot.observed_at')
645
+ active_ids = registry_ids(snapshot['active_customer_ids'], 'source_snapshot.active_customer_ids')
646
+ supplemental_ids = registry_ids(root['supplemental_customer_ids'], 'supplemental_customer_ids')
647
+ if set(active_ids) & set(supplemental_ids):
648
+ raise CustomerRegistryError('source and supplemental customer IDs must not overlap')
649
+ required_ids = set(active_ids) | set(supplemental_ids)
650
+ customers_raw = root['customers']
651
+ if not isinstance(customers_raw, list) or not customers_raw:
652
+ raise CustomerRegistryError('customers must be a non-empty array')
653
+ records: list[CustomerRecord] = []
654
+ seen_ids: set[str] = set()
655
+ for index, customer_raw in enumerate(customers_raw):
656
+ context = f'customers[{index}]'
657
+ customer = registry_object(customer_raw, context)
658
+ registry_exact(customer, frozenset({'customer_id', 'display_name', 'active', 'source', 'pack'}), context)
659
+ customer_id = registry_id(customer['customer_id'], f'{context}.customer_id')
660
+ if customer_id in seen_ids:
661
+ raise CustomerRegistryError(f'customers contains duplicate {customer_id}')
662
+ seen_ids.add(customer_id)
663
+ display_name = registry_text(customer['display_name'], f'{context}.display_name')
664
+ if customer['active'] is not True:
665
+ raise CustomerRegistryError(f'{context}.active must be true in the active registry')
666
+ source = registry_text(customer['source'], f'{context}.source')
667
+ expected_source = 'registry_snapshot' if customer_id in active_ids else 'supplemental'
668
+ if source != expected_source:
669
+ raise CustomerRegistryError(f'{context}.source must be {expected_source}')
670
+ status, path, manifest_sha256, blockers = registry_validate_pack(customer['pack'], customer_id, f'{context}.pack')
671
+ records.append(CustomerRecord(customer_id, display_name, cast("Literal['available', 'missing']", status), path, manifest_sha256, blockers))
672
+ if seen_ids != required_ids:
673
+ raise CustomerRegistryError(f'customer registry assignment differs; missing={sorted(required_ids - seen_ids)}, unexpected={sorted(seen_ids - required_ids)}')
674
+ if [record.customer_id for record in records] != sorted(seen_ids):
675
+ raise CustomerRegistryError('customers must be sorted by customer_id')
676
+ return (registry_clone(root), tuple(records))
677
+
678
+ def validate_customer_registry(raw: object) -> CustomerRegistry:
679
+ """Validate complete customer assignments and retain a detached snapshot."""
680
+ return CustomerRegistry(*_validate_customer_registry_fields(raw))
681
+
682
+ def load_customer_registry(path: Path) -> CustomerRegistry:
683
+ """Read and validate a local privacy-safe registry."""
684
+ try:
685
+ raw: Any = json.loads(path.read_text(encoding='utf-8'))
686
+ except (OSError, json.JSONDecodeError) as error:
687
+ raise CustomerRegistryError(f'cannot read customer registry: {error}') from error
688
+ return validate_customer_registry(raw)
689
+
690
+ """Content-bound, privacy-safe intake and objective customer-pack readiness."""
691
+ import hashlib
692
+ import json
693
+ import re
694
+ from collections.abc import Mapping
695
+ from pathlib import Path
696
+ from typing import Any, Literal, cast
697
+ PACK_SCHEMA_VERSION = 'sable.rag.customer-pack.v1'
698
+ PRIVACY_POLICY_VERSION = 'sable.rag.privacy.v1'
699
+ intake_ID = re.compile('[a-z0-9][a-z0-9._:-]{0,127}')
700
+ intake_SHA256 = re.compile('[0-9a-f]{64}')
701
+ intake_PROHIBITED_KEYS = frozenset({'account_id', 'api_key', 'auth_token', 'call_id', 'cookie', 'credential', 'identity_map', 'org_public_id', 'password', 'private_url', 'raw_screenshot', 'raw_transcript', 'reversible_map', 'screenshot', 'secret', 'session_id', 'transcript', 'user_id'})
702
+ intake_TEXT_PATTERNS: tuple[tuple[str, re.Pattern[str]], ...] = (('email_address', re.compile('\\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\\.[A-Z]{2,}\\b', re.I)), ('direct_platform_identifier', re.compile('\\b(?:acct|account|agt|call|org|session|usr|user)_[A-Za-z0-9]{6,}\\b')), ('credential_value', re.compile('\\b(?:ghp_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|sk-[A-Za-z0-9]{16,})\\b')), ('absolute_url', re.compile('\\b[a-z][a-z0-9+.-]{2,15}://[^\\s\\"\']+', re.I)), ('uuid_identifier', re.compile('\\b[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\\b', re.I)), ('phone_number', re.compile('(?<!\\d)(?:\\+?1[ .-]?)?\\(?\\d{3}\\)?[ .-]\\d{3}[ .-]\\d{4}(?!\\d)')))
703
+
704
+ class PackIntakeError(ValueError):
705
+ """Raised when a pack cannot be trusted or content-bound."""
706
+
707
+ class PackPrivacyError(PackIntakeError):
708
+ """Raised without echoing prohibited content back into logs."""
709
+
710
+ def __init__(self, findings: tuple[PrivacyFinding, ...]) -> None:
711
+ self.findings = findings
712
+ summary = ', '.join((f'{item.path}:{item.category}' for item in findings))
713
+ super().__init__(f'privacy scan rejected pack: {summary}')
714
+
715
+ def intake_object(value: object, context: str) -> Mapping[str, object]:
716
+ if not isinstance(value, Mapping) or any((not isinstance(key, str) for key in value)):
717
+ raise PackIntakeError(f'{context} must be an object with string keys')
718
+ return cast('Mapping[str, object]', value)
719
+
720
+ def intake_exact(value: Mapping[str, object], fields: frozenset[str], context: str) -> None:
721
+ missing = sorted(fields - value.keys())
722
+ extra = sorted(value.keys() - fields)
723
+ if missing or extra:
724
+ raise PackIntakeError(f'{context} keys differ; missing={missing}, extra={extra}')
725
+
726
+ def intake_text(value: object, context: str) -> str:
727
+ if not isinstance(value, str) or not value.strip():
728
+ raise PackIntakeError(f'{context} must be non-empty text')
729
+ return value
730
+
731
+ def intake_id(value: object, context: str) -> str:
732
+ result = intake_text(value, context)
733
+ if intake_ID.fullmatch(result) is None:
734
+ raise PackIntakeError(f'{context} must be a privacy-safe identifier')
735
+ return result
736
+
737
+ def intake_digest(value: object, context: str) -> str:
738
+ result = intake_text(value, context)
739
+ if intake_SHA256.fullmatch(result) is None:
740
+ raise PackIntakeError(f'{context} must be a lowercase SHA-256 digest')
741
+ return result
742
+
743
+ def intake_file_sha256(path: Path) -> str:
744
+ digest = hashlib.sha256()
745
+ try:
746
+ with path.open('rb') as source:
747
+ for chunk in iter(lambda: source.read(1048576), b''):
748
+ digest.update(chunk)
749
+ except OSError as error:
750
+ raise PackIntakeError(f'cannot hash local input {path.name}: {error}') from error
751
+ return digest.hexdigest()
752
+
753
+ def intake_redacted_finding(category: str, path: str, value: str) -> PrivacyFinding:
754
+ return PrivacyFinding(category, path, hashlib.sha256(value.encode('utf-8')).hexdigest())
755
+
756
+ def scan_privacy(value: object, path: str='$') -> tuple[PrivacyFinding, ...]:
757
+ """Return deterministic redacted findings without retaining matched sensitive values."""
758
+ findings: list[PrivacyFinding] = []
759
+ if isinstance(value, Mapping):
760
+ for raw_key, item in sorted(value.items(), key=lambda pair: str(pair[0])):
761
+ key = str(raw_key)
762
+ child_path = f'{path}.{key}'
763
+ if key.casefold() in intake_PROHIBITED_KEYS:
764
+ findings.append(intake_redacted_finding('prohibited_field', child_path, key))
765
+ findings.extend(scan_privacy(item, child_path))
766
+ elif isinstance(value, list):
767
+ for index, item in enumerate(value):
768
+ findings.extend(scan_privacy(item, f'{path}[{index}]'))
769
+ elif isinstance(value, str):
770
+ for category, pattern in intake_TEXT_PATTERNS:
771
+ for match in pattern.finditer(value):
772
+ findings.append(intake_redacted_finding(category, path, match.group(0)))
773
+ return tuple(sorted(findings, key=lambda item: (item.path, item.category, item.value_sha256)))
774
+
775
+ def intake_approval(raw: object, context: str) -> tuple[bool, str | None]:
776
+ approval = intake_object(raw, context)
777
+ intake_exact(approval, frozenset({'status', 'approval_id', 'approved_by', 'approved_at', 'policy_version', 'blocker'}), context)
778
+ status = intake_text(approval['status'], f'{context}.status')
779
+ if status not in {'approved', 'pending'}:
780
+ raise PackIntakeError(f'{context}.status must be approved or pending')
781
+ if intake_text(approval['policy_version'], f'{context}.policy_version') != PRIVACY_POLICY_VERSION:
782
+ raise PackIntakeError(f'{context}.policy_version is unsupported')
783
+ if status == 'approved':
784
+ intake_id(approval['approval_id'], f'{context}.approval_id')
785
+ intake_id(approval['approved_by'], f'{context}.approved_by')
786
+ intake_text(approval['approved_at'], f'{context}.approved_at')
787
+ if approval['blocker'] is not None:
788
+ raise PackIntakeError(f'{context} approved state cannot retain a blocker')
789
+ return (True, None)
790
+ if any((approval[field] is not None for field in ('approval_id', 'approved_by', 'approved_at'))):
791
+ raise PackIntakeError(f'{context} pending state cannot claim approval metadata')
792
+ blocker = intake_text(approval['blocker'], f'{context}.blocker')
793
+ return (False, blocker)
794
+
795
+ def intake_validate_pack_manifest(raw: object) -> dict[str, object]:
796
+ pack = intake_object(raw, 'pack manifest')
797
+ intake_exact(pack, frozenset({'schema_version', 'pack_id', 'customer_id', 'dataset', 'privacy_approval', 'source_approval', 'holdout', 'expected_level_inputs'}), 'pack manifest')
798
+ if intake_text(pack['schema_version'], 'schema_version') != PACK_SCHEMA_VERSION:
799
+ raise PackIntakeError(f'schema_version must be {PACK_SCHEMA_VERSION}')
800
+ intake_id(pack['pack_id'], 'pack_id')
801
+ intake_id(pack['customer_id'], 'customer_id')
802
+ dataset = intake_object(pack['dataset'], 'dataset')
803
+ intake_exact(dataset, frozenset({'dataset_id', 'version', 'sha256'}), 'dataset')
804
+ intake_id(dataset['dataset_id'], 'dataset.dataset_id')
805
+ intake_text(dataset['version'], 'dataset.version')
806
+ intake_digest(dataset['sha256'], 'dataset.sha256')
807
+ intake_approval(pack['privacy_approval'], 'privacy_approval')
808
+ intake_approval(pack['source_approval'], 'source_approval')
809
+ holdout = intake_object(pack['holdout'], 'holdout')
810
+ intake_exact(holdout, frozenset({'historical_development_reuse', 'access_log_sha256'}), 'holdout')
811
+ if not isinstance(holdout['historical_development_reuse'], bool):
812
+ raise PackIntakeError('holdout.historical_development_reuse must be boolean')
813
+ if holdout['access_log_sha256'] is not None:
814
+ intake_digest(holdout['access_log_sha256'], 'holdout.access_log_sha256')
815
+ inputs = intake_object(pack['expected_level_inputs'], 'expected_level_inputs')
816
+ intake_exact(inputs, frozenset({'level1', 'level2', 'level3_subject_traces'}), 'expected_level_inputs')
817
+ for field in ('level1', 'level2', 'level3_subject_traces'):
818
+ if not isinstance(inputs[field], bool):
819
+ raise PackIntakeError(f'expected_level_inputs.{field} must be boolean')
820
+ return dict(pack)
821
+
822
+ def intake_data_rows(dataset: DatasetV3) -> list[dict[str, Any]]:
823
+ raw = cast('dict[str, Any]', dataset.value)
824
+ return cast('list[dict[str, Any]]', raw['tasks'])
825
+
826
+ def intake_coverage_counts(tasks: list[dict[str, Any]]) -> dict[str, int]:
827
+ """Count independent clusters represented in each required pilot cohort."""
828
+ cohorts: dict[str, set[str]] = {'unsupported_cases': set(), 'multi_evidence_cases': set(), 'authority_currentness': set(), 'scope_constraint': set(), 'negation': set(), 'hard_negative_discrimination': set(), 'high_severity': set()}
829
+ for task in tasks:
830
+ cluster_id = cast('str', task['cluster_id'])
831
+ if task['answerability'] == 'unsupported':
832
+ cohorts['unsupported_cases'].add(cluster_id)
833
+ if sum((group['requirement'] == 'required' for group in task['evidence_groups'])) >= 2:
834
+ cohorts['multi_evidence_cases'].add(cluster_id)
835
+ hard_negative_types = {hard_negative_type for candidate in task['candidates'] for hard_negative_type in candidate['hard_negative_types']}
836
+ if {'authority_conflict', 'stale'} & hard_negative_types:
837
+ cohorts['authority_currentness'].add(cluster_id)
838
+ if 'scope_mismatch' in hard_negative_types:
839
+ cohorts['scope_constraint'].add(cluster_id)
840
+ if 'negation_flip' in hard_negative_types or 'negation' in task['query_categories']:
841
+ cohorts['negation'].add(cluster_id)
842
+ if hard_negative_types:
843
+ cohorts['hard_negative_discrimination'].add(cluster_id)
844
+ if task['business_severity'] in {'high', 'critical'}:
845
+ cohorts['high_severity'].add(cluster_id)
846
+ return {name: len(cluster_ids) for name, cluster_ids in cohorts.items()}
847
+
848
+ def intake_coverage(counts: Mapping[str, int]) -> dict[str, bool]:
849
+ return {name: count >= (2 if name in {'unsupported_cases', 'multi_evidence_cases'} else 1) for name, count in counts.items()}
850
+
851
+ def intake_double_labeled(task: dict[str, Any]) -> bool:
852
+ judged = [candidate for candidate in task['candidates'] if candidate['judgment_status'] == 'judged']
853
+ return bool(judged) and all((len(candidate['judgments']) >= 2 for candidate in judged))
854
+
855
+ def assess_readiness(dataset: DatasetV3, pack: Mapping[str, object]) -> ReadinessReport:
856
+ """Compute predeclared maturity thresholds and named, scoped blockers."""
857
+ tasks = intake_data_rows(dataset)
858
+ clusters = {task['cluster_id'] for task in tasks}
859
+ by_cluster: dict[str, list[dict[str, Any]]] = {}
860
+ for task in tasks:
861
+ by_cluster.setdefault(task['cluster_id'], []).append(task)
862
+ double_labeled_clusters = sum((all((intake_double_labeled(task) for task in cluster_tasks)) for cluster_tasks in by_cluster.values()))
863
+ double_rate = double_labeled_clusters / len(clusters)
864
+ coverage_counts = intake_coverage_counts(tasks)
865
+ coverage = intake_coverage(coverage_counts)
866
+ blockers: list[ReadinessBlocker] = []
867
+ privacy_approved, privacy_blocker = intake_approval(pack['privacy_approval'], 'privacy_approval')
868
+ source_approved, source_blocker = intake_approval(pack['source_approval'], 'source_approval')
869
+ if not privacy_approved:
870
+ blockers.append(ReadinessBlocker('privacy_approval_missing', 'data', privacy_blocker or 'pending'))
871
+ if not source_approved:
872
+ blockers.append(ReadinessBlocker('source_approval_missing', 'data', source_blocker or 'pending'))
873
+ if any((candidate['judgment_status'] == 'unknown' for task in tasks for candidate in task['candidates'])):
874
+ blockers.append(ReadinessBlocker('qrels_incomplete', 'data', 'At least one candidate has an unknown qrel.'))
875
+ holdout = cast('Mapping[str, object]', pack['holdout'])
876
+ has_holdout = any((task['partition'] == 'protected_holdout' for task in tasks))
877
+ if holdout['historical_development_reuse'] is True:
878
+ blockers.append(ReadinessBlocker('holdout_reused_for_development', 'data', 'Development evidence cannot be promoted as protected holdout truth.'))
879
+ if has_holdout and holdout['access_log_sha256'] is None:
880
+ blockers.append(ReadinessBlocker('holdout_access_log_missing', 'data', 'Protected holdout requires a content-bound access log.'))
881
+ inputs = cast('Mapping[str, object]', pack['expected_level_inputs'])
882
+ if inputs['level1'] is not True:
883
+ blockers.append(ReadinessBlocker('level1_input_missing', 'level1', 'Frozen component inputs are missing.'))
884
+ if inputs['level2'] is not True:
885
+ blockers.append(ReadinessBlocker('level2_input_missing', 'level2', 'Pipeline inputs are missing.'))
886
+ if inputs['level3_subject_traces'] is not True:
887
+ blockers.append(ReadinessBlocker('model_traces_missing', 'level3', 'No valid full-RAG subject traces are available for scoring.'))
888
+ data_blocked = any((blocker.scope == 'data' for blocker in blockers))
889
+ pilot_coverage = len(clusters) >= 12 and all(coverage.values())
890
+ high_severity_double_labeled = all((intake_double_labeled(task) for task in tasks if task['business_severity'] in {'high', 'critical'}))
891
+ decision_grade = len(clusters) >= 50 and double_rate >= 0.2 and high_severity_double_labeled and pilot_coverage
892
+ status: Literal['blocked', 'decision_grade', 'draft', 'pilot_ready']
893
+ if data_blocked:
894
+ status = 'blocked'
895
+ elif not pilot_coverage:
896
+ status = 'draft'
897
+ elif decision_grade:
898
+ status = 'decision_grade'
899
+ else:
900
+ status = 'pilot_ready'
901
+ return ReadinessReport(status, len(clusters), double_rate, tuple(blockers), coverage, coverage_counts)
902
+
903
+ def intake_customer_pack(*, registry_path: Path, pack_root: Path, customer_id: str, dataset_path: Path) -> PackIntake:
904
+ """Validate one explicitly addressed pack and dataset without copying raw data."""
905
+ registry: CustomerRegistry = load_customer_registry(registry_path)
906
+ customer = next((item for item in registry.customers if item.customer_id == customer_id), None)
907
+ if customer is None:
908
+ raise PackIntakeError(f'customer {customer_id!r} is not assigned in the registry')
909
+ if customer.pack_status != 'available' or customer.pack_path is None or customer.pack_manifest_sha256 is None:
910
+ details = '; '.join(customer.blockers) or 'pack is unavailable'
911
+ raise PackIntakeError(f'customer pack is blocked: {details}')
912
+ from ._track_capture import safe_path, _read_file
913
+ manifest_path = safe_path(pack_root, customer.pack_path)
914
+ manifest_raw = _read_file(manifest_path, 'sha256:' + customer.pack_manifest_sha256)
915
+ manifest_findings = scan_privacy(manifest_raw)
916
+ if manifest_findings:
917
+ raise PackPrivacyError(manifest_findings)
918
+ pack = intake_validate_pack_manifest(manifest_raw)
919
+ if pack['customer_id'] != customer_id:
920
+ raise PackIntakeError('pack customer_id does not match registry selection')
921
+ if dataset_path.is_symlink() or not dataset_path.is_file():
922
+ raise PackIntakeError('dataset path must be an explicit regular local file')
923
+ dataset_meta = intake_object(pack['dataset'], 'dataset')
924
+ dataset_bytes = Path(dataset_path).read_bytes()
925
+ dataset_sha256 = hashlib.sha256(dataset_bytes).hexdigest()
926
+ if dataset_sha256 != dataset_meta['sha256']:
927
+ raise PackIntakeError('dataset hash does not match pack manifest')
928
+ from ._track_capture import strict_json
929
+ dataset_raw = strict_json(dataset_bytes)
930
+ findings = scan_privacy(dataset_raw)
931
+ if findings:
932
+ raise PackPrivacyError(findings)
933
+ dataset = validate_dataset_v3(dataset_raw)
934
+ if dataset.dataset_id != dataset_meta['dataset_id'] or dataset.version != dataset_meta['version']:
935
+ raise PackIntakeError('dataset identity does not match pack manifest')
936
+ if any((task.customer_id != customer_id for task in dataset.tasks)):
937
+ raise PackIntakeError('dataset task customer_id does not match selected customer')
938
+ readiness = assess_readiness(dataset, pack)
939
+ return PackIntake(customer_id=customer_id, pack_id=cast('str', pack['pack_id']), manifest_sha256=customer.pack_manifest_sha256, dataset_sha256=dataset_sha256, dataset=dataset, readiness=readiness)
940
+
941
+ # Sphynx file boundaries use strict JSON and optional external byte commitments.
942
+ def load_dataset_v3(path, *, expected_sha256=None):
943
+ from ._track_capture import strict_json, byte_digest
944
+ from .integration_contracts import require_digest
945
+ path = Path(path)
946
+ if path.is_symlink() or not path.is_file():
947
+ raise DatasetV3Error('dataset must be a regular local file')
948
+ data = path.read_bytes()
949
+ if expected_sha256 is not None:
950
+ require_digest(expected_sha256, 'expected_sha256')
951
+ if byte_digest(data) != expected_sha256:
952
+ raise DatasetV3Error('dataset source digest mismatch')
953
+ return validate_dataset_v3(strict_json(data))
954
+
955
+
956
+ def load_customer_registry(path):
957
+ from ._track_capture import strict_json
958
+ return validate_customer_registry(strict_json(Path(path).read_bytes()))
959
+
960
+
961
+ def import_dataset_v3(path, *, expected_sha256, dataset_id, version):
962
+ """Create an explicitly new V3 release; never rebind an existing per-pack ID."""
963
+ source = load_dataset_v3(path, expected_sha256=expected_sha256)
964
+ if dataset_id == source.dataset_id or version == source.version:
965
+ raise DatasetV3Error('conversion requires a new dataset ID and version')
966
+ raw = source.value
967
+ raw.update(dataset_id=dataset_id, version=version)
968
+ return validate_dataset_v3(raw)
969
+
970
+
971
+ def customer_readiness_manifest(registry_path, *, pack_root, dataset_paths=None):
972
+ """Only verified approved manifests and data can make a registry row ready."""
973
+ from ._track_capture import byte_digest
974
+ dataset_paths = dataset_paths or {}
975
+ registry = load_customer_registry(registry_path)
976
+ rows = []
977
+ for customer in registry.customers:
978
+ row = {'customer_id': customer.customer_id, 'state': 'blocked',
979
+ 'source_digest': None, 'reference_digest': None,
980
+ 'blockers': list(customer.blockers)}
981
+ if customer.pack_status != 'available':
982
+ row['blockers'].append('approved_pack_missing')
983
+ elif customer.customer_id not in dataset_paths:
984
+ row['blockers'].append('owner_dataset_missing')
985
+ else:
986
+ try:
987
+ intake = intake_customer_pack(registry_path=Path(registry_path), pack_root=Path(pack_root),
988
+ customer_id=customer.customer_id, dataset_path=Path(dataset_paths[customer.customer_id]))
989
+ row['source_digest'] = 'sha256:' + intake.dataset_sha256
990
+ row['reference_digest'] = row['source_digest']
991
+ row['blockers'] = [b.code for b in intake.readiness.blockers if b.scope == 'data']
992
+ if intake.readiness.quality_metrics_allowed:
993
+ row['state'] = 'ready'
994
+ row['maturity'] = intake.readiness.status
995
+ except (ValueError, OSError) as error:
996
+ row['blockers'].append(type(error).__name__)
997
+ rows.append(row)
998
+ return {'schema_version': 'sphynx.customer-readiness.v1',
999
+ 'registry_digest': byte_digest(Path(registry_path).read_bytes()), 'customers': rows}