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,211 @@
1
+ """Explicit decision callbacks over permitted saved-report snapshots.
2
+
3
+ A verdict records a selected policy's outcome. It does not execute candidates,
4
+ create a grading pass, grant access, or upgrade native evidence authority.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import html
9
+ import inspect
10
+ import json
11
+ import re
12
+ from dataclasses import dataclass, field
13
+ from typing import Any, Mapping, TYPE_CHECKING
14
+
15
+ from .workflow_contracts import (
16
+ BackendDescriptor, ReferenceBinding, WorkflowContractError,
17
+ _object, _text, _thaw, contract_digest,
18
+ )
19
+ from .workflow_registry import CallbackDefinition
20
+
21
+ if TYPE_CHECKING:
22
+ from .workflows import Report
23
+
24
+
25
+ class DecisionPolicyError(WorkflowContractError):
26
+ """A selected decision callback failed or returned an invalid outcome."""
27
+
28
+
29
+ def _reference_dict(reference: ReferenceBinding) -> dict:
30
+ return {"record_id": reference.record_id, "status": reference.status,
31
+ "trust_tier": reference.trust_tier, "run_id": reference.run_id,
32
+ "metadata": _thaw(reference.metadata)}
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class ReportSnapshot:
37
+ """Immutable permitted report data and its native binding at read time."""
38
+
39
+ backend: BackendDescriptor
40
+ reference: ReferenceBinding
41
+ data: Mapping[str, Any]
42
+
43
+ def __post_init__(self) -> None:
44
+ if not isinstance(self.backend, BackendDescriptor) or not isinstance(self.reference, ReferenceBinding):
45
+ raise WorkflowContractError("report snapshot requires native metadata bindings")
46
+ object.__setattr__(self, "data", _object(self.data, "report snapshot"))
47
+
48
+ @property
49
+ def projection_digest(self) -> str:
50
+ return contract_digest(self.data)
51
+
52
+ def to_dict(self) -> dict:
53
+ return {"backend": self.backend.to_dict(), "reference": _reference_dict(self.reference),
54
+ "data": _thaw(self.data)}
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class DecisionContext:
59
+ """Policy inputs. Construction alone does not authenticate their contents."""
60
+
61
+ candidate: ReportSnapshot
62
+ baseline: ReportSnapshot | None = None
63
+ comparison: Any = None
64
+ parameters: Mapping[str, Any] = field(default_factory=dict)
65
+
66
+ def __post_init__(self) -> None:
67
+ if not isinstance(self.candidate, ReportSnapshot):
68
+ raise WorkflowContractError("decision candidate must be ReportSnapshot")
69
+ if self.baseline is not None and not isinstance(self.baseline, ReportSnapshot):
70
+ raise WorkflowContractError("decision baseline must be ReportSnapshot")
71
+ if self.baseline is None and self.comparison is not None:
72
+ raise WorkflowContractError("a comparison requires a baseline report")
73
+ object.__setattr__(self, "comparison", _object({"value": self.comparison}, "comparison")["value"])
74
+ object.__setattr__(self, "parameters", _object(self.parameters, "decision parameters"))
75
+
76
+ def to_dict(self) -> dict:
77
+ return {"candidate": self.candidate.to_dict(),
78
+ "baseline": self.baseline.to_dict() if self.baseline else None,
79
+ "comparison": _thaw(self.comparison), "parameters": _thaw(self.parameters)}
80
+
81
+ @property
82
+ def context_digest(self) -> str:
83
+ return contract_digest(self.to_dict())
84
+
85
+
86
+ @dataclass(frozen=True)
87
+ class DecisionOutcome:
88
+ """An explicit domain outcome and optional binary CI decision.
89
+
90
+ None means the policy did not establish a binary pass/fail result. There is
91
+ no default threshold, aggregation, comparison interpretation, or pass state.
92
+ """
93
+
94
+ outcome: str
95
+ passed: bool | None
96
+ reason: str
97
+ details: Mapping[str, Any] = field(default_factory=dict)
98
+
99
+ def __post_init__(self) -> None:
100
+ _text(self.outcome, "outcome")
101
+ _text(self.reason, "reason")
102
+ if self.passed is not None and type(self.passed) is not bool:
103
+ raise WorkflowContractError("passed must be boolean or None")
104
+ object.__setattr__(self, "details", _object(self.details, "decision details"))
105
+
106
+ def to_dict(self) -> dict:
107
+ return {"outcome": self.outcome, "passed": self.passed,
108
+ "reason": self.reason, "details": _thaw(self.details)}
109
+
110
+
111
+ def _markdown(value: str) -> str:
112
+ inline = " ".join(value.splitlines())
113
+ return re.sub(r"([\\`*_{}\[\]()#+.!|~=\-])", r"\\\1", html.escape(inline, quote=False))
114
+
115
+
116
+ @dataclass(frozen=True)
117
+ class Verdict:
118
+ """A policy-bound result with read-only JSON, Markdown and CI projections."""
119
+
120
+ policy: CallbackDefinition
121
+ context: DecisionContext
122
+ result: DecisionOutcome
123
+
124
+ def __post_init__(self) -> None:
125
+ if not isinstance(self.policy, CallbackDefinition) or self.policy.kind != "decision":
126
+ raise WorkflowContractError("verdict requires a decision policy definition")
127
+ if not isinstance(self.context, DecisionContext) or not isinstance(self.result, DecisionOutcome):
128
+ raise WorkflowContractError("verdict requires decision context and outcome")
129
+
130
+ @property
131
+ def ci_exit_code(self) -> int:
132
+ """0 for explicit pass, 1 for explicit failure, 2 for undetermined."""
133
+ return 2 if self.result.passed is None else (0 if self.result.passed else 1)
134
+
135
+ def to_dict(self) -> dict:
136
+ def identity(snapshot):
137
+ if snapshot is None:
138
+ return None
139
+ return {"backend_id": snapshot.backend.backend_id,
140
+ "backend_digest": snapshot.backend.descriptor_digest,
141
+ "reference": _reference_dict(snapshot.reference),
142
+ "projection_digest": snapshot.projection_digest}
143
+
144
+ return {"schema_version": "sphynx.verdict.v1", "policy": self.policy.to_dict(),
145
+ "context_digest": self.context.context_digest,
146
+ "candidate": identity(self.context.candidate), "baseline": identity(self.context.baseline),
147
+ "parameters": _thaw(self.context.parameters), "result": self.result.to_dict(),
148
+ "ci_exit_code": self.ci_exit_code}
149
+
150
+ def to_json(self) -> str:
151
+ return json.dumps(self.to_dict(), ensure_ascii=False, sort_keys=True, indent=2, allow_nan=False)
152
+
153
+ def to_markdown(self) -> str:
154
+ state = {True: "Requirements met", False: "Requirements not met", None: "Undetermined"}[self.result.passed]
155
+ lines = ["## Verdict", "", "- Decision: " + state,
156
+ "- Outcome: " + _markdown(self.result.outcome),
157
+ "- Policy: " + _markdown(self.policy.callback_id) + " @ " + _markdown(self.policy.version),
158
+ "- Candidate grading: " + _markdown(self.context.candidate.reference.record_id or "unidentified"),
159
+ "- Trust tier: " + _markdown(self.context.candidate.reference.trust_tier), ""]
160
+ lines.extend("> " + _markdown(line) for line in self.result.reason.splitlines())
161
+ return "\n".join(lines) + "\n"
162
+
163
+
164
+ def _snapshot(report: Report) -> ReportSnapshot:
165
+ from .workflows import _reference
166
+
167
+ data = report.to_dict() # Native backend verifies and authorizes this projection.
168
+ backend = report._backend()
169
+ return ReportSnapshot(backend.descriptor, _reference(backend, report.native_ref, "grading"), data)
170
+
171
+
172
+ def decide_report(report: Report, policy_id: str, *, baseline: Report | None = None,
173
+ parameters: Mapping[str, Any] | None = None) -> Verdict:
174
+ """Explicitly invoke a registered synchronous decision policy once.
175
+
176
+ Optional comparison uses Report.compare and keeps the backend's JSON meaning.
177
+ Opaque comparison objects need a backend-supplied to_dict projection. The
178
+ policy owns compatibility, population, missingness and domain decision rules.
179
+ Exceptions remain errors; no successful verdict is manufactured from them.
180
+ """
181
+ from .workflows import Report
182
+
183
+ if not isinstance(report, Report) or (baseline is not None and not isinstance(baseline, Report)):
184
+ raise WorkflowContractError("decision inputs must be shared Report values")
185
+ registry = report.registry
186
+ assert registry is not None
187
+ definition = registry.get_policy(policy_id, kind="decision")
188
+ callback = registry.get_policy_callable(policy_id, kind="decision")
189
+ if inspect.iscoroutinefunction(callback):
190
+ raise DecisionPolicyError("decision callbacks must be synchronous")
191
+ parameters = _object({} if parameters is None else parameters, "decision parameters")
192
+ comparison = report.compare(baseline) if baseline is not None else None
193
+ if callable(getattr(comparison, "to_dict", None)):
194
+ comparison = comparison.to_dict()
195
+ context = DecisionContext(_snapshot(report), _snapshot(baseline) if baseline is not None else None,
196
+ comparison, parameters)
197
+ context_digest = context.context_digest
198
+ try:
199
+ result = callback(context)
200
+ except Exception as exc:
201
+ raise DecisionPolicyError("selected decision policy failed") from exc
202
+ if inspect.iscoroutine(result):
203
+ result.close()
204
+ if not isinstance(result, DecisionOutcome):
205
+ raise DecisionPolicyError("decision callback must return DecisionOutcome")
206
+ if context.context_digest != context_digest:
207
+ raise DecisionPolicyError("decision callback modified its input snapshot")
208
+ if (registry.get_policy(policy_id, kind="decision") != definition or
209
+ registry.get_policy_callable(policy_id, kind="decision") is not callback):
210
+ raise DecisionPolicyError("decision policy binding changed during execution")
211
+ return Verdict(definition, context, result)
@@ -0,0 +1,16 @@
1
+ """Explicit built-in workflow bootstrap, separate from shared orchestration.
2
+
3
+ The factory imports the enabled adapter lazily and returns a fresh operator
4
+ registry. Importing this module neither reads case assets nor executes candidate,
5
+ evaluator, reference, policy, or projection callbacks.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from .workflow_registry import WorkflowRegistry
10
+
11
+
12
+ def default_workflow_registry() -> WorkflowRegistry:
13
+ """Create the default extension bindings without a global mutable store."""
14
+ from .rerank_workflow import build_rerank_registry
15
+
16
+ return build_rerank_registry()
@@ -0,0 +1,212 @@
1
+ """Discover installed extension metadata, then explicitly enable reviewed code.
2
+
3
+ An installed distribution can include ``sphynx_extensions.json`` in its dist-info
4
+ directory and entry points in the ``sphynx.extensions`` group. Discovery never
5
+ loads entry points. An operator must approve the descriptor digest before
6
+ activation. The digest pins metadata; it does not authenticate package code.
7
+ Only enable packages whose installed code the operator already trusts.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import re
13
+ from collections.abc import Iterable, Mapping
14
+ from dataclasses import dataclass, field
15
+ from importlib import metadata
16
+ from typing import Any
17
+
18
+ from .workflow_contracts import (
19
+ WorkflowContractError, _object, _text, _texts, _thaw, contract_digest,
20
+ )
21
+ from .workflow_registry import WorkflowRegistry
22
+
23
+ _MANIFEST = "sphynx_extensions.json"
24
+ _GROUP = "sphynx.extensions"
25
+ _CONTRACT = "sphynx.workflow.v2"
26
+ _FACTORY = re.compile(r"[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*:[A-Za-z_]\w*\Z", re.ASCII)
27
+ _ROLES = {
28
+ "backends": ("list_backends", "backend_id"),
29
+ "schemas": ("list_schemas", "schema_id"),
30
+ "datasets": ("list_datasets", "dataset_id"),
31
+ "providers": ("list_providers", "provider_id"),
32
+ "criteria": ("list_criteria", "criterion_id"),
33
+ "evaluators": ("list_evaluators", "evaluator_id"),
34
+ "metrics": ("list_metrics", "metric_id"),
35
+ "suites": ("list_suites", "suite_id"),
36
+ "policies": ("list_policies", "callback_id"),
37
+ "projections": ("list_projections", "callback_id"),
38
+ }
39
+
40
+
41
+ class ExtensionError(WorkflowContractError):
42
+ """An installed extension is malformed, unapproved or incompatible."""
43
+
44
+
45
+ def _strict_json(raw: str) -> dict[str, Any]:
46
+ def pairs(items):
47
+ result = {}
48
+ for key, value in items:
49
+ if key in result:
50
+ raise ExtensionError("duplicate extension manifest field")
51
+ result[key] = value
52
+ return result
53
+ if type(raw) is not str or len(raw.encode("utf-8")) > 1_048_576:
54
+ raise ExtensionError("extension manifest must be UTF-8 JSON of at most 1 MiB")
55
+ try:
56
+ value = json.loads(raw, object_pairs_hook=pairs,
57
+ parse_constant=lambda value: (_ for _ in ()).throw(ValueError(value)))
58
+ except (ValueError, TypeError) as exc:
59
+ raise ExtensionError(f"invalid extension manifest: {exc}") from None
60
+ if not isinstance(value, dict):
61
+ raise ExtensionError("extension manifest must be an object")
62
+ return value
63
+
64
+
65
+ @dataclass(frozen=True)
66
+ class PackagedExtension:
67
+ """Installed metadata snapshot. This descriptor grants no execution trust."""
68
+
69
+ extension_id: str
70
+ version: str
71
+ implementation_id: str
72
+ distribution: str
73
+ distribution_version: str
74
+ entry_point: str
75
+ factory: str
76
+ contract_version: str
77
+ capabilities: tuple[str, ...] = ()
78
+ required_capabilities: tuple[str, ...] = ()
79
+ registrations: Mapping[str, Any] = field(default_factory=dict)
80
+ metadata: Mapping[str, Any] = field(default_factory=dict)
81
+ _installed_distribution: Any = field(default=None, repr=False, compare=False)
82
+
83
+ def __post_init__(self):
84
+ for name in ("extension_id", "version", "implementation_id", "distribution",
85
+ "distribution_version", "entry_point", "factory", "contract_version"):
86
+ _text(getattr(self, name), name)
87
+ if "." not in self.extension_id and ":" not in self.extension_id:
88
+ raise ExtensionError("extension_id must be namespaced")
89
+ if not _FACTORY.fullmatch(self.factory):
90
+ raise ExtensionError("factory must be an exact module:callable entry point without extras")
91
+ for name in ("capabilities", "required_capabilities"):
92
+ object.__setattr__(self, name, _texts(getattr(self, name), name, namespaced=True))
93
+ if not isinstance(self.registrations, Mapping) or set(self.registrations) - set(_ROLES):
94
+ raise ExtensionError("registrations contains an unknown registry role")
95
+ normalized = {role: list(_texts(values, role)) for role, values in self.registrations.items()}
96
+ object.__setattr__(self, "registrations", _object(normalized, "registrations"))
97
+ object.__setattr__(self, "metadata", _object(self.metadata, "metadata"))
98
+
99
+ def to_dict(self) -> dict[str, Any]:
100
+ return {"schema_version": "sphynx.packaged-extension.v1",
101
+ **{name: getattr(self, name) for name in (
102
+ "extension_id", "version", "implementation_id", "distribution",
103
+ "distribution_version", "entry_point", "factory", "contract_version")},
104
+ "capabilities": list(self.capabilities),
105
+ "required_capabilities": list(self.required_capabilities),
106
+ "registrations": _thaw(self.registrations), "metadata": _thaw(self.metadata)}
107
+
108
+ @property
109
+ def descriptor_digest(self) -> str:
110
+ return contract_digest(self.to_dict())
111
+
112
+
113
+ def _from_distribution(dist: Any) -> tuple[PackagedExtension, ...]:
114
+ raw = dist.read_text(_MANIFEST)
115
+ if raw is None:
116
+ return ()
117
+ manifest = _strict_json(raw)
118
+ if set(manifest) != {"schema_version", "extensions"} or manifest["schema_version"] != "sphynx.extensions.v1":
119
+ raise ExtensionError("unsupported extension manifest envelope")
120
+ rows = manifest["extensions"]
121
+ if not isinstance(rows, list) or not rows:
122
+ raise ExtensionError("extensions must be a nonempty array")
123
+ entries = {}
124
+ for ep in dist.entry_points:
125
+ if ep.group != _GROUP:
126
+ continue
127
+ if ep.name in entries:
128
+ raise ExtensionError("duplicate extension entry point")
129
+ entries[ep.name] = ep
130
+ result = []
131
+ required = {"extension_id", "version", "implementation_id", "entry_point", "contract_version", "registrations"}
132
+ optional = {"capabilities", "required_capabilities", "metadata"}
133
+ for row in rows:
134
+ if not isinstance(row, dict) or not required <= row.keys() or row.keys() - required - optional:
135
+ raise ExtensionError("invalid extension descriptor fields")
136
+ ep = entries.get(row["entry_point"])
137
+ if ep is None:
138
+ raise ExtensionError("extension has no unique installed entry point")
139
+ result.append(PackagedExtension(**row, distribution=dist.metadata["Name"],
140
+ distribution_version=dist.version, factory=ep.value,
141
+ _installed_distribution=dist))
142
+ if len({item.extension_id for item in result}) != len(result):
143
+ raise ExtensionError("duplicate extension ID in distribution")
144
+ return tuple(result)
145
+
146
+
147
+ def discover_extensions(*, distributions: Iterable[Any] | None = None) -> tuple[PackagedExtension, ...]:
148
+ """Read installed metadata only. Never import or load a factory.
149
+
150
+ ``distributions`` permits explicit installed-environment inventories and
151
+ offline tests. Malformed or duplicate declarations fail visibly.
152
+ """
153
+ selected = metadata.distributions() if distributions is None else distributions
154
+ result = [item for dist in selected for item in _from_distribution(dist)]
155
+ if len({item.extension_id for item in result}) != len(result):
156
+ raise ExtensionError("duplicate installed extension ID; select an unambiguous environment")
157
+ return tuple(sorted(result, key=lambda item: item.extension_id))
158
+
159
+
160
+ def _membership(registry: WorkflowRegistry) -> dict[str, set[str]]:
161
+ return {role: {getattr(item, identifier) for item in getattr(registry, method)()}
162
+ for role, (method, identifier) in _ROLES.items()}
163
+
164
+
165
+ def enable_extension(extension: PackagedExtension, registry: WorkflowRegistry, *,
166
+ approved_digest: str) -> WorkflowRegistry:
167
+ """Explicitly load trusted installed code into a staged registry.
168
+
169
+ Approve a reviewed descriptor digest, then use the returned registry. The
170
+ original registration stores remain unchanged if activation fails. Trusted
171
+ factory side effects and shared implementation state are not sandboxed.
172
+ Factories accept one registry, register exactly their declared additions,
173
+ and return None. Candidate-supplied import strings are not accepted here.
174
+ """
175
+ if not isinstance(extension, PackagedExtension) or not isinstance(registry, WorkflowRegistry):
176
+ raise ExtensionError("enable_extension requires discovered metadata and WorkflowRegistry")
177
+ if extension.descriptor_digest != approved_digest:
178
+ raise ExtensionError("extension descriptor has not been approved")
179
+ if extension.contract_version != _CONTRACT:
180
+ raise ExtensionError("extension requires an unsupported workflow contract")
181
+ dist = extension._installed_distribution
182
+ if dist is None:
183
+ raise ExtensionError("extension must come from an installed distribution inventory")
184
+ current = [item for item in _from_distribution(dist) if item.extension_id == extension.extension_id]
185
+ if len(current) != 1 or current[0].descriptor_digest != approved_digest:
186
+ raise ExtensionError("installed extension metadata changed after review")
187
+ available = {cap for backend in registry.list_backends() for cap in backend.capabilities}
188
+ if not set(extension.required_capabilities) <= available:
189
+ raise ExtensionError("extension requires unavailable backend capabilities")
190
+ entries = [ep for ep in dist.entry_points if ep.group == _GROUP and ep.name == extension.entry_point]
191
+ if len(entries) != 1 or entries[0].value != extension.factory:
192
+ raise ExtensionError("installed extension factory changed after review")
193
+ staged = registry.fork()
194
+ before = _membership(staged)
195
+ factory = entries[0].load()
196
+ if not callable(factory):
197
+ raise ExtensionError("installed extension factory is not callable")
198
+ if factory(staged) is not None:
199
+ raise ExtensionError("extension factory must register into its supplied registry and return None")
200
+ after = _membership(staged)
201
+ for role in _ROLES:
202
+ expected = set(extension.registrations.get(role, ()))
203
+ if after[role] - before[role] != expected or not before[role] <= after[role]:
204
+ raise ExtensionError(f"extension registered unexpected {role}")
205
+ provided = {cap for backend in staged.list_backends()
206
+ if backend.backend_id not in before["backends"] for cap in backend.capabilities}
207
+ if not set(extension.capabilities) <= provided:
208
+ raise ExtensionError("extension did not register its declared capabilities")
209
+ for suite in staged.list_suites():
210
+ staged.resolve_suite(suite.suite_id)
211
+ staged.fork() # Recheck validator/provider/backend pins after trusted factory execution.
212
+ return staged