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,169 @@
1
+ """Optional fixed-view snapshot reads with explicit separate credentials.
2
+
3
+ No environment, API-token, cloud-secret, raw-SQL or writer integration exists.
4
+ The operator must provision a fixed-scope read-only role before using this API.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import base64
9
+ from collections.abc import Sequence
10
+ from dataclasses import dataclass, field
11
+ import http.client
12
+ import ipaddress
13
+ import math
14
+ import re
15
+ import ssl
16
+ from urllib.parse import SplitResult, urlencode, urlsplit
17
+
18
+ from ._analytical_values import identifier, parse_json, require
19
+ from .analytical_views import ProjectionEventRef, ProjectionPage, ProjectionResult, ProjectionRow
20
+ from .workflow_contracts import WorkflowContractError
21
+
22
+ _COLUMNS = ('team_id', 'event_id', 'row_id', 'row_hash', 'row_kind', 'record_id',
23
+ 'bank_id', 'release_id', 'cohort_id', 'source_version', 'summary_json',
24
+ 'source_digest', 'plan_digest', 'manifest_digest', 'row_count', 'commit_hash')
25
+ _MAX_RESPONSE = 16 * 1024 * 1024
26
+
27
+
28
+ class ReadOnlyClickHouseError(RuntimeError):
29
+ """Safe connector failure. Credentials and raw response bodies stay hidden."""
30
+ def __init__(self, code: str):
31
+ self.code = code
32
+ super().__init__(code + ': the fixed-view snapshot read could not be verified')
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class ReadOnlyClickHouseConfig:
37
+ endpoint: str = field(repr=False)
38
+ database: str
39
+ view: str
40
+ username: str = field(repr=False)
41
+ password: str = field(repr=False)
42
+ team_id: str
43
+ bank_id: str
44
+ release_id: str
45
+ cohort_id: str
46
+ connect_proxy: str | None = field(default=None, repr=False)
47
+ timeout: float = 30
48
+ allow_local_http: bool = False
49
+ _target: SplitResult = field(init=False, repr=False)
50
+ _port: int = field(init=False, repr=False)
51
+ _proxy: tuple[str, int] | None = field(init=False, repr=False)
52
+
53
+ def __post_init__(self) -> None:
54
+ try:
55
+ require(type(self.endpoint) is str and 1 <= len(self.endpoint) <= 2048
56
+ and not any(char.isspace() or ord(char) < 32 for char in self.endpoint))
57
+ target = urlsplit(self.endpoint)
58
+ require(target.hostname and target.path in ('', '/') and not target.username
59
+ and not target.password and not target.query and not target.fragment)
60
+ require(type(self.allow_local_http) is bool)
61
+ require(target.scheme == 'https' or self.allow_local_http and target.scheme == 'http'
62
+ and target.hostname in ('localhost', '127.0.0.1', '::1'))
63
+ port = target.port or (443 if target.scheme == 'https' else 80)
64
+ require(1 <= port <= 65535)
65
+ require(type(self.database) is str and re.fullmatch('[a-z][a-z0-9_]{0,63}', self.database))
66
+ require(type(self.view) is str and re.fullmatch('sphynx_ro_[a-z][a-z0-9_]{0,47}', self.view))
67
+ for name in ('team_id', 'bank_id', 'release_id', 'cohort_id'):
68
+ identifier(getattr(self, name))
69
+ require(type(self.timeout) in (int, float) and math.isfinite(self.timeout) and 0 < self.timeout <= 120)
70
+ require(type(self.username) is str and type(self.password) is str
71
+ and bool(self.username) and bool(self.password) and ':' not in self.username
72
+ and len(self.username.encode('utf-8')) <= 256 and len(self.password.encode('utf-8')) <= 4096
73
+ and not any(ord(char) < 32 or ord(char) == 127 for char in self.username + self.password))
74
+ proxy_address = None
75
+ if self.connect_proxy is not None:
76
+ require(type(self.connect_proxy) is str and len(self.connect_proxy) <= 2048
77
+ and not any(char.isspace() or ord(char) < 32 for char in self.connect_proxy))
78
+ proxy = urlsplit(self.connect_proxy)
79
+ address = ipaddress.ip_address(proxy.hostname or '')
80
+ require(proxy.scheme == 'http' and address.version == 4
81
+ and any(address in ipaddress.ip_network(net) for net in
82
+ ('10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16'))
83
+ and proxy.port and proxy.path in ('', '/') and not proxy.username
84
+ and not proxy.password and not proxy.query and not proxy.fragment and target.scheme == 'https')
85
+ assert proxy.port is not None
86
+ proxy_address = (str(address), proxy.port)
87
+ object.__setattr__(self, '_target', target)
88
+ object.__setattr__(self, '_port', port)
89
+ object.__setattr__(self, '_proxy', proxy_address)
90
+ except (WorkflowContractError, ValueError, TypeError, UnicodeError, AttributeError):
91
+ raise ReadOnlyClickHouseError('invalid_readonly_clickhouse_config') from None
92
+
93
+
94
+ class ClickHouseReader:
95
+ """Read only known references through one operator-approved fixed view."""
96
+ def __init__(self, config: ReadOnlyClickHouseConfig):
97
+ if not isinstance(config, ReadOnlyClickHouseConfig):
98
+ raise ReadOnlyClickHouseError('invalid_readonly_clickhouse_config')
99
+ self._config = config
100
+
101
+ def __repr__(self) -> str:
102
+ return f'ClickHouseReader(database={self._config.database!r}, view={self._config.view!r})'
103
+
104
+ def _read(self, refs: tuple[ProjectionEventRef, ...]) -> str:
105
+ config = self._config
106
+ sql = ('SELECT DISTINCT ' + ','.join(_COLUMNS) + ' FROM ' + config.database + '.' + config.view
107
+ + ' WHERE team_id={team:String} AND bank_id={bank:String} AND release_id={release:String}'
108
+ + " AND (cohort_id={cohort:String} OR (row_kind='audit' AND cohort_id=''))"
109
+ + " AND event_id IN splitByChar(',',{events:String}) LIMIT 4097 FORMAT JSONEachRow")
110
+ query = {'database': config.database, 'readonly': '1', 'max_execution_time': '15',
111
+ 'max_result_rows': '4097', 'output_format_json_quote_64bit_integers': '0',
112
+ 'param_team': config.team_id, 'param_bank': config.bank_id,
113
+ 'param_release': config.release_id, 'param_cohort': config.cohort_id,
114
+ 'param_events': ','.join(ref.event_id for ref in refs)}
115
+ connection: http.client.HTTPConnection | None = None
116
+ try:
117
+ assert config._target.hostname is not None
118
+ if config._target.scheme == 'https':
119
+ host, port = config._proxy or (config._target.hostname, config._port)
120
+ connection = http.client.HTTPSConnection(host, port, timeout=config.timeout,
121
+ context=ssl.create_default_context())
122
+ if config._proxy:
123
+ connection.set_tunnel(config._target.hostname, config._port)
124
+ else:
125
+ connection = http.client.HTTPConnection(config._target.hostname, config._port, timeout=config.timeout)
126
+ authorization = 'Basic ' + base64.b64encode((config.username + ':' + config.password).encode('utf-8')).decode('ascii')
127
+ connection.request('POST', '/?' + urlencode(query), body=sql.encode('utf-8'),
128
+ headers={'Authorization': authorization, 'Content-Type': 'text/plain; charset=utf-8'})
129
+ with connection.getresponse() as response:
130
+ if response.status != 200:
131
+ raise ReadOnlyClickHouseError('readonly_clickhouse_unavailable')
132
+ raw = response.read(_MAX_RESPONSE + 1)
133
+ if len(raw) > _MAX_RESPONSE:
134
+ raise ReadOnlyClickHouseError('readonly_clickhouse_response_too_large')
135
+ return raw.decode('utf-8')
136
+ except (OSError, ValueError, UnicodeError, http.client.HTTPException):
137
+ raise ReadOnlyClickHouseError('readonly_clickhouse_unavailable') from None
138
+ finally:
139
+ if connection is not None:
140
+ connection.close()
141
+
142
+ def read_events(self, refs: Sequence[ProjectionEventRef]) -> ProjectionPage:
143
+ """Return historical snapshots. No current review/acceptance authority."""
144
+ require(isinstance(refs, (tuple, list)) and 1 <= len(refs) <= 25,
145
+ 'read_events needs from 1 to 25 known source references')
146
+ require(all(isinstance(ref, ProjectionEventRef) for ref in refs))
147
+ frozen = tuple(refs)
148
+ require(len({ref.event_id for ref in frozen}) == len(frozen), 'source references must be unique')
149
+ raw = self._read(frozen)
150
+ try:
151
+ expected = {ref.event_id: ref.source_digest for ref in frozen}
152
+ groups: dict[str, list[ProjectionRow]] = {ref.event_id: [] for ref in frozen}
153
+ seen: set[tuple[str, str]] = set()
154
+ for line in raw.splitlines():
155
+ if not line.strip():
156
+ continue
157
+ require(len(seen) < 4096)
158
+ row = ProjectionRow.from_dict(parse_json(line, maximum=_MAX_RESPONSE))
159
+ pair = (row.event_id, row.row_id)
160
+ require(row.event_id in expected and row.source_digest == expected[row.event_id] and pair not in seen)
161
+ groups[row.event_id].append(row); seen.add(pair)
162
+ items = tuple(ProjectionResult(ref.event_id, ref.source_digest,
163
+ 'available' if groups[ref.event_id] else 'unavailable',
164
+ None if groups[ref.event_id] else 'projection_not_committed', tuple(groups[ref.event_id])) for ref in frozen)
165
+ config = self._config
166
+ return ProjectionPage(config.team_id, config.bank_id, config.release_id, config.cohort_id,
167
+ 'committed_snapshot_requires_current_service_check', items)
168
+ except (WorkflowContractError, ValueError, TypeError, KeyError, UnicodeError, RecursionError):
169
+ raise ReadOnlyClickHouseError('invalid_readonly_clickhouse_response') from None
sphynx/client.py ADDED
@@ -0,0 +1,486 @@
1
+ """Thin authenticated client for hosted submissions and saved grading passes.
2
+
3
+ Configuration: explicit arguments, SPHYNX_API_URL / SPHYNX_TOKEN, or the private
4
+ ~/.sphynx/client.json file. A hosted server must be deployed before this can run.
5
+ """
6
+ from __future__ import annotations
7
+ import json
8
+ import math
9
+ import os
10
+ import re
11
+ import time
12
+ import urllib.error
13
+ import urllib.request
14
+ import uuid
15
+ from pathlib import Path
16
+ from typing import TYPE_CHECKING
17
+
18
+ if TYPE_CHECKING:
19
+ from .diagnostics import Diagnostic
20
+ from .progress import ProgressView
21
+ from .audit_access import ReviewHistoryPage, ReviewOperationReceipt, ReviewTargetPage, TargetReviewPage
22
+ from .audit_contracts import BankRelease, ReviewContribution
23
+ from .audit_requests import BankReleaseDeclaration, BankReleaseTransition, ReviewAdjudication
24
+ from .analytical_views import EvaluationBoardPage, ProjectionPage
25
+ from .pr_evaluations import PrEvaluationRequest
26
+ from ._pr_client import PrEvaluation
27
+ from urllib.parse import quote, urlencode, urlsplit
28
+ from .http_transport import open_no_redirect
29
+
30
+ _MAX_RESPONSE = 4_194_304
31
+ _ID = re.compile(r'^exp_[0-9a-f]{32}$')
32
+
33
+ def _unique_pairs(pairs):
34
+ value = {}
35
+ for key, item in pairs:
36
+ if key in value: raise ValueError('duplicate JSON field')
37
+ value[key] = item
38
+ return value
39
+
40
+ def _release_ref(value):
41
+ if not isinstance(value, str) or not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9._:@/-]{0,255}', value):
42
+ raise ValueError('release must be a stable catalog identifier')
43
+ return quote(value, safe='')
44
+ def _saved_id(value, prefix):
45
+ if not isinstance(value, str) or not re.fullmatch(prefix + r'_[0-9a-f]{32}', value):
46
+ raise ValueError(f'invalid {prefix} ID')
47
+ return value
48
+
49
+ def _retry_key(value):
50
+ key = uuid.uuid4().hex if value is None else value
51
+ if not isinstance(key, str) or not re.fullmatch('[A-Za-z0-9_-]{16,100}', key):
52
+ raise ValueError('idempotency_key must contain 16 to 100 letters, numbers, underscores or hyphens')
53
+ return key
54
+
55
+ def _response_matches(actual, expected, kind):
56
+ if actual != expected:
57
+ raise HostedClientError('invalid_server_response', f'the server returned a different {kind}')
58
+
59
+ def _unchanged_links(previous, fresh):
60
+ for name in ('run_id', 'evaluation_id', 'experiment_id'):
61
+ if previous.get(name) is not None and fresh.get(name) != previous[name]:
62
+ raise HostedClientError('invalid_server_response', 'the server changed a bound record identity')
63
+
64
+ def _validate_links(payload):
65
+ for name, prefix in (('run_id', 'run'), ('evaluation_id', 'eval'), ('experiment_id', 'exp')):
66
+ if payload.get(name) is not None:
67
+ try: _saved_id(payload[name], prefix)
68
+ except ValueError:
69
+ raise HostedClientError('invalid_server_response', 'the server returned an invalid linked identity') from None
70
+
71
+ class HostedClientError(RuntimeError):
72
+ def __init__(self, code: str, message: str, *, experiment_id: str | None = None, handle: object | None = None):
73
+ self.code, self.experiment_id, self.handle = code, experiment_id, handle
74
+ super().__init__(f'{code}: {message}')
75
+
76
+ @property
77
+ def diagnostic(self) -> Diagnostic:
78
+ from .diagnostics import explain_error
79
+ return explain_error(self)
80
+
81
+ class HostedResult:
82
+ """Native hosted report presentation; also available as HostedReport."""
83
+ def __init__(self, payload: dict): self._payload = payload
84
+ def to_dict(self): return json.loads(json.dumps(self._payload, allow_nan=False))
85
+ def summary(self):
86
+ text = self._payload.get('summary')
87
+ if not isinstance(text, str): raise HostedClientError('invalid_server_response', 'the report has no readable summary')
88
+ return text
89
+
90
+ class Experiment:
91
+ """Native hosted submission handle; also available as HostedSubmission.
92
+
93
+ Native IDs, wire fields, polling, and representations keep their existing
94
+ identity. This handle coordinates work; it is not a run or an assessment.
95
+ """
96
+ def __init__(self, client: Client, payload: dict):
97
+ if not isinstance(payload, dict) or not isinstance(payload.get('id'), str) or not _ID.fullmatch(payload['id']):
98
+ raise HostedClientError('invalid_server_response', 'the server did not return an experiment ID')
99
+ _validate_links(payload)
100
+ self._client, self._record = client, payload
101
+ self.id = payload['id']
102
+ @property
103
+ def progress(self) -> ProgressView:
104
+ from .progress import _hosted_progress
105
+ return _hosted_progress(self._record, 'submission')
106
+ @property
107
+ def status(self): return self._record.get('status')
108
+ @property
109
+ def head_sha(self): return self._record.get('head_sha')
110
+ @property
111
+ def run_id(self): return self._record.get('run_id')
112
+ @property
113
+ def evaluation_id(self): return self._record.get('evaluation_id')
114
+ def refresh(self):
115
+ fresh = self._client.get_experiment(self.id)
116
+ _response_matches(fresh.id, self.id, 'submission')
117
+ _unchanged_links(self._record, fresh._record)
118
+ self._record = fresh._record
119
+ return self
120
+ def wait(self, *, timeout: float = 900, poll_interval: float = 2, on_progress=None):
121
+ for value in (timeout, poll_interval):
122
+ if type(value) not in (int, float) or not math.isfinite(value) or value <= 0:
123
+ raise ValueError('timeout and poll_interval must be finite positive seconds')
124
+ deadline, previous = time.monotonic() + timeout, None
125
+ while True:
126
+ state = self.status
127
+ if state != previous:
128
+ if on_progress is not None: on_progress(state)
129
+ previous = state
130
+ if state == 'completed':
131
+ return HostedResult(self._client._request('GET', f'/v1/experiments/{self.id}/result'))
132
+ if state == 'failed':
133
+ error = self._record.get('error') or {}
134
+ raise HostedClientError(error.get('code', 'experiment_failed'),
135
+ f"experiment {self.id} failed at {error.get('stage', 'unknown')}; inspect its status for details", experiment_id=self.id)
136
+ remaining = deadline - time.monotonic()
137
+ if remaining <= 0:
138
+ raise HostedClientError('wait_timeout', f'experiment {self.id} continues remotely; use get_experiment() to resume waiting', experiment_id=self.id, handle=self)
139
+ time.sleep(min(poll_interval, remaining))
140
+ self.refresh()
141
+ def to_dict(self): return json.loads(json.dumps(self._record, allow_nan=False))
142
+ def __repr__(self): return f'Experiment(id={self.id!r}, status={self.status!r})'
143
+
144
+ class HostedRun:
145
+ """Captured remote execution. Grading this run does not execute its candidate."""
146
+ def __init__(self, client: Client, payload: dict):
147
+ try: identity = _saved_id(payload.get('id'), 'run')
148
+ except (AttributeError, ValueError):
149
+ raise HostedClientError('invalid_server_response', 'the server did not return a run ID') from None
150
+ _validate_links(payload)
151
+ self._client, self._record, self.id = client, payload, identity
152
+ @property
153
+ def progress(self) -> ProgressView:
154
+ from .progress import _hosted_progress
155
+ return _hosted_progress(self._record, 'execution')
156
+ @property
157
+ def status(self): return self._record.get('status')
158
+ def refresh(self):
159
+ fresh = self._client.get_run(self.id)
160
+ if fresh.id != self.id:
161
+ raise HostedClientError('invalid_server_response', 'the server returned a different run')
162
+ _unchanged_links(self._record, fresh._record)
163
+ self._record = fresh._record
164
+ return self
165
+ def grade(self, *, idempotency_key=None):
166
+ return self._client.grade(self.id, idempotency_key=idempotency_key)
167
+ def to_dict(self): return json.loads(json.dumps(self._record, allow_nan=False))
168
+ def __repr__(self): return f'HostedRun(id={self.id!r}, status={self.status!r})'
169
+
170
+ class HostedEvaluation:
171
+ """A durable remote grading pass; also available as HostedGradingPass."""
172
+ def __init__(self, client: Client, payload: dict):
173
+ try: identity = _saved_id(payload.get('id'), 'eval')
174
+ except (AttributeError, ValueError):
175
+ raise HostedClientError('invalid_server_response', 'the server did not return an evaluation ID') from None
176
+ _validate_links(payload)
177
+ self._client, self._record, self.id = client, payload, identity
178
+ @property
179
+ def progress(self) -> ProgressView:
180
+ from .progress import _hosted_progress
181
+ return _hosted_progress(self._record, 'grading')
182
+ @property
183
+ def status(self): return self._record.get('status')
184
+ @property
185
+ def run_id(self): return self._record.get('run_id')
186
+ def refresh(self):
187
+ fresh = self._client.get_evaluation(self.id)
188
+ if fresh.id != self.id:
189
+ raise HostedClientError('invalid_server_response', 'the server returned a different evaluation')
190
+ _unchanged_links(self._record, fresh._record)
191
+ self._record = fresh._record
192
+ return self
193
+ def wait(self, *, timeout=900, poll_interval=2, on_progress=None):
194
+ for value in (timeout, poll_interval):
195
+ if type(value) not in (int, float) or not math.isfinite(value) or value <= 0:
196
+ raise ValueError('timeout and poll_interval must be finite positive seconds')
197
+ deadline, previous = time.monotonic() + timeout, None
198
+ while True:
199
+ state = self.status
200
+ if state != previous:
201
+ if on_progress is not None: on_progress(state)
202
+ previous = state
203
+ if state == 'completed':
204
+ return HostedResult(self._client._request('GET', f'/v1/evaluations/{self.id}/result'))
205
+ if state == 'failed':
206
+ error = self._record.get('error') or {}
207
+ raise HostedClientError(error.get('code', 'evaluation_failed'),
208
+ f'evaluation {self.id} failed during grading; inspect its status for details')
209
+ remaining = deadline - time.monotonic()
210
+ if remaining <= 0:
211
+ raise HostedClientError('wait_timeout',
212
+ f'evaluation {self.id} continues remotely; use get_evaluation() to resume waiting', handle=self)
213
+ time.sleep(min(poll_interval, remaining))
214
+ self.refresh()
215
+ def to_dict(self): return json.loads(json.dumps(self._record, allow_nan=False))
216
+ def __repr__(self): return f'HostedEvaluation(id={self.id!r}, status={self.status!r})'
217
+
218
+ class ComparisonRef:
219
+ """Saved comparison of owned reports. Retrieval never runs or grades a candidate."""
220
+ def __init__(self, client: Client, payload: dict):
221
+ try: identity = _saved_id(payload.get('id'), 'cmp')
222
+ except (AttributeError, ValueError):
223
+ raise HostedClientError('invalid_server_response', 'the server did not return a comparison ID') from None
224
+ self._client, self._record, self.id = client, payload, identity
225
+ @property
226
+ def status(self): return self._record.get('status')
227
+ def result(self):
228
+ payload = self._client._request('GET', f'/v1/comparisons/{self.id}/result')
229
+ if payload.get('comparison_id') != self.id or payload.get('schema_version') != 'sphynx.hosted.saved-comparison.v1':
230
+ raise HostedClientError('invalid_server_response', 'the server returned a different comparison')
231
+ return HostedResult(payload)
232
+ def summary(self): return self.result().summary()
233
+ def to_dict(self): return json.loads(json.dumps(self._record, allow_nan=False))
234
+ def __repr__(self): return f'ComparisonRef(id={self.id!r}, status={self.status!r})'
235
+
236
+ class Client:
237
+ def __init__(self, api_url: str | None = None, token: str | None = None, *, timeout: float = 120,
238
+ config_path: str | Path | None = None, allow_local_http: bool = False, retries: int = 2):
239
+ config = {}
240
+ path = Path(config_path) if config_path else Path.home() / '.sphynx' / 'client.json'
241
+ if (api_url is None or token is None) and path.exists():
242
+ try:
243
+ if path.stat().st_size > 16_384: raise ValueError('size')
244
+ config = json.loads(path.read_bytes())
245
+ if not isinstance(config, dict) or set(config) != {'api_url', 'token'}: raise ValueError('shape')
246
+ except (OSError, ValueError):
247
+ raise HostedClientError('invalid_client_config', 'the private client configuration is invalid') from None
248
+ api_url = api_url or os.environ.get('SPHYNX_API_URL') or config.get('api_url')
249
+ token = token or os.environ.get('SPHYNX_TOKEN') or config.get('token')
250
+ if not isinstance(api_url, str) or not isinstance(token, str) or not token:
251
+ raise HostedClientError('client_not_configured', 'configure a hosted API URL and your Sphynx token')
252
+ try: parsed = urlsplit(api_url)
253
+ except ValueError: raise HostedClientError('invalid_api_url', 'use a trusted HTTPS API origin') from None
254
+ if (parsed.username or parsed.password or parsed.query or parsed.fragment or parsed.path not in ('', '/')
255
+ or not parsed.hostname or any(c.isspace() or ord(c) < 32 for c in api_url)):
256
+ raise HostedClientError('invalid_api_url', 'use a trusted HTTPS API origin without a path or credentials')
257
+ if parsed.scheme != 'https' and not (allow_local_http and parsed.scheme == 'http' and parsed.hostname in ('127.0.0.1', '::1', 'localhost')):
258
+ raise HostedClientError('invalid_api_url', 'HTTPS is required; local HTTP must be explicitly enabled for development')
259
+ if not 32 <= len(token) <= 256 or any(c.isspace() for c in token):
260
+ raise HostedClientError('invalid_client_token', 'use an operator-issued Sphynx token')
261
+ if type(timeout) not in (int, float) or not math.isfinite(timeout) or timeout <= 0:
262
+ raise ValueError('timeout must be finite positive seconds')
263
+ if type(retries) is not int or not 0 <= retries <= 4: raise ValueError('retries must be between zero and four')
264
+ self.api_url, self._token, self.timeout, self.retries = api_url.rstrip('/'), token, timeout, retries
265
+ def __repr__(self): return f'Client(api_url={self.api_url!r})'
266
+ def _request(self, method, path, payload=None, *, retry_key=None, deadline=None):
267
+ if deadline is not None and (type(deadline) not in (int, float) or not math.isfinite(deadline)):
268
+ raise ValueError('request deadline must be a finite monotonic time')
269
+ def remaining():
270
+ if deadline is None: return None
271
+ value = deadline - time.monotonic()
272
+ if value <= 0: raise HostedClientError('wait_timeout', 'the read budget expired; retain the original operation handle')
273
+ return value
274
+ def pause(seconds):
275
+ budget = remaining()
276
+ time.sleep(seconds if budget is None else min(seconds, budget))
277
+ body = None if payload is None else json.dumps(payload, separators=(',', ':'), allow_nan=False).encode()
278
+ headers = {'Authorization': 'Bearer ' + self._token, 'Accept': 'application/json', 'Content-Type': 'application/json'}
279
+ if retry_key: headers['Idempotency-Key'] = retry_key
280
+ request = urllib.request.Request(self.api_url + path, data=body, headers=headers, method=method)
281
+ for attempt in range(self.retries + 1):
282
+ budget = remaining()
283
+ socket_timeout = self.timeout if budget is None else min(self.timeout, budget)
284
+ try:
285
+ with open_no_redirect(request, timeout=socket_timeout) as response:
286
+ raw = response.read(_MAX_RESPONSE + 1)
287
+ except urllib.error.HTTPError as exc:
288
+ status = exc.code
289
+ try: raw = exc.read(16_385)
290
+ finally: exc.close()
291
+ if status in (429, 500, 502, 503, 504) and attempt < self.retries:
292
+ pause(min(2 ** attempt, 4)); continue
293
+ try: error = json.loads(raw, object_pairs_hook=_unique_pairs).get('error', {})
294
+ except (ValueError, AttributeError, RecursionError): error = {}
295
+ code = error.get('code') if isinstance(error, dict) else None
296
+ safe = code if isinstance(code, str) and re.fullmatch('[a-z_]{1,64}', code) else 'hosted_request_failed'
297
+ message = ('authentication failed; provision or renew your Sphynx token' if status == 401
298
+ else 'submission or access was rejected; check repository and suite registration' if status == 403
299
+ else f'the hosted API returned HTTP {status}; inspect the experiment status or retry')
300
+ raise HostedClientError(safe, message) from None
301
+ except (urllib.error.URLError, OSError, TimeoutError):
302
+ if attempt < self.retries: pause(min(2 ** attempt, 4)); continue
303
+ raise HostedClientError('hosted_transport_failed', 'the hosted API could not be reached; retry with the same submission key') from None
304
+ remaining()
305
+ if len(raw) > _MAX_RESPONSE: raise HostedClientError('invalid_server_response', 'the hosted response exceeds its size limit')
306
+ try:
307
+ value = json.loads(raw, object_pairs_hook=_unique_pairs, parse_constant=lambda _: (_ for _ in ()).throw(ValueError('nonfinite')))
308
+ if not isinstance(value, dict): raise ValueError('shape')
309
+ except (ValueError, RecursionError): raise HostedClientError('invalid_server_response', 'the hosted API returned invalid JSON') from None
310
+ return value
311
+ @staticmethod
312
+ def list_evals():
313
+ """Installed contract discovery, offline; not hosted availability/access."""
314
+ from .evals import list_evals
315
+ return list_evals()
316
+
317
+ @staticmethod
318
+ def describe_eval(evaluation_id: str):
319
+ """Describe an installed definition without any authenticated request."""
320
+ from .evals import describe_eval
321
+ return describe_eval(evaluation_id)
322
+
323
+ def list_benchmarks(self):
324
+ """Authenticated public metadata discovery; does not run a candidate."""
325
+ return self._request('GET', '/v1/benchmarks')
326
+
327
+ def get_benchmark(self, release: str):
328
+ """Inspect release membership and eligibility, without private inputs or labels."""
329
+ return self._request('GET', '/v1/benchmarks/' + _release_ref(release))
330
+
331
+ def get_label_release(self, release: str):
332
+ """Inspect declared label provenance; metadata does not prove review or approval."""
333
+ return self._request('GET', '/v1/label-releases/' + _release_ref(release))
334
+ def evaluate(self, *, candidate_pr: str, suite: str = 'rag.rerank.v1', baseline: str = 'supplied-score:v1',
335
+ idempotency_key: str | None = None, parameters: dict | None = None,
336
+ expected_head_sha: str | None = None, candidate_method: str | None = None,
337
+ expected_baseline_sha: str | None = None) -> Experiment:
338
+ """Submit a PR and optional named method; a full commit SHA can guard its head."""
339
+ if candidate_method is not None:
340
+ if not isinstance(candidate_method, str) or not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9._:-]{0,127}', candidate_method):
341
+ raise ValueError('candidate_method must be a bounded ASCII stable identifier')
342
+ if expected_head_sha is not None:
343
+ if not isinstance(expected_head_sha, str) or not re.fullmatch(r'[0-9a-fA-F]{40}', expected_head_sha):
344
+ raise ValueError('expected_head_sha must be a complete 40-character hexadecimal commit SHA')
345
+ expected_head_sha = expected_head_sha.lower()
346
+ if expected_baseline_sha is not None:
347
+ if baseline != 'registered-main-reflex-lexical:v1':
348
+ raise ValueError('expected_baseline_sha requires the registered main baseline')
349
+ if not isinstance(expected_baseline_sha, str) or not re.fullmatch(r'[0-9a-fA-F]{40}', expected_baseline_sha):
350
+ raise ValueError('expected_baseline_sha must be a complete 40-character hexadecimal commit SHA')
351
+ expected_baseline_sha = expected_baseline_sha.lower()
352
+ key = _retry_key(idempotency_key)
353
+ payload = {'candidate_pr': candidate_pr, 'suite': suite, 'baseline': baseline}
354
+ if parameters is not None:
355
+ if not isinstance(parameters, dict): raise ValueError('parameters must be an object')
356
+ payload['parameters'] = parameters
357
+ if expected_head_sha is not None:
358
+ payload['expected_head_sha'] = expected_head_sha
359
+ if candidate_method is not None:
360
+ payload['candidate_method'] = candidate_method
361
+ if expected_baseline_sha is not None:
362
+ payload['expected_baseline_sha'] = expected_baseline_sha
363
+ return Experiment(self, self._request('POST', '/v1/experiments', payload, retry_key=key))
364
+ def get_experiment(self, experiment_id: str) -> Experiment:
365
+ if not isinstance(experiment_id, str) or not _ID.fullmatch(experiment_id): raise ValueError('invalid experiment ID')
366
+ result = Experiment(self, self._request('GET', '/v1/experiments/' + experiment_id))
367
+ _response_matches(result.id, experiment_id, 'submission')
368
+ return result
369
+ # Equivalent names retain exact signatures, validation, and native wire
370
+ # behavior. They do not add a shared-workflow hosted execution adapter.
371
+ submit = evaluate
372
+ get_submission = get_experiment
373
+ def get_run(self, run_id: str) -> HostedRun:
374
+ identity = _saved_id(run_id, 'run')
375
+ result = HostedRun(self, self._request('GET', '/v1/runs/' + identity))
376
+ _response_matches(result.id, identity, 'run')
377
+ return result
378
+ def grade(self, run_id: str, *, idempotency_key: str | None = None) -> HostedEvaluation:
379
+ """Grade an owned saved capture again with its pinned grader; no candidate run."""
380
+ identity = _saved_id(run_id, 'run')
381
+ result = HostedEvaluation(self, self._request('POST', '/v1/runs/' + identity + '/evaluations',
382
+ {}, retry_key=_retry_key(idempotency_key)))
383
+ if result.run_id is not None:
384
+ _response_matches(result.run_id, identity, 'grading run')
385
+ return result
386
+ def get_evaluation(self, evaluation_id: str) -> HostedEvaluation:
387
+ identity = _saved_id(evaluation_id, 'eval')
388
+ result = HostedEvaluation(self, self._request('GET', '/v1/evaluations/' + identity))
389
+ _response_matches(result.id, identity, 'grading pass')
390
+ return result
391
+ def compare_saved(self, evaluation_a: str, evaluation_b: str, *, lane_a='candidate', lane_b='candidate') -> ComparisonRef:
392
+ """Compare two existing owned evaluations; no candidate run or new grading."""
393
+ a, b = _saved_id(evaluation_a, 'eval'), _saved_id(evaluation_b, 'eval')
394
+ if lane_a not in ('candidate', 'baseline') or lane_b not in ('candidate', 'baseline'):
395
+ raise ValueError('comparison lanes must be candidate or baseline')
396
+ return ComparisonRef(self, self._request('POST', '/v1/comparisons', {
397
+ 'evaluation_a': a, 'evaluation_b': b, 'lane_a': lane_a, 'lane_b': lane_b}))
398
+ def get_comparison(self, comparison_id: str) -> ComparisonRef:
399
+ identity = _saved_id(comparison_id, 'cmp')
400
+ record = self._request('GET', '/v1/comparisons/' + identity)
401
+ result = ComparisonRef(self, record)
402
+ if result.id != identity:
403
+ raise HostedClientError('invalid_server_response', 'the server returned a different comparison')
404
+ return result
405
+ def get_bank_release(self, bank_id: str, release_id: str) -> BankRelease:
406
+ """Read current permitted metadata/eligibility for a known selected bank."""
407
+ from ._audit_client import release
408
+ return release(self, bank_id, release_id)
409
+
410
+ def get_bank_review_targets(self, bank_id: str, release_id: str, *, after: str | None = None,
411
+ through_version: int | None = None, limit: int = 20) -> ReviewTargetPage:
412
+ """Read one protected page. No global catalog or automatic pagination."""
413
+ from ._audit_client import targets
414
+ return targets(self, bank_id, release_id, after=after, through_version=through_version, limit=limit)
415
+
416
+ def get_bank_review_history(self, bank_id: str, release_id: str, *, after: int = 0,
417
+ through_version: int | None = None, limit: int | None = None,
418
+ protected: bool = False) -> ReviewHistoryPage:
419
+ """Read metadata, or explicitly request operator-only private history."""
420
+ from ._audit_client import history
421
+ return history(self, bank_id, release_id, after=after, through_version=through_version,
422
+ limit=limit, protected=protected)
423
+
424
+ def get_bank_review_details(self, bank_id: str, release_id: str, target_id: str, *, after: int = 0,
425
+ through_version: int | None = None, limit: int = 10) -> TargetReviewPage:
426
+ """Read authorized judgments/protocol and the current contribution-set digest."""
427
+ from ._audit_client import details
428
+ return details(self, bank_id, release_id, target_id, after=after, through_version=through_version, limit=limit)
429
+
430
+ def create_bank_release(self, bank_id: str, request: BankReleaseDeclaration) -> ReviewOperationReceipt:
431
+ """Submit an operator declaration with its original explicit key."""
432
+ from ._audit_client import write
433
+ return write(self, bank_id, None, 'create', request)
434
+
435
+ def contribute_bank_review(self, bank_id: str, release_id: str, request: ReviewContribution) -> ReviewOperationReceipt:
436
+ """Submit a version-guarded review; the service supplies reviewer identity."""
437
+ from ._audit_client import write
438
+ return write(self, bank_id, release_id, 'contribute', request)
439
+
440
+ def adjudicate_bank_review(self, bank_id: str, release_id: str, request: ReviewAdjudication) -> ReviewOperationReceipt:
441
+ """Submit an independent decision bound to the exact current review set."""
442
+ from ._audit_client import write
443
+ return write(self, bank_id, release_id, 'adjudicate', request)
444
+
445
+ def transition_bank_release(self, bank_id: str, release_id: str, request: BankReleaseTransition) -> ReviewOperationReceipt:
446
+ """Request operator hold/resume/withdraw with the original version/key."""
447
+ from ._audit_client import write
448
+ return write(self, bank_id, release_id, 'transition', request)
449
+
450
+ def get_evaluation_board(self, bank_id: str, board_id: str, *, limit: int = 20,
451
+ cursor: str | None = None) -> EvaluationBoardPage:
452
+ """Read a selected private board. Retain its cursor with its snapshot."""
453
+ from ._analytical_client import board
454
+ return board(self, bank_id, board_id, limit=limit, cursor=cursor)
455
+ def submit_pr_evaluation(self, request: PrEvaluationRequest, *, idempotency_key: str | None = None) -> PrEvaluation:
456
+ """Submit one server-selected private evaluation plan for exact base/head revisions."""
457
+ from ._pr_client import submit
458
+ return submit(self, request, idempotency_key=idempotency_key)
459
+ def get_pr_evaluation(self, evaluation_id: str) -> PrEvaluation:
460
+ """Read the original owned PR operation without creating more candidate work."""
461
+ from ._pr_client import get
462
+ return get(self, evaluation_id)
463
+
464
+ def get_evaluation_projection(self, bank_id: str, board_id: str, entry_id: str) -> ProjectionPage:
465
+ """Read permitted source summaries for one selected board entry."""
466
+ from ._analytical_client import projection
467
+ return projection(self, bank_id, board_id, entry_id)
468
+
469
+ def leaderboard(self, suite: str = 'rag.rerank.v1', *, include_baseline: bool = False, limit: int = 200):
470
+ """Read owner-scoped saved rankings, grouped by quality and actual runtime."""
471
+ if suite not in ('rag.rerank.v1', 'rag.rerank.public.v1'):
472
+ raise ValueError('suite has no admitted hosted leaderboard')
473
+ if type(include_baseline) is not bool or type(limit) is not int or not 1 <= limit <= 200:
474
+ raise ValueError('include_baseline must be boolean and limit must be from 1 to 200')
475
+ query = urlencode({'suite': suite, 'include_baseline': str(include_baseline).lower(), 'limit': limit})
476
+ board = self._request('GET', '/v1/leaderboards?' + query)
477
+ if board.get('schema_version') != 'sphynx.hosted.leaderboard.v1' or board.get('official') is not False:
478
+ raise HostedClientError('invalid_server_response', 'the server returned an invalid development leaderboard')
479
+ return board
480
+
481
+
482
+ # Direct aliases preserve native type identity and saved/wire compatibility.
483
+ # Existing imports remain supported; no new remote capability is implied.
484
+ HostedSubmission = Experiment
485
+ HostedGradingPass = HostedEvaluation
486
+ HostedReport = HostedResult