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
@@ -0,0 +1,299 @@
1
+ """Build owner-held development reranker pools from existing customer KBs.
2
+
3
+ This adapter measures recovery of machine-generated source anchors. It does
4
+ not turn unmatched passages into reviewed semantic negatives. The explicit
5
+ ``unjudged_policy`` prevents accidentally treating this as a benchmark qrel set.
6
+ No customer material is bundled with Sphynx and no network calls are made.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import json
13
+ import math
14
+ import re
15
+ from collections import Counter
16
+ from pathlib import Path
17
+ from typing import Any, Iterable
18
+
19
+ from .contracts import ContractError, canonical_json
20
+ from .rag import validate_dataset_v2
21
+ from .sable_rerank import SABLE_PACK_ID, PrivateEvaluation, load_private_pack
22
+
23
+ LABEL_BASIS = "machine_generated_source_anchor_recovery"
24
+ UNJUDGED_POLICY = "assume_zero_development"
25
+ DEFAULT_CUSTOMERS = ("figma", "monday", "notion", "tipalti", "vanta")
26
+ _TOKENS = re.compile(r"[a-z0-9]+")
27
+ _STOP = frozenset({"a", "an", "and", "are", "can", "do", "how", "i", "in", "is", "of", "the", "to", "what", "about", "should", "customer", "know"})
28
+
29
+
30
+ def _sha(payload: bytes) -> str:
31
+ return hashlib.sha256(payload).hexdigest()
32
+
33
+
34
+ def _read_json(path: Path) -> dict[str, Any]:
35
+ value = json.loads(path.read_text(encoding="utf-8"))
36
+ if not isinstance(value, dict):
37
+ raise ContractError(f"{path.name}: expected JSON object")
38
+ return value
39
+
40
+
41
+ def _manifest(path: Path, schema: str | None = None) -> dict[str, Any]:
42
+ value = _read_json(path)
43
+ digest = _sha(canonical_json({key: item for key, item in value.items() if key != "manifest_sha256"}).encode("utf-8"))
44
+ if value.get("manifest_sha256") != digest:
45
+ raise ContractError(f"{path.name}: source manifest digest mismatch")
46
+ if schema is not None and value.get("schema_version") != schema:
47
+ raise ContractError(f"{path.name}: unsupported source manifest schema")
48
+ return value
49
+
50
+
51
+ def _read_jsonl(path: Path) -> list[dict[str, Any]]:
52
+ rows = [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line.strip()]
53
+ if not rows or any(not isinstance(row, dict) for row in rows):
54
+ raise ContractError(f"{path.name}: expected nonempty JSONL objects")
55
+ return rows
56
+
57
+
58
+ def _unique(rows: Iterable[dict[str, Any]], field: str) -> dict[str, dict[str, Any]]:
59
+ result: dict[str, dict[str, Any]] = {}
60
+ for row in rows:
61
+ identity = row.get(field)
62
+ if not isinstance(identity, str) or not identity or identity in result:
63
+ raise ContractError(f"missing or duplicated {field}")
64
+ result[identity] = row
65
+ return result
66
+
67
+
68
+ def _tokens(text: str) -> list[str]:
69
+ return [token for token in _TOKENS.findall(text.lower()) if token not in _STOP]
70
+
71
+
72
+ def _rank(query: str, chunks: list[dict[str, Any]]) -> list[tuple[float, dict[str, Any]]]:
73
+ """Deterministic BM25 with k1=1.2, b=0.75, sorted chunk-ID ties."""
74
+
75
+ documents = [Counter(_tokens(chunk["text"])) for chunk in chunks]
76
+ lengths = [sum(document.values()) for document in documents]
77
+ average = sum(lengths) / len(lengths) if sum(lengths) else 1.0
78
+ terms = set(_tokens(query))
79
+ frequency = {term: sum(term in document for document in documents) for term in terms}
80
+ scores = []
81
+ for chunk, document, length in zip(chunks, documents, lengths):
82
+ score = 0.0
83
+ for term in terms:
84
+ tf = document[term]
85
+ if tf:
86
+ idf = math.log(1.0 + (len(chunks) - frequency[term] + 0.5) / (frequency[term] + 0.5))
87
+ score += idf * tf * 2.2 / (tf + 1.2 * (0.25 + 0.75 * length / average))
88
+ scores.append((score, chunk))
89
+ return sorted(scores, key=lambda item: (-item[0], item[1]["chunk_id"]))
90
+
91
+
92
+ def build_customer_development_dataset(
93
+ question_bank_root: str | Path,
94
+ knowledge_base_root: str | Path,
95
+ *,
96
+ unjudged_policy: str,
97
+ customers: Iterable[str] = DEFAULT_CUSTOMERS,
98
+ candidate_depth: int = 15,
99
+ ) -> dict[str, Any]:
100
+ """Freeze actual local KB pools for every anchored ``call_rag`` question.
101
+
102
+ Only development call cases are used; clarification/context controls are
103
+ excluded by their action. Gold anchors are NEVER injected into retrieval.
104
+ Cases with no retrieved anchor remain in ``retrieval_coverage.excluded``.
105
+ An anchor gets grade 3; every other passage gets grade 0 meaning "not this
106
+ known anchor", NOT "reviewed irrelevant". These are anchor-recovery proxy
107
+ metrics and must be displayed with that qualification.
108
+ """
109
+
110
+ if unjudged_policy != UNJUDGED_POLICY:
111
+ raise ContractError("customer development pools require explicit assume_zero_development policy")
112
+ if type(candidate_depth) is not int or not 5 <= candidate_depth <= 100:
113
+ raise ContractError("candidate_depth must be an integer from 5 to 100")
114
+ customer_ids = tuple(customers)
115
+ if not customer_ids or len(set(customer_ids)) != len(customer_ids) or any(
116
+ not isinstance(company, str) or not re.fullmatch(r"[a-z0-9-]+", company) for company in customer_ids
117
+ ):
118
+ raise ContractError("customers must be unique simple identifiers")
119
+ bank = Path(question_bank_root).expanduser().resolve(strict=True)
120
+ kb = Path(knowledge_base_root).expanduser().resolve(strict=True)
121
+ question_manifest = _manifest(bank / "manifest.json", "sable.invocation-question-corpus.v1")
122
+ corpus_manifest = _manifest(kb / "manifest.json")
123
+ if question_manifest.get("knowledge_base_manifest_sha256") != corpus_manifest["manifest_sha256"]:
124
+ raise ContractError("question bank and source knowledge base are not the same pinned release")
125
+ if not question_manifest.get("development_only") or question_manifest.get("protected_test_split"):
126
+ raise ContractError("this builder accepts development question banks only")
127
+ input_path, gold_path = bank / "model-input.jsonl", bank / "gold.jsonl"
128
+ inputs = _unique(_read_jsonl(input_path), "question_id")
129
+ gold = _unique(_read_jsonl(gold_path), "question_id")
130
+ if set(inputs) != set(gold) or len(inputs) != question_manifest.get("question_count"):
131
+ raise ContractError("question bank input/gold membership or declared count mismatch")
132
+ manifest_companies = {row["company_id"]: row for row in corpus_manifest["companies"]}
133
+ tasks: list[dict[str, Any]] = []
134
+ sources, coverage = [], {}
135
+ for company in sorted(customer_ids):
136
+ if company not in manifest_companies:
137
+ raise ContractError(f"source KB has no company {company}")
138
+ company_root = kb / "companies" / company
139
+ company_manifest = _manifest(company_root / "manifest.json", "sable.company-kb.v1")
140
+ if company_manifest["manifest_sha256"] != manifest_companies[company]["manifest_sha256"]:
141
+ raise ContractError(f"{company}: company/global manifest mismatch")
142
+ chunks_path = (company_root / company_manifest["retrieval_chunks"]).resolve(strict=True)
143
+ if not chunks_path.is_relative_to(company_root.resolve()):
144
+ raise ContractError(f"{company}: chunk path leaves company source directory")
145
+ chunks = _read_jsonl(chunks_path)
146
+ by_chunk = _unique(chunks, "chunk_id")
147
+ if len(chunks) != company_manifest.get("chunk_count") or len(chunks) < candidate_depth:
148
+ raise ContractError(f"{company}: KB chunk count disagrees or is too small")
149
+ for chunk in chunks:
150
+ if chunk.get("company_id") != company or not isinstance(chunk.get("text"), str) or not chunk["text"]:
151
+ raise ContractError(f"{company}: malformed or wrong-customer chunk")
152
+ if _sha(chunk["text"].encode("utf-8")) != chunk.get("text_sha256"):
153
+ raise ContractError(f"{company}: chunk text digest mismatch")
154
+ corpus_sha = _sha(chunks_path.read_bytes())
155
+ sources.append({
156
+ "customer": company, "chunk_count": len(chunks), "chunks_sha256": corpus_sha,
157
+ "manifest_sha256": company_manifest["manifest_sha256"],
158
+ "source_classes": dict(sorted(Counter(chunk["source_class"] for chunk in chunks).items())),
159
+ })
160
+ eligible = retrieved = anchor_count = retrieved_anchor_count = 0
161
+ excluded = []
162
+ for identity, label in sorted(gold.items()):
163
+ row = inputs[identity]
164
+ if row.get("company_id") != label.get("company_id") or row.get("user_turn") != label.get("user_turn") or row.get("conversation_context") != label.get("conversation_context"):
165
+ raise ContractError(f"{identity}: input and gold question/context differ")
166
+ if label["company_id"] != company or label["gold"].get("invocation_action") != "call_rag":
167
+ continue
168
+ if label.get("split") != "development":
169
+ raise ContractError(f"{identity}: selected source case is not development")
170
+ anchors = set(label["gold"].get("evidence_chunk_ids", []))
171
+ documents = set(label["gold"].get("evidence_document_ids", []))
172
+ if not anchors or not anchors <= set(by_chunk) or any(by_chunk[cid]["document_id"] not in documents for cid in anchors):
173
+ raise ContractError(f"{identity}: source anchor/document is missing or mismatched")
174
+ eligible += 1
175
+ anchor_count += len(anchors)
176
+ pool = _rank(row["user_turn"], chunks)[:candidate_depth]
177
+ present = anchors & {chunk["chunk_id"] for _, chunk in pool}
178
+ retrieved_anchor_count += len(present)
179
+ if not present:
180
+ excluded.append({"case_id": identity, "reason": "no_source_anchor_in_retrieved_pool", "anchor_count": len(anchors)})
181
+ continue
182
+ retrieved += 1
183
+ group_id = "anchor:" + identity
184
+ candidates = []
185
+ for rank, (score, chunk) in enumerate(pool, 1):
186
+ matched = chunk["chunk_id"] in anchors
187
+ candidates.append({
188
+ "candidate_id": chunk["chunk_id"], "document_id": chunk["document_id"],
189
+ "source_id": chunk["document_id"], "chunk_id": chunk["chunk_id"],
190
+ "url": chunk["source_uri"], "input": chunk["text"],
191
+ "dense_rank": rank, "dense_score": score,
192
+ "relevance_grade": 3 if matched else 0,
193
+ "evidence_group_ids": [group_id] if matched else [],
194
+ "known_hard_negative": False,
195
+ "label_basis": "source_anchor_match" if matched else "unjudged_not_known_anchor",
196
+ "source_class": chunk["source_class"], "text_sha256": chunk["text_sha256"],
197
+ })
198
+ tasks.append({
199
+ "task_id": identity, "query_id": identity,
200
+ "cluster_id": label["family_id"], "split_group_id": label["family_id"],
201
+ "domain": company, "customer": company, "query": row["user_turn"],
202
+ "answerability": "answerable", "business_severity": "medium",
203
+ "query_categories": ["straightforward_factual"], "topic": label["topic"],
204
+ "split": "dev", "suite_type": "customer_acceptance",
205
+ "versions": {"corpus": "source-kb:" + corpus_sha,
206
+ "index": "bm25-reconstructed.v1:" + corpus_sha,
207
+ "chunking": company_manifest["parser_version"],
208
+ "embedding": "none.lexical-bm25.v1"},
209
+ "candidates": candidates,
210
+ "evidence_groups": [{"evidence_group_id": group_id,
211
+ "description": "Recover a machine-generated source anchor; semantic sufficiency is not adjudicated",
212
+ "required": True, "acceptable_candidate_ids": sorted(present)}],
213
+ "source_anchor_count": len(anchors), "retrieved_source_anchor_count": len(present),
214
+ "source_question_provenance": label["source_provenance"],
215
+ "source_review_status": label["review_status"],
216
+ })
217
+ if eligible == 0:
218
+ raise ContractError(f"{company}: no anchored development call questions")
219
+ coverage[company] = {
220
+ "eligible_query_count": eligible, "scored_query_count": retrieved,
221
+ "retrieval_missing_query_count": eligible - retrieved,
222
+ "anchor_pool_coverage": retrieved / eligible,
223
+ "source_anchor_count": anchor_count, "retrieved_anchor_count": retrieved_anchor_count,
224
+ "anchor_recall_at_pool_depth": retrieved_anchor_count / anchor_count,
225
+ "excluded": excluded,
226
+ }
227
+ if not tasks:
228
+ raise ContractError("no retrieved source anchors available for reranker grading")
229
+ dataset = {
230
+ "schema_version": "rag-evals.dataset.v2", "dataset_id": "sable.customer-source-anchor.dev.v1",
231
+ "version": "v1", "default_relevance_threshold": 2,
232
+ "sphynx_private_pack": {
233
+ "schema_version": "sphynx.sable.rerank.private.v1", "pack_id": SABLE_PACK_ID,
234
+ "visibility": "private", "origin": "reconstructed_development", "label_status": "provisional",
235
+ "source_revision": "source-kb:" + corpus_manifest["manifest_sha256"],
236
+ "label_basis": LABEL_BASIS, "unjudged_policy": UNJUDGED_POLICY,
237
+ "metric_semantics": "source-anchor recovery proxy; unmatched chunks are unjudged, not human negatives",
238
+ "oracle_anchor_injection": False, "candidate_source": "deterministic local BM25 top-k",
239
+ "candidate_depth": candidate_depth,
240
+ "question_manifest_sha256": question_manifest["manifest_sha256"],
241
+ "source_input_sha256": _sha(input_path.read_bytes()), "source_label_sha256": _sha(gold_path.read_bytes()),
242
+ "knowledge_base_manifest_sha256": corpus_manifest["manifest_sha256"],
243
+ "sources": sources, "retrieval_coverage": coverage,
244
+ },
245
+ "tasks": tasks,
246
+ }
247
+ validate_dataset_v2(dataset)
248
+ return dataset
249
+
250
+
251
+ def summarize_customer_rerank(evaluation: PrivateEvaluation, dataset_path: str | Path) -> dict[str, Any]:
252
+ """Return qualified aggregate and customer metrics without passage/label text."""
253
+
254
+ private = load_private_pack(dataset_path)
255
+ if evaluation.dataset_digest != private.dataset_digest:
256
+ raise ContractError("customer summary requires the exact evaluated dataset bytes")
257
+ dataset = _read_json(Path(dataset_path))
258
+ meta = dataset["sphynx_private_pack"]
259
+ if meta.get("label_basis") != LABEL_BASIS:
260
+ raise ContractError("customer summary requires source-anchor recovery labels")
261
+ customers = {task["task_id"]: task["customer"] for task in dataset["tasks"]}
262
+ per_customer = {}
263
+ for company in sorted(meta["retrieval_coverage"]):
264
+ rows = [row for row in evaluation.cases if customers.get(row["case_id"]) == company]
265
+ metrics = {}
266
+ for metric_id in evaluation.metrics:
267
+ values = sorted(row["values"][metric_id] for row in rows if row["values"].get(metric_id) is not None)
268
+ complete = bool(rows) and len(values) == len(rows)
269
+ if metric_id == "runner_latency_ms":
270
+ def percentile(fraction: float) -> float | None:
271
+ if not complete or not values:
272
+ return None
273
+ position = (len(values) - 1) * fraction
274
+ left = int(position)
275
+ return values[left] + (values[min(left + 1, len(values) - 1)] - values[left]) * (position - left)
276
+ value, p95 = percentile(0.5), percentile(0.95)
277
+ else:
278
+ value = sum(values) / len(values) if complete and values else None
279
+ p95 = None
280
+ metrics[metric_id] = {"value": value, "p95": p95, "eligible_count": len(rows), "observed_count": len(values), "complete": complete}
281
+ per_customer[company] = {"case_count": len(rows), "retrieval_coverage": meta["retrieval_coverage"][company], "metrics": metrics}
282
+ # Excluded-case IDs belong in the owner-held dataset, not this scorecard.
283
+ for item in per_customer.values():
284
+ item["retrieval_coverage"] = {key: value for key, value in item["retrieval_coverage"].items() if key != "excluded"}
285
+ return {
286
+ "schema_version": "sphynx.customer.rerank.summary.v1", "dataset_digest": private.dataset_digest,
287
+ "pack_id": evaluation.pack_id,
288
+ "run_id": evaluation.run_id, "candidate": dict(evaluation.candidate), "status": evaluation.status,
289
+ "origin": evaluation.origin, "development_only": True,
290
+ "trust_tier": "local_unverified", "label_status": "provisional", "label_basis": LABEL_BASIS,
291
+ "metric_semantics": meta["metric_semantics"], "oracle_anchor_injection": False,
292
+ "candidate_depth": meta["candidate_depth"], "customer_count": len(per_customer),
293
+ "case_count": len(evaluation.cases), "candidate_count": sum(len(task["candidates"]) for task in dataset["tasks"]),
294
+ "success_count": sum(row["status"] == "success" for row in evaluation.cases),
295
+ "failure_count": sum(row["status"] != "success" for row in evaluation.cases),
296
+ "eligible_query_count": sum(item["eligible_query_count"] for item in meta["retrieval_coverage"].values()),
297
+ "retrieval_missing_query_count": sum(item["retrieval_missing_query_count"] for item in meta["retrieval_coverage"].values()),
298
+ "metrics": {key: dict(value) for key, value in evaluation.metrics.items()}, "customers": per_customer,
299
+ }
@@ -0,0 +1,146 @@
1
+ """Freeze full owner-held customer corpora for retrieval architecture evals.
2
+
3
+ Source anchors are provisional recovery proxies, not human relevance labels.
4
+ Unlike the reranker builder, no question is excluded for a retrieval miss and
5
+ no candidate pool is constructed here. Each architecture searches the corpus.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import re
12
+ from collections import Counter
13
+ from pathlib import Path
14
+ from typing import Any, Iterable
15
+
16
+ from .contracts import ContractError
17
+ from .customer_rerank import (
18
+ DEFAULT_CUSTOMERS, LABEL_BASIS, UNJUDGED_POLICY, _manifest, _read_jsonl, _unique,
19
+ )
20
+
21
+
22
+ def build_customer_retrieval_dataset(
23
+ question_bank_root: str | Path,
24
+ knowledge_base_root: str | Path,
25
+ *,
26
+ unjudged_policy: str,
27
+ customers: Iterable[str] = DEFAULT_CUSTOMERS,
28
+ ) -> dict[str, Any]:
29
+ """Return every anchored development CALL_RAG case and its full corpus.
30
+
31
+ The returned object includes private references. Store it outside Git and do
32
+ not pass it to a candidate. ``build_retrieval_request`` strips references and
33
+ filters corpus chunks to the case's customer. A required group means recover
34
+ any accepted source anchor for this case; semantic answer sufficiency remains
35
+ unreviewed. Neither anchors nor alternative passages are injected into pools.
36
+ """
37
+ if unjudged_policy != UNJUDGED_POLICY:
38
+ raise ContractError("explicit assume_zero_development policy is required")
39
+ customer_ids = tuple(customers)
40
+ if (not customer_ids or len(set(customer_ids)) != len(customer_ids)
41
+ or any(not isinstance(c, str) or not re.fullmatch(r"[a-z0-9-]+", c)
42
+ for c in customer_ids)):
43
+ raise ContractError("customers must be unique simple identifiers")
44
+ bank = Path(question_bank_root).expanduser().resolve(strict=True)
45
+ kb = Path(knowledge_base_root).expanduser().resolve(strict=True)
46
+ question_manifest = _manifest(bank / "manifest.json", "sable.invocation-question-corpus.v1")
47
+ corpus_manifest = _manifest(kb / "manifest.json")
48
+ if question_manifest.get("knowledge_base_manifest_sha256") != corpus_manifest["manifest_sha256"]:
49
+ raise ContractError("question bank and corpus are not the same pinned release")
50
+ if not question_manifest.get("development_only") or question_manifest.get("protected_test_split"):
51
+ raise ContractError("only development question banks are accepted")
52
+ input_path, gold_path = bank / "model-input.jsonl", bank / "gold.jsonl"
53
+ inputs = _unique(_read_jsonl(input_path), "question_id")
54
+ gold = _unique(_read_jsonl(gold_path), "question_id")
55
+ if set(inputs) != set(gold) or len(inputs) != question_manifest.get("question_count"):
56
+ raise ContractError("question input/gold identities or declared counts differ")
57
+ for identity, label in gold.items():
58
+ row = inputs[identity]
59
+ if any(row.get(key) != label.get(key) for key in ("company_id", "user_turn", "conversation_context")):
60
+ raise ContractError(f"{identity}: input and gold differ")
61
+ companies = _unique(corpus_manifest["companies"], "company_id")
62
+ corpus: list[dict[str, Any]] = []
63
+ cases: list[dict[str, Any]] = []
64
+ sources: list[dict[str, Any]] = []
65
+ counts: dict[str, int] = {}
66
+ for customer in sorted(customer_ids):
67
+ if customer not in companies:
68
+ raise ContractError(f"missing source company {customer}")
69
+ directory = (kb / "companies" / customer).resolve(strict=True)
70
+ if not directory.is_relative_to(kb):
71
+ raise ContractError("customer source leaves knowledge base directory")
72
+ manifest = _manifest(directory / "manifest.json", "sable.company-kb.v1")
73
+ if manifest["manifest_sha256"] != companies[customer]["manifest_sha256"]:
74
+ raise ContractError(f"{customer}: company/global source manifest differs")
75
+ path = (directory / manifest["retrieval_chunks"]).resolve(strict=True)
76
+ if not path.is_relative_to(directory):
77
+ raise ContractError("corpus path leaves customer directory")
78
+ chunks = _read_jsonl(path)
79
+ by_id = _unique(chunks, "chunk_id")
80
+ if len(chunks) != manifest.get("chunk_count"):
81
+ raise ContractError(f"{customer}: corpus count differs")
82
+ for chunk in chunks:
83
+ text = chunk.get("text")
84
+ if chunk.get("company_id") != customer or not isinstance(text, str) or not text:
85
+ raise ContractError(f"{customer}: invalid corpus chunk")
86
+ if hashlib.sha256(text.encode("utf-8")).hexdigest() != chunk.get("text_sha256"):
87
+ raise ContractError(f"{customer}: corpus text digest differs")
88
+ corpus.append({
89
+ "chunk_id": chunk["chunk_id"], "document_id": chunk["document_id"],
90
+ "customer": customer, "text": text, "text_sha256": chunk["text_sha256"],
91
+ "source_class": chunk["source_class"], "source_uri": chunk["source_uri"],
92
+ })
93
+ sources.append({
94
+ "customer": customer, "chunk_count": len(chunks),
95
+ "chunks_sha256": hashlib.sha256(path.read_bytes()).hexdigest(),
96
+ "manifest_sha256": manifest["manifest_sha256"],
97
+ "chunking_version": manifest["parser_version"],
98
+ "source_classes": dict(sorted(Counter(c["source_class"] for c in chunks).items())),
99
+ })
100
+ counts[customer] = 0
101
+ for identity, label in sorted(gold.items()):
102
+ if label.get("company_id") != customer or label.get("gold", {}).get("invocation_action") != "call_rag":
103
+ continue
104
+ if label.get("split") != "development":
105
+ raise ContractError(f"{identity}: only development cases are accepted")
106
+ anchors = label["gold"].get("evidence_chunk_ids", [])
107
+ documents = label["gold"].get("evidence_document_ids", [])
108
+ if (not isinstance(anchors, list) or not anchors or len(set(anchors)) != len(anchors)
109
+ or not set(anchors) <= set(by_id)
110
+ or any(by_id[a]["document_id"] not in documents for a in anchors)):
111
+ raise ContractError(f"{identity}: source anchors are absent or inconsistent")
112
+ query = inputs[identity]["user_turn"]
113
+ family = label.get("family_id")
114
+ if not isinstance(query, str) or not query.strip() or not isinstance(family, str) or not family:
115
+ raise ContractError(f"{identity}: query or independent family identity is missing")
116
+ cases.append({
117
+ "query_id": identity, "query": query, "customer": customer,
118
+ "cluster_id": family, "conversation_context": inputs[identity].get("conversation_context", []),
119
+ "references": {
120
+ "evidence_groups": [{"evidence_group_id": "anchor:" + identity,
121
+ "required": True, "acceptable_chunk_ids": sorted(anchors)}],
122
+ "graded_relevance": {anchor: 3 for anchor in sorted(anchors)},
123
+ },
124
+ })
125
+ counts[customer] += 1
126
+ if not counts[customer]:
127
+ raise ContractError(f"{customer}: no anchored retrieval questions")
128
+ _unique(corpus, "chunk_id")
129
+ _unique(cases, "query_id")
130
+ return {
131
+ "schema_version": "sphynx.rag.retrieve.dataset.v1",
132
+ "metadata": {
133
+ "pack_id": "rag.retrieve.v1", "origin": "reconstructed_development",
134
+ "label_status": "provisional", "label_basis": LABEL_BASIS,
135
+ "unjudged_policy": UNJUDGED_POLICY, "development_only": True,
136
+ "metric_semantics": "source-anchor recovery proxy; complete-evidence means complete known anchor groups, not reviewed answer sufficiency",
137
+ "oracle_anchor_injection": False, "retrieval_population": "all anchored development CALL_RAG questions",
138
+ "knowledge_base_manifest_sha256": corpus_manifest["manifest_sha256"],
139
+ "question_manifest_sha256": question_manifest["manifest_sha256"],
140
+ "source_input_sha256": hashlib.sha256(input_path.read_bytes()).hexdigest(),
141
+ "source_label_sha256": hashlib.sha256(gold_path.read_bytes()).hexdigest(),
142
+ "case_count": len(cases), "customer_case_counts": counts, "sources": sources,
143
+ },
144
+ "corpus": sorted(corpus, key=lambda c: c["chunk_id"]),
145
+ "cases": sorted(cases, key=lambda c: (c["customer"], c["query_id"])),
146
+ }
@@ -0,0 +1,164 @@
1
+ {
2
+ "benchmarks": [
3
+ {
4
+ "cases": [
5
+ {
6
+ "case_id": "core-onboarding-security",
7
+ "company": "synthetic",
8
+ "form_receipt": null,
9
+ "form_status": "unclassified",
10
+ "input_digest": "sha256:de96e83e201c629361c57d5831eee6f4e91ce22c9aeea5c1334b6f4cd235ed70",
11
+ "input_form": "unclassified",
12
+ "length_words": 8,
13
+ "split": "dev"
14
+ },
15
+ {
16
+ "case_id": "core-onboarding-security-paraphrase",
17
+ "company": "synthetic",
18
+ "form_receipt": null,
19
+ "form_status": "unclassified",
20
+ "input_digest": "sha256:7428d18761ac9c9f0df1f0de1a7cf319bc4461009fcb4d4932115e784fddc483",
21
+ "input_form": "unclassified",
22
+ "length_words": 7,
23
+ "split": "dev"
24
+ },
25
+ {
26
+ "case_id": "customer-seat-transfer",
27
+ "company": "synthetic",
28
+ "form_receipt": null,
29
+ "form_status": "unclassified",
30
+ "input_digest": "sha256:0f2fd15f28a8f15e3c3b4eaf09e8b79bcdf5740ffffdd61ef48d742851fb7de9",
31
+ "input_form": "unclassified",
32
+ "length_words": 9,
33
+ "split": "test"
34
+ },
35
+ {
36
+ "case_id": "production-passage-not-url",
37
+ "company": "synthetic",
38
+ "form_receipt": null,
39
+ "form_status": "unclassified",
40
+ "input_digest": "sha256:efb18047d05174c26aafc52938e0374b37ff00a142a996154502523ab9179ac0",
41
+ "input_form": "unclassified",
42
+ "length_words": 7,
43
+ "split": "test"
44
+ },
45
+ {
46
+ "case_id": "adversarial-unsupported-sso",
47
+ "company": "synthetic",
48
+ "form_receipt": null,
49
+ "form_status": "unclassified",
50
+ "input_digest": "sha256:0acddc94d4ae4bd8f35663d863711ee7c1e9d4192333be1d8aae35efbfe5e404",
51
+ "input_form": "unclassified",
52
+ "length_words": 7,
53
+ "split": "test"
54
+ },
55
+ {
56
+ "case_id": "adversarial-duplicate-guides",
57
+ "company": "synthetic",
58
+ "form_receipt": null,
59
+ "form_status": "unclassified",
60
+ "input_digest": "sha256:81c3019143d778315ff90b630a40af9bf8ffeb65dd51f81c0ab162b1c6201ef0",
61
+ "input_form": "unclassified",
62
+ "length_words": 6,
63
+ "split": "test"
64
+ }
65
+ ],
66
+ "corpus": {
67
+ "digest": "sha256:fa979226c3ead51d5b153a3343b6ef107161b1bb62947f483b10acfa3d86a50d",
68
+ "ref": "synthetic.rerank.corpus-projection@2.0.0",
69
+ "version": "2.0.0"
70
+ },
71
+ "evaluation_id": "rag.rerank.v1",
72
+ "input": {
73
+ "digest": "sha256:5b78e9060f9dcfd963b91820ae2e93306afad9b69b8f9275dfc77ed2a1a796b7",
74
+ "ref": "synthetic.rerank.input-projection@2.0.0",
75
+ "version": "2.0.0"
76
+ },
77
+ "intended_use": "illustrative",
78
+ "interface_version": "sphynx.rag.rerank.v1",
79
+ "label_set": {
80
+ "digest": "sha256:951cd8806fe47a0c52cc630aecb044e726af68835776a8f5b136a3a6c57efa68",
81
+ "ref": "synthetic.rerank.labels@2.0.0",
82
+ "version": "2.0.0"
83
+ },
84
+ "metric_profile": "rag.rerank.metric.v1",
85
+ "protocol_revision": "rag.rerank.v1",
86
+ "ref": "synthetic.rerank.fixture@2.0.0",
87
+ "required_input_form": "any",
88
+ "required_label_profile": "passage_qrels_and_evidence_groups",
89
+ "schema_version": "sphynx.benchmark-release.v1",
90
+ "scope": "component",
91
+ "version": "2.0.0"
92
+ }
93
+ ],
94
+ "independent_assets_archived": false,
95
+ "label_sets": [
96
+ {
97
+ "authority": "synthetic_fixture",
98
+ "cases": [
99
+ {
100
+ "case_id": "core-onboarding-security",
101
+ "input_digest": "sha256:de96e83e201c629361c57d5831eee6f4e91ce22c9aeea5c1334b6f4cd235ed70",
102
+ "status": "judged"
103
+ },
104
+ {
105
+ "case_id": "core-onboarding-security-paraphrase",
106
+ "input_digest": "sha256:7428d18761ac9c9f0df1f0de1a7cf319bc4461009fcb4d4932115e784fddc483",
107
+ "status": "judged"
108
+ },
109
+ {
110
+ "case_id": "customer-seat-transfer",
111
+ "input_digest": "sha256:0f2fd15f28a8f15e3c3b4eaf09e8b79bcdf5740ffffdd61ef48d742851fb7de9",
112
+ "status": "judged"
113
+ },
114
+ {
115
+ "case_id": "production-passage-not-url",
116
+ "input_digest": "sha256:efb18047d05174c26aafc52938e0374b37ff00a142a996154502523ab9179ac0",
117
+ "status": "judged"
118
+ },
119
+ {
120
+ "case_id": "adversarial-unsupported-sso",
121
+ "input_digest": "sha256:0acddc94d4ae4bd8f35663d863711ee7c1e9d4192333be1d8aae35efbfe5e404",
122
+ "status": "judged"
123
+ },
124
+ {
125
+ "case_id": "adversarial-duplicate-guides",
126
+ "input_digest": "sha256:81c3019143d778315ff90b630a40af9bf8ffeb65dd51f81c0ab162b1c6201ef0",
127
+ "status": "judged"
128
+ }
129
+ ],
130
+ "corpus": {
131
+ "digest": "sha256:fa979226c3ead51d5b153a3343b6ef107161b1bb62947f483b10acfa3d86a50d",
132
+ "ref": "synthetic.rerank.corpus-projection@2.0.0",
133
+ "version": "2.0.0"
134
+ },
135
+ "input": {
136
+ "digest": "sha256:5b78e9060f9dcfd963b91820ae2e93306afad9b69b8f9275dfc77ed2a1a796b7",
137
+ "ref": "synthetic.rerank.input-projection@2.0.0",
138
+ "version": "2.0.0"
139
+ },
140
+ "label_profile": "passage_qrels_and_evidence_groups",
141
+ "lineage": [],
142
+ "missingness": "complete_required",
143
+ "policy_revision": "rag.rerank.synthetic-label-policy.v1",
144
+ "ref": "synthetic.rerank.labels@2.0.0",
145
+ "reference": {
146
+ "digest": "sha256:eef5634b7bb182c18241f146c5dc22d20b44fd65131c0de7344cd070028b6f24",
147
+ "ref": "synthetic.rerank.reference-projection@2.0.0",
148
+ "version": "2.0.0"
149
+ },
150
+ "review_receipts": [],
151
+ "rubric_revision": "rag.rerank.metric.v1",
152
+ "schema_version": "sphynx.label-set-release.v1",
153
+ "version": "2.0.0"
154
+ }
155
+ ],
156
+ "metadata_only": true,
157
+ "projection_version": "sphynx.synthetic-rerank.metadata-projections.v1",
158
+ "schema_version": "sphynx.benchmark-catalog.v1",
159
+ "source": {
160
+ "digest": "sha256:74884a0176738649d85cc8b681323103961939aaa0f3d5f1dd1565ed2e53d86b",
161
+ "ref": "help-center-evidence-mini@2.0.0",
162
+ "version": "2.0.0"
163
+ }
164
+ }