spanforge 1.0.0__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. spanforge/__init__.py +815 -0
  2. spanforge/_ansi.py +93 -0
  3. spanforge/_batch_exporter.py +409 -0
  4. spanforge/_cli.py +2094 -0
  5. spanforge/_cli_audit.py +639 -0
  6. spanforge/_cli_compliance.py +711 -0
  7. spanforge/_cli_cost.py +243 -0
  8. spanforge/_cli_ops.py +791 -0
  9. spanforge/_cli_phase11.py +356 -0
  10. spanforge/_hooks.py +337 -0
  11. spanforge/_server.py +1708 -0
  12. spanforge/_span.py +1036 -0
  13. spanforge/_store.py +288 -0
  14. spanforge/_stream.py +664 -0
  15. spanforge/_trace.py +335 -0
  16. spanforge/_tracer.py +254 -0
  17. spanforge/actor.py +141 -0
  18. spanforge/alerts.py +469 -0
  19. spanforge/auto.py +464 -0
  20. spanforge/baseline.py +335 -0
  21. spanforge/cache.py +635 -0
  22. spanforge/compliance.py +325 -0
  23. spanforge/config.py +532 -0
  24. spanforge/consent.py +228 -0
  25. spanforge/consumer.py +377 -0
  26. spanforge/core/__init__.py +5 -0
  27. spanforge/core/compliance_mapping.py +1254 -0
  28. spanforge/cost.py +600 -0
  29. spanforge/debug.py +548 -0
  30. spanforge/deprecations.py +205 -0
  31. spanforge/drift.py +482 -0
  32. spanforge/egress.py +58 -0
  33. spanforge/eval.py +648 -0
  34. spanforge/event.py +1064 -0
  35. spanforge/exceptions.py +240 -0
  36. spanforge/explain.py +178 -0
  37. spanforge/export/__init__.py +69 -0
  38. spanforge/export/append_only.py +337 -0
  39. spanforge/export/cloud.py +357 -0
  40. spanforge/export/datadog.py +497 -0
  41. spanforge/export/grafana.py +320 -0
  42. spanforge/export/jsonl.py +195 -0
  43. spanforge/export/openinference.py +158 -0
  44. spanforge/export/otel_bridge.py +294 -0
  45. spanforge/export/otlp.py +811 -0
  46. spanforge/export/otlp_bridge.py +233 -0
  47. spanforge/export/redis_backend.py +282 -0
  48. spanforge/export/siem_schema.py +98 -0
  49. spanforge/export/siem_splunk.py +264 -0
  50. spanforge/export/siem_syslog.py +212 -0
  51. spanforge/export/webhook.py +299 -0
  52. spanforge/exporters/__init__.py +30 -0
  53. spanforge/exporters/console.py +271 -0
  54. spanforge/exporters/jsonl.py +144 -0
  55. spanforge/exporters/sqlite.py +142 -0
  56. spanforge/gate.py +1150 -0
  57. spanforge/governance.py +181 -0
  58. spanforge/hitl.py +295 -0
  59. spanforge/http.py +187 -0
  60. spanforge/inspect.py +427 -0
  61. spanforge/integrations/__init__.py +45 -0
  62. spanforge/integrations/_pricing.py +280 -0
  63. spanforge/integrations/anthropic.py +388 -0
  64. spanforge/integrations/azure_openai.py +133 -0
  65. spanforge/integrations/bedrock.py +292 -0
  66. spanforge/integrations/crewai.py +251 -0
  67. spanforge/integrations/gemini.py +351 -0
  68. spanforge/integrations/groq.py +442 -0
  69. spanforge/integrations/langchain.py +349 -0
  70. spanforge/integrations/langgraph.py +306 -0
  71. spanforge/integrations/llamaindex.py +373 -0
  72. spanforge/integrations/ollama.py +287 -0
  73. spanforge/integrations/openai.py +368 -0
  74. spanforge/integrations/together.py +483 -0
  75. spanforge/io.py +214 -0
  76. spanforge/lint.py +322 -0
  77. spanforge/metrics.py +417 -0
  78. spanforge/metrics_export.py +343 -0
  79. spanforge/migrate.py +402 -0
  80. spanforge/model_registry.py +278 -0
  81. spanforge/models.py +389 -0
  82. spanforge/namespaces/__init__.py +254 -0
  83. spanforge/namespaces/audit.py +256 -0
  84. spanforge/namespaces/cache.py +237 -0
  85. spanforge/namespaces/chain.py +77 -0
  86. spanforge/namespaces/confidence.py +72 -0
  87. spanforge/namespaces/consent.py +92 -0
  88. spanforge/namespaces/cost.py +179 -0
  89. spanforge/namespaces/decision.py +143 -0
  90. spanforge/namespaces/diff.py +157 -0
  91. spanforge/namespaces/drift.py +80 -0
  92. spanforge/namespaces/eval_.py +251 -0
  93. spanforge/namespaces/feedback.py +241 -0
  94. spanforge/namespaces/fence.py +193 -0
  95. spanforge/namespaces/guard.py +105 -0
  96. spanforge/namespaces/hitl.py +91 -0
  97. spanforge/namespaces/latency.py +72 -0
  98. spanforge/namespaces/prompt.py +190 -0
  99. spanforge/namespaces/redact.py +173 -0
  100. spanforge/namespaces/retrieval.py +379 -0
  101. spanforge/namespaces/runtime_governance.py +494 -0
  102. spanforge/namespaces/template.py +208 -0
  103. spanforge/namespaces/tool_call.py +77 -0
  104. spanforge/namespaces/trace.py +1029 -0
  105. spanforge/normalizer.py +171 -0
  106. spanforge/plugins.py +82 -0
  107. spanforge/presidio_backend.py +349 -0
  108. spanforge/processor.py +258 -0
  109. spanforge/prompt_registry.py +418 -0
  110. spanforge/py.typed +0 -0
  111. spanforge/redact.py +914 -0
  112. spanforge/regression.py +192 -0
  113. spanforge/runtime_policy.py +159 -0
  114. spanforge/sampling.py +511 -0
  115. spanforge/schema.py +183 -0
  116. spanforge/schemas/v1.0/schema.json +170 -0
  117. spanforge/schemas/v2.0/schema.json +536 -0
  118. spanforge/sdk/__init__.py +625 -0
  119. spanforge/sdk/_base.py +584 -0
  120. spanforge/sdk/_base.pyi +71 -0
  121. spanforge/sdk/_exceptions.py +1096 -0
  122. spanforge/sdk/_types.py +2184 -0
  123. spanforge/sdk/alert.py +1514 -0
  124. spanforge/sdk/alert.pyi +56 -0
  125. spanforge/sdk/audit.py +1196 -0
  126. spanforge/sdk/audit.pyi +67 -0
  127. spanforge/sdk/cec.py +1215 -0
  128. spanforge/sdk/cec.pyi +37 -0
  129. spanforge/sdk/config.py +641 -0
  130. spanforge/sdk/config.pyi +55 -0
  131. spanforge/sdk/enterprise.py +714 -0
  132. spanforge/sdk/enterprise.pyi +79 -0
  133. spanforge/sdk/explain.py +170 -0
  134. spanforge/sdk/fallback.py +432 -0
  135. spanforge/sdk/feedback.py +351 -0
  136. spanforge/sdk/gate.py +874 -0
  137. spanforge/sdk/gate.pyi +51 -0
  138. spanforge/sdk/identity.py +2114 -0
  139. spanforge/sdk/identity.pyi +47 -0
  140. spanforge/sdk/lineage.py +175 -0
  141. spanforge/sdk/observe.py +1065 -0
  142. spanforge/sdk/observe.pyi +50 -0
  143. spanforge/sdk/operator.py +338 -0
  144. spanforge/sdk/pii.py +1473 -0
  145. spanforge/sdk/pii.pyi +119 -0
  146. spanforge/sdk/pipelines.py +458 -0
  147. spanforge/sdk/pipelines.pyi +39 -0
  148. spanforge/sdk/policy.py +930 -0
  149. spanforge/sdk/rag.py +594 -0
  150. spanforge/sdk/rbac.py +280 -0
  151. spanforge/sdk/registry.py +430 -0
  152. spanforge/sdk/registry.pyi +46 -0
  153. spanforge/sdk/scope.py +279 -0
  154. spanforge/sdk/secrets.py +293 -0
  155. spanforge/sdk/secrets.pyi +25 -0
  156. spanforge/sdk/security.py +560 -0
  157. spanforge/sdk/security.pyi +57 -0
  158. spanforge/sdk/trust.py +472 -0
  159. spanforge/sdk/trust.pyi +41 -0
  160. spanforge/secrets.py +799 -0
  161. spanforge/signing.py +1179 -0
  162. spanforge/stats.py +100 -0
  163. spanforge/stream.py +560 -0
  164. spanforge/testing.py +378 -0
  165. spanforge/testing_mocks.py +1052 -0
  166. spanforge/trace.py +199 -0
  167. spanforge/types.py +696 -0
  168. spanforge/ulid.py +300 -0
  169. spanforge/validate.py +379 -0
  170. spanforge-1.0.0.dist-info/METADATA +1509 -0
  171. spanforge-1.0.0.dist-info/RECORD +174 -0
  172. spanforge-1.0.0.dist-info/WHEEL +4 -0
  173. spanforge-1.0.0.dist-info/entry_points.txt +5 -0
  174. spanforge-1.0.0.dist-info/licenses/LICENSE +128 -0
@@ -0,0 +1,256 @@
1
+ """spanforge.namespaces.audit — Audit chain payload types (RFC-0001 §11 + RFC-0001 SPANFORGE).
2
+
3
+ Classes
4
+ -------
5
+ AuditKeyRotatedPayload llm.audit.key.rotated
6
+ AuditChainVerifiedPayload llm.audit.chain.verified
7
+ AuditChainTamperedPayload llm.audit.chain.tampered
8
+ AuditChainPayload audit.event_signed / audit.chain_verified / audit.tamper_detected
9
+ RFC-0001 SPANFORGE tamper-evident cross-reference chain
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import dataclass, field
15
+ from typing import Any
16
+
17
+ __all__ = [
18
+ "AuditChainPayload",
19
+ "AuditChainTamperedPayload",
20
+ "AuditChainVerifiedPayload",
21
+ "AuditKeyRotatedPayload",
22
+ ]
23
+
24
+ _VALID_ROTATION_REASONS = frozenset(
25
+ {"scheduled", "suspected_compromise", "policy_update", "key_expiry", "manual"}
26
+ )
27
+ _VALID_SEVERITIES = frozenset({"low", "medium", "high", "critical"})
28
+
29
+
30
+ @dataclass
31
+ class AuditKeyRotatedPayload:
32
+ """RFC-0001 §11.5 — An HMAC signing key was rotated.
33
+
34
+ ``key_algorithm`` defaults to ``"HMAC-SHA256"`` (the only algorithm
35
+ mandated by the RFC). ``effective_from_event_id`` is the ULID of the
36
+ first event signed with the new key.
37
+ """
38
+
39
+ key_id: str
40
+ previous_key_id: str
41
+ rotated_at: str # ISO 8601 timestamp with exactly 6 decimal places
42
+ rotated_by: str
43
+ rotation_reason: str | None = None
44
+ key_algorithm: str = "HMAC-SHA256"
45
+ effective_from_event_id: str | None = None # ULID
46
+
47
+ def __post_init__(self) -> None:
48
+ if not self.key_id:
49
+ raise ValueError("AuditKeyRotatedPayload.key_id must be non-empty")
50
+ if not self.previous_key_id:
51
+ raise ValueError("AuditKeyRotatedPayload.previous_key_id must be non-empty")
52
+ if not self.rotated_at:
53
+ raise ValueError("AuditKeyRotatedPayload.rotated_at must be non-empty")
54
+ if not self.rotated_by:
55
+ raise ValueError("AuditKeyRotatedPayload.rotated_by must be non-empty")
56
+ if self.rotation_reason is not None and self.rotation_reason not in _VALID_ROTATION_REASONS:
57
+ raise ValueError(
58
+ f"AuditKeyRotatedPayload.rotation_reason must be one of {sorted(_VALID_ROTATION_REASONS)}"
59
+ )
60
+
61
+ def to_dict(self) -> dict[str, Any]:
62
+ """Serialise the payload to a plain ``dict``."""
63
+ d: dict[str, Any] = {
64
+ "key_id": self.key_id,
65
+ "previous_key_id": self.previous_key_id,
66
+ "rotated_at": self.rotated_at,
67
+ "rotated_by": self.rotated_by,
68
+ "key_algorithm": self.key_algorithm,
69
+ }
70
+ if self.rotation_reason is not None:
71
+ d["rotation_reason"] = self.rotation_reason
72
+ if self.effective_from_event_id is not None:
73
+ d["effective_from_event_id"] = self.effective_from_event_id
74
+ return d
75
+
76
+ @classmethod
77
+ def from_dict(cls, data: dict[str, Any]) -> AuditKeyRotatedPayload:
78
+ """Deserialise from a plain ``dict``."""
79
+ return cls(
80
+ key_id=data["key_id"],
81
+ previous_key_id=data["previous_key_id"],
82
+ rotated_at=data["rotated_at"],
83
+ rotated_by=data["rotated_by"],
84
+ rotation_reason=data.get("rotation_reason"),
85
+ key_algorithm=data.get("key_algorithm", "HMAC-SHA256"),
86
+ effective_from_event_id=data.get("effective_from_event_id"),
87
+ )
88
+
89
+
90
+ @dataclass
91
+ class AuditChainVerifiedPayload:
92
+ """RFC-0001 §11 — An audit chain segment was verified intact."""
93
+
94
+ verified_from_event_id: str
95
+ verified_to_event_id: str
96
+ event_count: int
97
+ verified_at: str
98
+ verified_by: str
99
+
100
+ def __post_init__(self) -> None:
101
+ if not self.verified_from_event_id:
102
+ raise ValueError("AuditChainVerifiedPayload.verified_from_event_id must be non-empty")
103
+ if not self.verified_to_event_id:
104
+ raise ValueError("AuditChainVerifiedPayload.verified_to_event_id must be non-empty")
105
+ if not isinstance(self.event_count, int) or self.event_count < 0:
106
+ raise ValueError("AuditChainVerifiedPayload.event_count must be a non-negative int")
107
+ if not self.verified_at:
108
+ raise ValueError("AuditChainVerifiedPayload.verified_at must be non-empty")
109
+ if not self.verified_by:
110
+ raise ValueError("AuditChainVerifiedPayload.verified_by must be non-empty")
111
+
112
+ def to_dict(self) -> dict[str, Any]:
113
+ """Serialise the payload to a plain ``dict``."""
114
+ return {
115
+ "verified_from_event_id": self.verified_from_event_id,
116
+ "verified_to_event_id": self.verified_to_event_id,
117
+ "event_count": self.event_count,
118
+ "verified_at": self.verified_at,
119
+ "verified_by": self.verified_by,
120
+ }
121
+
122
+ @classmethod
123
+ def from_dict(cls, data: dict[str, Any]) -> AuditChainVerifiedPayload:
124
+ """Deserialise from a plain ``dict``."""
125
+ return cls(
126
+ verified_from_event_id=data["verified_from_event_id"],
127
+ verified_to_event_id=data["verified_to_event_id"],
128
+ event_count=int(data["event_count"]),
129
+ verified_at=data["verified_at"],
130
+ verified_by=data["verified_by"],
131
+ )
132
+
133
+
134
+ @dataclass
135
+ class AuditChainTamperedPayload:
136
+ """RFC-0001 §11 — Tampering or a gap was detected in the audit chain."""
137
+
138
+ first_tampered_event_id: str
139
+ tampered_count: int
140
+ detected_at: str
141
+ detected_by: str
142
+ gap_count: int | None = None
143
+ gap_prev_ids: list[str] = field(default_factory=list)
144
+ severity: str | None = None # "low"|"medium"|"high"|"critical"
145
+
146
+ def __post_init__(self) -> None:
147
+ if not self.first_tampered_event_id:
148
+ raise ValueError("AuditChainTamperedPayload.first_tampered_event_id must be non-empty")
149
+ if not isinstance(self.tampered_count, int) or self.tampered_count < 0:
150
+ raise ValueError("AuditChainTamperedPayload.tampered_count must be a non-negative int")
151
+ if not self.detected_at:
152
+ raise ValueError("AuditChainTamperedPayload.detected_at must be non-empty")
153
+ if not self.detected_by:
154
+ raise ValueError("AuditChainTamperedPayload.detected_by must be non-empty")
155
+ if self.severity is not None and self.severity not in _VALID_SEVERITIES:
156
+ raise ValueError(
157
+ f"AuditChainTamperedPayload.severity must be one of {sorted(_VALID_SEVERITIES)}"
158
+ )
159
+
160
+ def to_dict(self) -> dict[str, Any]:
161
+ """Serialise the payload to a plain ``dict``."""
162
+ d: dict[str, Any] = {
163
+ "first_tampered_event_id": self.first_tampered_event_id,
164
+ "tampered_count": self.tampered_count,
165
+ "detected_at": self.detected_at,
166
+ "detected_by": self.detected_by,
167
+ }
168
+ if self.gap_count is not None:
169
+ d["gap_count"] = self.gap_count
170
+ if self.gap_prev_ids:
171
+ d["gap_prev_ids"] = list(self.gap_prev_ids)
172
+ if self.severity is not None:
173
+ d["severity"] = self.severity
174
+ return d
175
+
176
+ @classmethod
177
+ def from_dict(cls, data: dict[str, Any]) -> AuditChainTamperedPayload:
178
+ """Deserialise from a plain ``dict``."""
179
+ return cls(
180
+ first_tampered_event_id=data["first_tampered_event_id"],
181
+ tampered_count=int(data["tampered_count"]),
182
+ detected_at=data["detected_at"],
183
+ detected_by=data["detected_by"],
184
+ gap_count=int(data["gap_count"]) if "gap_count" in data else None,
185
+ gap_prev_ids=list(data.get("gap_prev_ids", [])),
186
+ severity=data.get("severity"),
187
+ )
188
+
189
+
190
+ @dataclass
191
+ class AuditChainPayload:
192
+ """RFC-0001 SPANFORGE — tamper-evident cross-reference audit chain event.
193
+
194
+ Every event emitted in the 10 RFC-0001 SPANFORGE namespaces is cross-
195
+ referenced into this immutable audit chain. Each entry holds the HMAC
196
+ of the referenced event and the chained HMAC of all prior chain entries,
197
+ making any post-emission mutation detectable.
198
+
199
+ Events:
200
+ audit.event_signed — a new event was appended to the chain
201
+ audit.chain_verified — a chain segment was verified intact
202
+ audit.tamper_detected — a break in the HMAC sequence was detected
203
+ """
204
+
205
+ event_id: str # ULID of the referenced event
206
+ event_type: str # wire event type string of the referenced event
207
+ event_hmac: str # HMAC-SHA256 of the referenced event canonical JSON
208
+ chain_position: int # monotonically increasing position in the chain
209
+ signer_id: str # identity of the signing service / key ID
210
+ signed_at: str # ISO 8601 timestamp with 6 decimal places
211
+ prev_chain_hmac: str | None = None # HMAC of the previous chain entry; None for entry 0
212
+
213
+ def __post_init__(self) -> None:
214
+ if not self.event_id:
215
+ raise ValueError("AuditChainPayload.event_id must be non-empty")
216
+ if not self.event_type:
217
+ raise ValueError("AuditChainPayload.event_type must be non-empty")
218
+ if not self.event_hmac:
219
+ raise ValueError("AuditChainPayload.event_hmac must be non-empty")
220
+ if not isinstance(self.chain_position, int) or self.chain_position < 0:
221
+ raise ValueError("AuditChainPayload.chain_position must be a non-negative int")
222
+ if not self.signer_id:
223
+ raise ValueError("AuditChainPayload.signer_id must be non-empty")
224
+ if not self.signed_at:
225
+ raise ValueError("AuditChainPayload.signed_at must be non-empty")
226
+ if self.chain_position > 0 and not self.prev_chain_hmac:
227
+ raise ValueError(
228
+ "AuditChainPayload.prev_chain_hmac is required when chain_position > 0"
229
+ )
230
+
231
+ def to_dict(self) -> dict[str, Any]:
232
+ """Serialise the payload to a plain ``dict``."""
233
+ d: dict[str, Any] = {
234
+ "event_id": self.event_id,
235
+ "event_type": self.event_type,
236
+ "event_hmac": self.event_hmac,
237
+ "chain_position": self.chain_position,
238
+ "signer_id": self.signer_id,
239
+ "signed_at": self.signed_at,
240
+ }
241
+ if self.prev_chain_hmac is not None:
242
+ d["prev_chain_hmac"] = self.prev_chain_hmac
243
+ return d
244
+
245
+ @classmethod
246
+ def from_dict(cls, data: dict[str, Any]) -> AuditChainPayload:
247
+ """Deserialise from a plain ``dict``."""
248
+ return cls(
249
+ event_id=data["event_id"],
250
+ event_type=data["event_type"],
251
+ event_hmac=data["event_hmac"],
252
+ chain_position=int(data["chain_position"]),
253
+ signer_id=data["signer_id"],
254
+ signed_at=data["signed_at"],
255
+ prev_chain_hmac=data.get("prev_chain_hmac"),
256
+ )
@@ -0,0 +1,237 @@
1
+ """spanforge.namespaces.cache — Cache payload types (RFC-0001).
2
+
3
+ Classes
4
+ -------
5
+ CacheHitPayload llm.cache.hit
6
+ CacheMissPayload llm.cache.miss
7
+ CacheEvictedPayload llm.cache.evicted
8
+ CacheWrittenPayload llm.cache.written
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass
14
+ from typing import Any
15
+
16
+ from spanforge.namespaces.trace import CostBreakdown, ModelInfo, TokenUsage
17
+
18
+ __all__ = [
19
+ "CacheEvictedPayload",
20
+ "CacheHitPayload",
21
+ "CacheMissPayload",
22
+ "CacheWrittenPayload",
23
+ ]
24
+
25
+ _VALID_EVICTION_REASONS = frozenset(
26
+ {
27
+ "ttl_expired",
28
+ "lru_eviction",
29
+ "manual_invalidation",
30
+ "capacity_exceeded",
31
+ "schema_upgrade",
32
+ }
33
+ )
34
+
35
+
36
+ @dataclass
37
+ class CacheHitPayload:
38
+ """Payload for llm.cache.hit — semantic cache lookup succeeded."""
39
+
40
+ key_hash: str
41
+ namespace: str
42
+ similarity_score: float
43
+ ttl_remaining_seconds: int | None = None
44
+ cached_model: ModelInfo | None = None
45
+ cost_saved: CostBreakdown | None = None
46
+ tokens_saved: TokenUsage | None = None
47
+ lookup_duration_ms: float | None = None
48
+
49
+ def __post_init__(self) -> None:
50
+ if not self.key_hash:
51
+ raise ValueError("CacheHitPayload.key_hash must be non-empty")
52
+ if not self.namespace:
53
+ raise ValueError("CacheHitPayload.namespace must be non-empty")
54
+ if not (0.0 <= self.similarity_score <= 1.0):
55
+ raise ValueError("CacheHitPayload.similarity_score must be in [0,1]")
56
+
57
+ def to_dict(self) -> dict[str, Any]:
58
+ """Serialise the payload to a plain ``dict``."""
59
+ d: dict[str, Any] = {
60
+ "key_hash": self.key_hash,
61
+ "namespace": self.namespace,
62
+ "similarity_score": self.similarity_score,
63
+ }
64
+ if self.ttl_remaining_seconds is not None:
65
+ d["ttl_remaining_seconds"] = self.ttl_remaining_seconds
66
+ if self.cached_model is not None:
67
+ d["cached_model"] = self.cached_model.to_dict()
68
+ if self.cost_saved is not None:
69
+ d["cost_saved"] = self.cost_saved.to_dict()
70
+ if self.tokens_saved is not None:
71
+ d["tokens_saved"] = self.tokens_saved.to_dict()
72
+ if self.lookup_duration_ms is not None:
73
+ d["lookup_duration_ms"] = self.lookup_duration_ms
74
+ return d
75
+
76
+ @classmethod
77
+ def from_dict(cls, data: dict[str, Any]) -> CacheHitPayload:
78
+ """Deserialise from a plain ``dict``."""
79
+ return cls(
80
+ key_hash=data["key_hash"],
81
+ namespace=data["namespace"],
82
+ similarity_score=float(data["similarity_score"]),
83
+ ttl_remaining_seconds=int(data["ttl_remaining_seconds"])
84
+ if "ttl_remaining_seconds" in data
85
+ else None,
86
+ cached_model=ModelInfo.from_dict(data["cached_model"])
87
+ if "cached_model" in data
88
+ else None,
89
+ cost_saved=CostBreakdown.from_dict(data["cost_saved"])
90
+ if "cost_saved" in data
91
+ else None,
92
+ tokens_saved=TokenUsage.from_dict(data["tokens_saved"])
93
+ if "tokens_saved" in data
94
+ else None,
95
+ lookup_duration_ms=float(data["lookup_duration_ms"])
96
+ if "lookup_duration_ms" in data
97
+ else None,
98
+ )
99
+
100
+
101
+ @dataclass
102
+ class CacheMissPayload:
103
+ """Payload for llm.cache.miss — semantic cache lookup failed."""
104
+
105
+ key_hash: str
106
+ namespace: str
107
+ best_similarity_score: float | None = None
108
+ similarity_threshold: float | None = None
109
+ lookup_duration_ms: float | None = None
110
+
111
+ def __post_init__(self) -> None:
112
+ if not self.key_hash:
113
+ raise ValueError("CacheMissPayload.key_hash must be non-empty")
114
+ if not self.namespace:
115
+ raise ValueError("CacheMissPayload.namespace must be non-empty")
116
+
117
+ def to_dict(self) -> dict[str, Any]:
118
+ """Serialise the payload to a plain ``dict``."""
119
+ d: dict[str, Any] = {"key_hash": self.key_hash, "namespace": self.namespace}
120
+ if self.best_similarity_score is not None:
121
+ d["best_similarity_score"] = self.best_similarity_score
122
+ if self.similarity_threshold is not None:
123
+ d["similarity_threshold"] = self.similarity_threshold
124
+ if self.lookup_duration_ms is not None:
125
+ d["lookup_duration_ms"] = self.lookup_duration_ms
126
+ return d
127
+
128
+ @classmethod
129
+ def from_dict(cls, data: dict[str, Any]) -> CacheMissPayload:
130
+ """Deserialise from a plain ``dict``."""
131
+ return cls(
132
+ key_hash=data["key_hash"],
133
+ namespace=data["namespace"],
134
+ best_similarity_score=float(data["best_similarity_score"])
135
+ if "best_similarity_score" in data
136
+ else None,
137
+ similarity_threshold=float(data["similarity_threshold"])
138
+ if "similarity_threshold" in data
139
+ else None,
140
+ lookup_duration_ms=float(data["lookup_duration_ms"])
141
+ if "lookup_duration_ms" in data
142
+ else None,
143
+ )
144
+
145
+
146
+ @dataclass
147
+ class CacheEvictedPayload:
148
+ """Payload for llm.cache.evicted — a cache entry was removed."""
149
+
150
+ key_hash: str
151
+ namespace: str
152
+ eviction_reason: str
153
+ entry_age_seconds: int | None = None
154
+
155
+ def __post_init__(self) -> None:
156
+ if not self.key_hash:
157
+ raise ValueError("CacheEvictedPayload.key_hash must be non-empty")
158
+ if not self.namespace:
159
+ raise ValueError("CacheEvictedPayload.namespace must be non-empty")
160
+ if self.eviction_reason not in _VALID_EVICTION_REASONS:
161
+ raise ValueError(
162
+ f"CacheEvictedPayload.eviction_reason must be one of {sorted(_VALID_EVICTION_REASONS)}"
163
+ )
164
+
165
+ def to_dict(self) -> dict[str, Any]:
166
+ """Serialise the payload to a plain ``dict``."""
167
+ d: dict[str, Any] = {
168
+ "key_hash": self.key_hash,
169
+ "namespace": self.namespace,
170
+ "eviction_reason": self.eviction_reason,
171
+ }
172
+ if self.entry_age_seconds is not None:
173
+ d["entry_age_seconds"] = self.entry_age_seconds
174
+ return d
175
+
176
+ @classmethod
177
+ def from_dict(cls, data: dict[str, Any]) -> CacheEvictedPayload:
178
+ """Deserialise from a plain ``dict``."""
179
+ return cls(
180
+ key_hash=data["key_hash"],
181
+ namespace=data["namespace"],
182
+ eviction_reason=data["eviction_reason"],
183
+ entry_age_seconds=int(data["entry_age_seconds"])
184
+ if "entry_age_seconds" in data
185
+ else None,
186
+ )
187
+
188
+
189
+ @dataclass
190
+ class CacheWrittenPayload:
191
+ """Payload for llm.cache.written — a response was written to cache."""
192
+
193
+ key_hash: str
194
+ namespace: str
195
+ ttl_seconds: int
196
+ model: ModelInfo | None = None
197
+ response_token_count: int | None = None
198
+ write_duration_ms: float | None = None
199
+
200
+ def __post_init__(self) -> None:
201
+ if not self.key_hash:
202
+ raise ValueError("CacheWrittenPayload.key_hash must be non-empty")
203
+ if not self.namespace:
204
+ raise ValueError("CacheWrittenPayload.namespace must be non-empty")
205
+ if not isinstance(self.ttl_seconds, int) or self.ttl_seconds < 0:
206
+ raise ValueError("CacheWrittenPayload.ttl_seconds must be a non-negative int")
207
+
208
+ def to_dict(self) -> dict[str, Any]:
209
+ """Serialise the payload to a plain ``dict``."""
210
+ d: dict[str, Any] = {
211
+ "key_hash": self.key_hash,
212
+ "namespace": self.namespace,
213
+ "ttl_seconds": self.ttl_seconds,
214
+ }
215
+ if self.model is not None:
216
+ d["model"] = self.model.to_dict()
217
+ if self.response_token_count is not None:
218
+ d["response_token_count"] = self.response_token_count
219
+ if self.write_duration_ms is not None:
220
+ d["write_duration_ms"] = self.write_duration_ms
221
+ return d
222
+
223
+ @classmethod
224
+ def from_dict(cls, data: dict[str, Any]) -> CacheWrittenPayload:
225
+ """Deserialise from a plain ``dict``."""
226
+ return cls(
227
+ key_hash=data["key_hash"],
228
+ namespace=data["namespace"],
229
+ ttl_seconds=int(data["ttl_seconds"]),
230
+ model=ModelInfo.from_dict(data["model"]) if "model" in data else None,
231
+ response_token_count=int(data["response_token_count"])
232
+ if "response_token_count" in data
233
+ else None,
234
+ write_duration_ms=float(data["write_duration_ms"])
235
+ if "write_duration_ms" in data
236
+ else None,
237
+ )
@@ -0,0 +1,77 @@
1
+ """spanforge.namespaces.chain \u2014 Chain namespace payload types (RFC-0001 SPANFORGE).
2
+
3
+ Classes
4
+ -------
5
+ ChainPayload chain.started / chain.step_completed / chain.completed / chain.failed
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+ from typing import Any
12
+
13
+ __all__ = ["ChainPayload"]
14
+
15
+
16
+ @dataclass
17
+ class ChainPayload:
18
+ """RFC-0001 SPANFORGE \u2014 payload for chain.* events.
19
+
20
+ Captures multi-step prompt chain state: step sequence, inter-step data
21
+ flow references, and cumulative error/cost/latency propagation.
22
+ """
23
+
24
+ chain_id: str
25
+ step_index: int
26
+ step_name: str
27
+ cumulative_latency_ms: float
28
+ cumulative_token_cost: float
29
+ error_propagated: bool
30
+ total_steps: int | None = None
31
+ input_refs: list[str] = field(default_factory=list) # event ULIDs of inputs
32
+ output_refs: list[str] = field(default_factory=list) # event ULIDs of outputs
33
+
34
+ def __post_init__(self) -> None:
35
+ if not self.chain_id:
36
+ raise ValueError("ChainPayload.chain_id must be non-empty")
37
+ if self.step_index < 0:
38
+ raise ValueError("ChainPayload.step_index must be >= 0")
39
+ if not self.step_name:
40
+ raise ValueError("ChainPayload.step_name must be non-empty")
41
+ if self.cumulative_latency_ms < 0:
42
+ raise ValueError("ChainPayload.cumulative_latency_ms must be >= 0")
43
+ if self.cumulative_token_cost < 0:
44
+ raise ValueError("ChainPayload.cumulative_token_cost must be >= 0")
45
+ if self.total_steps is not None and self.total_steps < 1:
46
+ raise ValueError("ChainPayload.total_steps must be >= 1")
47
+
48
+ def to_dict(self) -> dict[str, Any]:
49
+ """Serialise to a plain dict."""
50
+ d: dict[str, Any] = {
51
+ "chain_id": self.chain_id,
52
+ "step_index": self.step_index,
53
+ "step_name": self.step_name,
54
+ "cumulative_latency_ms": self.cumulative_latency_ms,
55
+ "cumulative_token_cost": self.cumulative_token_cost,
56
+ "error_propagated": self.error_propagated,
57
+ "input_refs": list(self.input_refs),
58
+ "output_refs": list(self.output_refs),
59
+ }
60
+ if self.total_steps is not None:
61
+ d["total_steps"] = self.total_steps
62
+ return d
63
+
64
+ @classmethod
65
+ def from_dict(cls, data: dict[str, Any]) -> ChainPayload:
66
+ """Deserialise from a plain dict."""
67
+ return cls(
68
+ chain_id=data["chain_id"],
69
+ step_index=int(data["step_index"]),
70
+ step_name=data["step_name"],
71
+ cumulative_latency_ms=float(data["cumulative_latency_ms"]),
72
+ cumulative_token_cost=float(data["cumulative_token_cost"]),
73
+ error_propagated=bool(data["error_propagated"]),
74
+ total_steps=data.get("total_steps"),
75
+ input_refs=list(data.get("input_refs", [])),
76
+ output_refs=list(data.get("output_refs", [])),
77
+ )
@@ -0,0 +1,72 @@
1
+ """spanforge.namespaces.confidence \u2014 Confidence namespace payload types (RFC-0001 SPANFORGE).
2
+
3
+ Classes
4
+ -------
5
+ ConfidencePayload confidence.sample / confidence.threshold_breach
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ from typing import Any
12
+
13
+ __all__ = ["ConfidencePayload"]
14
+
15
+
16
+ @dataclass
17
+ class ConfidencePayload:
18
+ """RFC-0001 SPANFORGE \u2014 payload for confidence.* events.
19
+
20
+ Tracks output confidence score distributions per decision type and model,
21
+ measured against the deployment baseline (T \u2014 Traceability).
22
+ """
23
+
24
+ model_id: str
25
+ decision_type: str
26
+ score: float # 0.0\u20131.0
27
+ threshold_breached: bool
28
+ sampled_at: str # ISO 8601 timestamp
29
+ baseline_mean: float | None = None
30
+ baseline_stddev: float | None = None
31
+ z_score: float | None = None
32
+
33
+ def __post_init__(self) -> None:
34
+ if not self.model_id:
35
+ raise ValueError("ConfidencePayload.model_id must be non-empty")
36
+ if not self.decision_type:
37
+ raise ValueError("ConfidencePayload.decision_type must be non-empty")
38
+ if not (0.0 <= self.score <= 1.0):
39
+ raise ValueError("ConfidencePayload.score must be in [0.0, 1.0]")
40
+ if not self.sampled_at:
41
+ raise ValueError("ConfidencePayload.sampled_at must be non-empty")
42
+
43
+ def to_dict(self) -> dict[str, Any]:
44
+ """Serialise to a plain dict."""
45
+ d: dict[str, Any] = {
46
+ "model_id": self.model_id,
47
+ "decision_type": self.decision_type,
48
+ "score": self.score,
49
+ "threshold_breached": self.threshold_breached,
50
+ "sampled_at": self.sampled_at,
51
+ }
52
+ if self.baseline_mean is not None:
53
+ d["baseline_mean"] = self.baseline_mean
54
+ if self.baseline_stddev is not None:
55
+ d["baseline_stddev"] = self.baseline_stddev
56
+ if self.z_score is not None:
57
+ d["z_score"] = self.z_score
58
+ return d
59
+
60
+ @classmethod
61
+ def from_dict(cls, data: dict[str, Any]) -> ConfidencePayload:
62
+ """Deserialise from a plain dict."""
63
+ return cls(
64
+ model_id=data["model_id"],
65
+ decision_type=data["decision_type"],
66
+ score=float(data["score"]),
67
+ threshold_breached=bool(data["threshold_breached"]),
68
+ sampled_at=data["sampled_at"],
69
+ baseline_mean=data.get("baseline_mean"),
70
+ baseline_stddev=data.get("baseline_stddev"),
71
+ z_score=data.get("z_score"),
72
+ )