spanforge 2.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 (101) hide show
  1. spanforge/__init__.py +695 -0
  2. spanforge/_batch_exporter.py +322 -0
  3. spanforge/_cli.py +3081 -0
  4. spanforge/_hooks.py +340 -0
  5. spanforge/_server.py +953 -0
  6. spanforge/_span.py +1015 -0
  7. spanforge/_store.py +287 -0
  8. spanforge/_stream.py +654 -0
  9. spanforge/_trace.py +334 -0
  10. spanforge/_tracer.py +253 -0
  11. spanforge/actor.py +141 -0
  12. spanforge/alerts.py +464 -0
  13. spanforge/auto.py +181 -0
  14. spanforge/baseline.py +336 -0
  15. spanforge/config.py +460 -0
  16. spanforge/consent.py +227 -0
  17. spanforge/consumer.py +379 -0
  18. spanforge/core/__init__.py +5 -0
  19. spanforge/core/compliance_mapping.py +1060 -0
  20. spanforge/cost.py +597 -0
  21. spanforge/debug.py +514 -0
  22. spanforge/drift.py +488 -0
  23. spanforge/egress.py +63 -0
  24. spanforge/eval.py +575 -0
  25. spanforge/event.py +1052 -0
  26. spanforge/exceptions.py +246 -0
  27. spanforge/explain.py +181 -0
  28. spanforge/export/__init__.py +50 -0
  29. spanforge/export/append_only.py +342 -0
  30. spanforge/export/cloud.py +349 -0
  31. spanforge/export/datadog.py +495 -0
  32. spanforge/export/grafana.py +331 -0
  33. spanforge/export/jsonl.py +198 -0
  34. spanforge/export/otel_bridge.py +291 -0
  35. spanforge/export/otlp.py +817 -0
  36. spanforge/export/otlp_bridge.py +231 -0
  37. spanforge/export/redis_backend.py +282 -0
  38. spanforge/export/webhook.py +302 -0
  39. spanforge/exporters/__init__.py +29 -0
  40. spanforge/exporters/console.py +271 -0
  41. spanforge/exporters/jsonl.py +144 -0
  42. spanforge/hitl.py +297 -0
  43. spanforge/inspect.py +429 -0
  44. spanforge/integrations/__init__.py +39 -0
  45. spanforge/integrations/_pricing.py +277 -0
  46. spanforge/integrations/anthropic.py +388 -0
  47. spanforge/integrations/bedrock.py +306 -0
  48. spanforge/integrations/crewai.py +251 -0
  49. spanforge/integrations/gemini.py +349 -0
  50. spanforge/integrations/groq.py +444 -0
  51. spanforge/integrations/langchain.py +349 -0
  52. spanforge/integrations/llamaindex.py +370 -0
  53. spanforge/integrations/ollama.py +286 -0
  54. spanforge/integrations/openai.py +370 -0
  55. spanforge/integrations/together.py +485 -0
  56. spanforge/metrics.py +393 -0
  57. spanforge/metrics_export.py +342 -0
  58. spanforge/migrate.py +278 -0
  59. spanforge/model_registry.py +282 -0
  60. spanforge/models.py +407 -0
  61. spanforge/namespaces/__init__.py +215 -0
  62. spanforge/namespaces/audit.py +253 -0
  63. spanforge/namespaces/cache.py +209 -0
  64. spanforge/namespaces/chain.py +74 -0
  65. spanforge/namespaces/confidence.py +69 -0
  66. spanforge/namespaces/consent.py +85 -0
  67. spanforge/namespaces/cost.py +175 -0
  68. spanforge/namespaces/decision.py +135 -0
  69. spanforge/namespaces/diff.py +146 -0
  70. spanforge/namespaces/drift.py +79 -0
  71. spanforge/namespaces/eval_.py +232 -0
  72. spanforge/namespaces/fence.py +180 -0
  73. spanforge/namespaces/guard.py +104 -0
  74. spanforge/namespaces/hitl.py +92 -0
  75. spanforge/namespaces/latency.py +69 -0
  76. spanforge/namespaces/prompt.py +185 -0
  77. spanforge/namespaces/redact.py +172 -0
  78. spanforge/namespaces/template.py +197 -0
  79. spanforge/namespaces/tool_call.py +76 -0
  80. spanforge/namespaces/trace.py +1006 -0
  81. spanforge/normalizer.py +183 -0
  82. spanforge/presidio_backend.py +149 -0
  83. spanforge/processor.py +258 -0
  84. spanforge/prompt_registry.py +415 -0
  85. spanforge/py.typed +0 -0
  86. spanforge/redact.py +780 -0
  87. spanforge/sampling.py +500 -0
  88. spanforge/schemas/v1.0/schema.json +170 -0
  89. spanforge/schemas/v2.0/schema.json +536 -0
  90. spanforge/signing.py +1152 -0
  91. spanforge/stream.py +559 -0
  92. spanforge/testing.py +376 -0
  93. spanforge/trace.py +199 -0
  94. spanforge/types.py +696 -0
  95. spanforge/ulid.py +304 -0
  96. spanforge/validate.py +383 -0
  97. spanforge-2.0.0.dist-info/METADATA +1777 -0
  98. spanforge-2.0.0.dist-info/RECORD +101 -0
  99. spanforge-2.0.0.dist-info/WHEEL +4 -0
  100. spanforge-2.0.0.dist-info/entry_points.txt +5 -0
  101. spanforge-2.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,215 @@
1
+ """spanforge.namespaces — Namespace-specific payload dataclasses (v2.0).
2
+
3
+ Each sub-module provides dataclasses that model the ``payload`` field of
4
+ :class:`~spanforge.event.Event` for a given namespace.
5
+
6
+ All payload classes share the same contract:
7
+
8
+ * ``to_dict() -> dict`` — serialise to a plain dict for ``Event.payload``.
9
+ * ``from_dict(data) -> cls`` — reconstruct from a plain dict.
10
+ * ``__post_init__`` — validates every field at construction time.
11
+
12
+ Sub-modules
13
+ -----------
14
+ audit
15
+ :class:`AuditKeyRotatedPayload`, :class:`AuditChainVerifiedPayload`,
16
+ :class:`AuditChainTamperedPayload`, :class:`AuditChainPayload`
17
+ cache
18
+ :class:`CacheHitPayload`, :class:`CacheMissPayload`,
19
+ :class:`CacheEvictedPayload`, :class:`CacheWrittenPayload`
20
+ chain (RFC-0001 SPANFORGE)
21
+ :class:`ChainPayload`
22
+ confidence (RFC-0001 SPANFORGE)
23
+ :class:`ConfidencePayload`
24
+ consent (RFC-0001 SPANFORGE)
25
+ :class:`ConsentPayload`
26
+ hitl (RFC-0001 SPANFORGE)
27
+ :class:`HITLPayload`
28
+ playbook (RFC-0001 SPANFORGE)
29
+ *removed*
30
+ cost
31
+ :class:`CostTokenRecordedPayload`, :class:`CostSessionRecordedPayload`,
32
+ :class:`CostAttributedPayload`
33
+ decision (RFC-0001 SPANFORGE)
34
+ :class:`DecisionDriver`, :class:`DecisionPayload`
35
+ diff
36
+ :class:`DiffComputedPayload`, :class:`DiffRegressionFlaggedPayload`
37
+ drift (RFC-0001 SPANFORGE)
38
+ :class:`DriftPayload`
39
+ eval_
40
+ :class:`EvalScoreRecordedPayload`, :class:`EvalRegressionDetectedPayload`,
41
+ :class:`EvalScenarioStartedPayload`, :class:`EvalScenarioCompletedPayload`
42
+ fence
43
+ :class:`FenceValidatedPayload`, :class:`FenceRetryTriggeredPayload`,
44
+ :class:`FenceMaxRetriesExceededPayload`
45
+ guard
46
+ :class:`GuardPayload`
47
+ latency (RFC-0001 SPANFORGE)
48
+ :class:`LatencyPayload`
49
+ playbook (RFC-0001 SPANFORGE)
50
+ *removed*
51
+ prompt
52
+ :class:`PromptRenderedPayload`, :class:`PromptTemplateLoadedPayload`,
53
+ :class:`PromptVersionChangedPayload`
54
+ redact
55
+ :class:`RedactPiiDetectedPayload`, :class:`RedactPhiDetectedPayload`,
56
+ :class:`RedactAppliedPayload`
57
+ template
58
+ :class:`TemplateRegisteredPayload`, :class:`TemplateVariableBoundPayload`,
59
+ :class:`TemplateValidationFailedPayload`
60
+ tool_call (RFC-0001 SPANFORGE)
61
+ :class:`ToolCallPayload`
62
+ trace
63
+ :class:`GenAISystem`, :class:`GenAIOperationName`, :class:`SpanKind`,
64
+ :class:`TokenUsage`, :class:`ModelInfo`, :class:`CostBreakdown`,
65
+ :class:`PricingTier`, :class:`ToolCall`, :class:`ReasoningStep`,
66
+ :class:`DecisionPoint`, :class:`SpanPayload`, :class:`AgentStepPayload`,
67
+ :class:`AgentRunPayload`
68
+ """
69
+
70
+ from spanforge.namespaces.audit import (
71
+ AuditChainPayload,
72
+ AuditChainTamperedPayload,
73
+ AuditChainVerifiedPayload,
74
+ AuditKeyRotatedPayload,
75
+ )
76
+ from spanforge.namespaces.cache import (
77
+ CacheEvictedPayload,
78
+ CacheHitPayload,
79
+ CacheMissPayload,
80
+ CacheWrittenPayload,
81
+ )
82
+ from spanforge.namespaces.chain import ChainPayload
83
+ from spanforge.namespaces.confidence import ConfidencePayload
84
+ from spanforge.namespaces.consent import ConsentPayload
85
+ from spanforge.namespaces.hitl import HITLPayload
86
+ from spanforge.namespaces.cost import (
87
+ CostAttributedPayload,
88
+ CostSessionRecordedPayload,
89
+ CostTokenRecordedPayload,
90
+ )
91
+ from spanforge.namespaces.decision import DecisionDriver, DecisionPayload
92
+ from spanforge.namespaces.diff import (
93
+ DiffComputedPayload,
94
+ DiffRegressionFlaggedPayload,
95
+ )
96
+ from spanforge.namespaces.drift import DriftPayload
97
+ from spanforge.namespaces.eval_ import (
98
+ EvalRegressionDetectedPayload,
99
+ EvalScenarioCompletedPayload,
100
+ EvalScenarioStartedPayload,
101
+ EvalScoreRecordedPayload,
102
+ )
103
+ from spanforge.namespaces.fence import (
104
+ FenceMaxRetriesExceededPayload,
105
+ FenceRetryTriggeredPayload,
106
+ FenceValidatedPayload,
107
+ )
108
+ from spanforge.namespaces.guard import GuardPayload
109
+ from spanforge.namespaces.latency import LatencyPayload
110
+ from spanforge.namespaces.prompt import (
111
+ PromptRenderedPayload,
112
+ PromptTemplateLoadedPayload,
113
+ PromptVersionChangedPayload,
114
+ )
115
+ from spanforge.namespaces.redact import (
116
+ RedactAppliedPayload,
117
+ RedactPhiDetectedPayload,
118
+ RedactPiiDetectedPayload,
119
+ )
120
+ from spanforge.namespaces.template import (
121
+ TemplateRegisteredPayload,
122
+ TemplateValidationFailedPayload,
123
+ TemplateVariableBoundPayload,
124
+ )
125
+ from spanforge.namespaces.tool_call import ToolCallPayload
126
+ from spanforge.namespaces.trace import (
127
+ AgentRunPayload,
128
+ AgentStepPayload,
129
+ CostBreakdown,
130
+ DecisionPoint,
131
+ GenAIOperationName,
132
+ GenAISystem,
133
+ ModelInfo,
134
+ PricingTier,
135
+ ReasoningStep,
136
+ SpanKind,
137
+ SpanPayload,
138
+ TokenUsage,
139
+ ToolCall,
140
+ )
141
+
142
+ __all__: list = [
143
+ "AgentRunPayload",
144
+ "AgentStepPayload",
145
+ # audit (legacy + RFC-0001 SPANFORGE)
146
+ "AuditChainPayload",
147
+ "AuditChainTamperedPayload",
148
+ "AuditChainVerifiedPayload",
149
+ "AuditKeyRotatedPayload",
150
+ # cache
151
+ "CacheEvictedPayload",
152
+ "CacheHitPayload",
153
+ "CacheMissPayload",
154
+ "CacheWrittenPayload",
155
+ # chain (RFC-0001 SPANFORGE)
156
+ "ChainPayload",
157
+ # confidence (RFC-0001 SPANFORGE)
158
+ "ConfidencePayload",
159
+ # consent (RFC-0001 SPANFORGE)
160
+ "ConsentPayload",
161
+ # cost
162
+ "CostAttributedPayload",
163
+ "CostBreakdown",
164
+ "CostSessionRecordedPayload",
165
+ "CostTokenRecordedPayload",
166
+ # decision (RFC-0001 SPANFORGE)
167
+ "DecisionDriver",
168
+ "DecisionPayload",
169
+ # diff
170
+ "DiffComputedPayload",
171
+ "DiffRegressionFlaggedPayload",
172
+ # drift (RFC-0001 SPANFORGE)
173
+ "DriftPayload",
174
+ # eval
175
+ "EvalRegressionDetectedPayload",
176
+ "EvalScenarioCompletedPayload",
177
+ "EvalScenarioStartedPayload",
178
+ "EvalScoreRecordedPayload",
179
+ # fence
180
+ "FenceMaxRetriesExceededPayload",
181
+ "FenceRetryTriggeredPayload",
182
+ "FenceValidatedPayload",
183
+ # guard
184
+ "GuardPayload",
185
+ # hitl (RFC-0001 SPANFORGE)
186
+ "HITLPayload",
187
+ # latency (RFC-0001 SPANFORGE)
188
+ "LatencyPayload",
189
+ # trace — value objects and payloads
190
+ "GenAIOperationName",
191
+ "GenAISystem",
192
+ "ModelInfo",
193
+ "PricingTier",
194
+ # prompt
195
+ "PromptRenderedPayload",
196
+ "PromptTemplateLoadedPayload",
197
+ "PromptVersionChangedPayload",
198
+ "ReasoningStep",
199
+ # redact
200
+ "RedactAppliedPayload",
201
+ "RedactPhiDetectedPayload",
202
+ "RedactPiiDetectedPayload",
203
+ "SpanKind",
204
+ "SpanPayload",
205
+ # template
206
+ "TemplateRegisteredPayload",
207
+ "TemplateValidationFailedPayload",
208
+ "TemplateVariableBoundPayload",
209
+ "TokenUsage",
210
+ "ToolCall",
211
+ # tool_call (RFC-0001 SPANFORGE)
212
+ "ToolCallPayload",
213
+ # Backward-compat trace value object
214
+ "DecisionPoint",
215
+ ]
@@ -0,0 +1,253 @@
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
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass, field
14
+ from typing import Any
15
+
16
+ __all__ = [
17
+ "AuditChainPayload",
18
+ "AuditChainTamperedPayload",
19
+ "AuditChainVerifiedPayload",
20
+ "AuditKeyRotatedPayload",
21
+ ]
22
+
23
+ _VALID_ROTATION_REASONS = frozenset({
24
+ "scheduled", "suspected_compromise", "policy_update", "key_expiry", "manual"
25
+ })
26
+ _VALID_SEVERITIES = frozenset({"low", "medium", "high", "critical"})
27
+
28
+
29
+ @dataclass
30
+ class AuditKeyRotatedPayload:
31
+ """RFC-0001 §11.5 — An HMAC signing key was rotated.
32
+
33
+ ``key_algorithm`` defaults to ``"HMAC-SHA256"`` (the only algorithm
34
+ mandated by the RFC). ``effective_from_event_id`` is the ULID of the
35
+ first event signed with the new key.
36
+ """
37
+
38
+ key_id: str
39
+ previous_key_id: str
40
+ rotated_at: str # ISO 8601 timestamp with exactly 6 decimal places
41
+ rotated_by: str
42
+ rotation_reason: str | None = None
43
+ key_algorithm: str = "HMAC-SHA256"
44
+ effective_from_event_id: str | None = None # ULID
45
+
46
+ def __post_init__(self) -> None:
47
+ if not self.key_id:
48
+ raise ValueError("AuditKeyRotatedPayload.key_id must be non-empty")
49
+ if not self.previous_key_id:
50
+ raise ValueError("AuditKeyRotatedPayload.previous_key_id must be non-empty")
51
+ if not self.rotated_at:
52
+ raise ValueError("AuditKeyRotatedPayload.rotated_at must be non-empty")
53
+ if not self.rotated_by:
54
+ raise ValueError("AuditKeyRotatedPayload.rotated_by must be non-empty")
55
+ if self.rotation_reason is not None and self.rotation_reason not in _VALID_ROTATION_REASONS:
56
+ raise ValueError(
57
+ f"AuditKeyRotatedPayload.rotation_reason must be one of {sorted(_VALID_ROTATION_REASONS)}" # noqa: E501
58
+ )
59
+
60
+ def to_dict(self) -> dict[str, Any]:
61
+ """Serialise the payload to a plain ``dict``."""
62
+ d: dict[str, Any] = {
63
+ "key_id": self.key_id,
64
+ "previous_key_id": self.previous_key_id,
65
+ "rotated_at": self.rotated_at,
66
+ "rotated_by": self.rotated_by,
67
+ "key_algorithm": self.key_algorithm,
68
+ }
69
+ if self.rotation_reason is not None:
70
+ d["rotation_reason"] = self.rotation_reason
71
+ if self.effective_from_event_id is not None:
72
+ d["effective_from_event_id"] = self.effective_from_event_id
73
+ return d
74
+
75
+ @classmethod
76
+ def from_dict(cls, data: dict[str, Any]) -> AuditKeyRotatedPayload:
77
+ """Deserialise from a plain ``dict``."""
78
+ return cls(
79
+ key_id=data["key_id"],
80
+ previous_key_id=data["previous_key_id"],
81
+ rotated_at=data["rotated_at"],
82
+ rotated_by=data["rotated_by"],
83
+ rotation_reason=data.get("rotation_reason"),
84
+ key_algorithm=data.get("key_algorithm", "HMAC-SHA256"),
85
+ effective_from_event_id=data.get("effective_from_event_id"),
86
+ )
87
+
88
+
89
+ @dataclass
90
+ class AuditChainVerifiedPayload:
91
+ """RFC-0001 §11 — An audit chain segment was verified intact."""
92
+
93
+ verified_from_event_id: str
94
+ verified_to_event_id: str
95
+ event_count: int
96
+ verified_at: str
97
+ verified_by: str
98
+
99
+ def __post_init__(self) -> None:
100
+ if not self.verified_from_event_id:
101
+ raise ValueError("AuditChainVerifiedPayload.verified_from_event_id must be non-empty")
102
+ if not self.verified_to_event_id:
103
+ raise ValueError("AuditChainVerifiedPayload.verified_to_event_id must be non-empty")
104
+ if not isinstance(self.event_count, int) or self.event_count < 0:
105
+ raise ValueError("AuditChainVerifiedPayload.event_count must be a non-negative int")
106
+ if not self.verified_at:
107
+ raise ValueError("AuditChainVerifiedPayload.verified_at must be non-empty")
108
+ if not self.verified_by:
109
+ raise ValueError("AuditChainVerifiedPayload.verified_by must be non-empty")
110
+
111
+ def to_dict(self) -> dict[str, Any]:
112
+ """Serialise the payload to a plain ``dict``."""
113
+ return {
114
+ "verified_from_event_id": self.verified_from_event_id,
115
+ "verified_to_event_id": self.verified_to_event_id,
116
+ "event_count": self.event_count,
117
+ "verified_at": self.verified_at,
118
+ "verified_by": self.verified_by,
119
+ }
120
+
121
+ @classmethod
122
+ def from_dict(cls, data: dict[str, Any]) -> AuditChainVerifiedPayload:
123
+ """Deserialise from a plain ``dict``."""
124
+ return cls(
125
+ verified_from_event_id=data["verified_from_event_id"],
126
+ verified_to_event_id=data["verified_to_event_id"],
127
+ event_count=int(data["event_count"]),
128
+ verified_at=data["verified_at"],
129
+ verified_by=data["verified_by"],
130
+ )
131
+
132
+
133
+ @dataclass
134
+ class AuditChainTamperedPayload:
135
+ """RFC-0001 §11 — Tampering or a gap was detected in the audit chain."""
136
+
137
+ first_tampered_event_id: str
138
+ tampered_count: int
139
+ detected_at: str
140
+ detected_by: str
141
+ gap_count: int | None = None
142
+ gap_prev_ids: list[str] = field(default_factory=list)
143
+ severity: str | None = None # "low"|"medium"|"high"|"critical"
144
+
145
+ def __post_init__(self) -> None:
146
+ if not self.first_tampered_event_id:
147
+ raise ValueError("AuditChainTamperedPayload.first_tampered_event_id must be non-empty")
148
+ if not isinstance(self.tampered_count, int) or self.tampered_count < 0:
149
+ raise ValueError("AuditChainTamperedPayload.tampered_count must be a non-negative int")
150
+ if not self.detected_at:
151
+ raise ValueError("AuditChainTamperedPayload.detected_at must be non-empty")
152
+ if not self.detected_by:
153
+ raise ValueError("AuditChainTamperedPayload.detected_by must be non-empty")
154
+ if self.severity is not None and self.severity not in _VALID_SEVERITIES:
155
+ raise ValueError(f"AuditChainTamperedPayload.severity must be one of {sorted(_VALID_SEVERITIES)}") # noqa: E501
156
+
157
+ def to_dict(self) -> dict[str, Any]:
158
+ """Serialise the payload to a plain ``dict``."""
159
+ d: dict[str, Any] = {
160
+ "first_tampered_event_id": self.first_tampered_event_id,
161
+ "tampered_count": self.tampered_count,
162
+ "detected_at": self.detected_at,
163
+ "detected_by": self.detected_by,
164
+ }
165
+ if self.gap_count is not None:
166
+ d["gap_count"] = self.gap_count
167
+ if self.gap_prev_ids:
168
+ d["gap_prev_ids"] = list(self.gap_prev_ids)
169
+ if self.severity is not None:
170
+ d["severity"] = self.severity
171
+ return d
172
+
173
+ @classmethod
174
+ def from_dict(cls, data: dict[str, Any]) -> AuditChainTamperedPayload:
175
+ """Deserialise from a plain ``dict``."""
176
+ return cls(
177
+ first_tampered_event_id=data["first_tampered_event_id"],
178
+ tampered_count=int(data["tampered_count"]),
179
+ detected_at=data["detected_at"],
180
+ detected_by=data["detected_by"],
181
+ gap_count=int(data["gap_count"]) if "gap_count" in data else None,
182
+ gap_prev_ids=list(data.get("gap_prev_ids", [])),
183
+ severity=data.get("severity"),
184
+ )
185
+
186
+
187
+ @dataclass
188
+ class AuditChainPayload:
189
+ """RFC-0001 SPANFORGE — tamper-evident cross-reference audit chain event.
190
+
191
+ Every event emitted in the 10 RFC-0001 SPANFORGE namespaces is cross-
192
+ referenced into this immutable audit chain. Each entry holds the HMAC
193
+ of the referenced event and the chained HMAC of all prior chain entries,
194
+ making any post-emission mutation detectable.
195
+
196
+ Events:
197
+ audit.event_signed — a new event was appended to the chain
198
+ audit.chain_verified — a chain segment was verified intact
199
+ audit.tamper_detected — a break in the HMAC sequence was detected
200
+ """
201
+
202
+ event_id: str # ULID of the referenced event
203
+ event_type: str # wire event type string of the referenced event
204
+ event_hmac: str # HMAC-SHA256 of the referenced event canonical JSON
205
+ chain_position: int # monotonically increasing position in the chain
206
+ signer_id: str # identity of the signing service / key ID
207
+ signed_at: str # ISO 8601 timestamp with 6 decimal places
208
+ prev_chain_hmac: str | None = None # HMAC of the previous chain entry; None for entry 0
209
+
210
+ def __post_init__(self) -> None:
211
+ if not self.event_id:
212
+ raise ValueError("AuditChainPayload.event_id must be non-empty")
213
+ if not self.event_type:
214
+ raise ValueError("AuditChainPayload.event_type must be non-empty")
215
+ if not self.event_hmac:
216
+ raise ValueError("AuditChainPayload.event_hmac must be non-empty")
217
+ if not isinstance(self.chain_position, int) or self.chain_position < 0:
218
+ raise ValueError("AuditChainPayload.chain_position must be a non-negative int")
219
+ if not self.signer_id:
220
+ raise ValueError("AuditChainPayload.signer_id must be non-empty")
221
+ if not self.signed_at:
222
+ raise ValueError("AuditChainPayload.signed_at must be non-empty")
223
+ if self.chain_position > 0 and not self.prev_chain_hmac:
224
+ raise ValueError(
225
+ "AuditChainPayload.prev_chain_hmac is required when chain_position > 0"
226
+ )
227
+
228
+ def to_dict(self) -> dict[str, Any]:
229
+ """Serialise the payload to a plain ``dict``."""
230
+ d: dict[str, Any] = {
231
+ "event_id": self.event_id,
232
+ "event_type": self.event_type,
233
+ "event_hmac": self.event_hmac,
234
+ "chain_position": self.chain_position,
235
+ "signer_id": self.signer_id,
236
+ "signed_at": self.signed_at,
237
+ }
238
+ if self.prev_chain_hmac is not None:
239
+ d["prev_chain_hmac"] = self.prev_chain_hmac
240
+ return d
241
+
242
+ @classmethod
243
+ def from_dict(cls, data: dict[str, Any]) -> AuditChainPayload:
244
+ """Deserialise from a plain ``dict``."""
245
+ return cls(
246
+ event_id=data["event_id"],
247
+ event_type=data["event_type"],
248
+ event_hmac=data["event_hmac"],
249
+ chain_position=int(data["chain_position"]),
250
+ signer_id=data["signer_id"],
251
+ signed_at=data["signed_at"],
252
+ prev_chain_hmac=data.get("prev_chain_hmac"),
253
+ )
@@ -0,0 +1,209 @@
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
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass
13
+ from typing import Any
14
+
15
+ from spanforge.namespaces.trace import CostBreakdown, ModelInfo, TokenUsage
16
+
17
+ __all__ = [
18
+ "CacheEvictedPayload",
19
+ "CacheHitPayload",
20
+ "CacheMissPayload",
21
+ "CacheWrittenPayload",
22
+ ]
23
+
24
+ _VALID_EVICTION_REASONS = frozenset({
25
+ "ttl_expired", "lru_eviction", "manual_invalidation",
26
+ "capacity_exceeded", "schema_upgrade",
27
+ })
28
+
29
+
30
+ @dataclass
31
+ class CacheHitPayload:
32
+ """Payload for llm.cache.hit — semantic cache lookup succeeded."""
33
+
34
+ key_hash: str
35
+ namespace: str
36
+ similarity_score: float
37
+ ttl_remaining_seconds: int | None = None
38
+ cached_model: ModelInfo | None = None
39
+ cost_saved: CostBreakdown | None = None
40
+ tokens_saved: TokenUsage | None = None
41
+ lookup_duration_ms: float | None = None
42
+
43
+ def __post_init__(self) -> None:
44
+ if not self.key_hash:
45
+ raise ValueError("CacheHitPayload.key_hash must be non-empty")
46
+ if not self.namespace:
47
+ raise ValueError("CacheHitPayload.namespace must be non-empty")
48
+ if not (0.0 <= self.similarity_score <= 1.0):
49
+ raise ValueError("CacheHitPayload.similarity_score must be in [0,1]")
50
+
51
+ def to_dict(self) -> dict[str, Any]:
52
+ """Serialise the payload to a plain ``dict``."""
53
+ d: dict[str, Any] = {
54
+ "key_hash": self.key_hash,
55
+ "namespace": self.namespace,
56
+ "similarity_score": self.similarity_score,
57
+ }
58
+ if self.ttl_remaining_seconds is not None:
59
+ d["ttl_remaining_seconds"] = self.ttl_remaining_seconds
60
+ if self.cached_model is not None:
61
+ d["cached_model"] = self.cached_model.to_dict()
62
+ if self.cost_saved is not None:
63
+ d["cost_saved"] = self.cost_saved.to_dict()
64
+ if self.tokens_saved is not None:
65
+ d["tokens_saved"] = self.tokens_saved.to_dict()
66
+ if self.lookup_duration_ms is not None:
67
+ d["lookup_duration_ms"] = self.lookup_duration_ms
68
+ return d
69
+
70
+ @classmethod
71
+ def from_dict(cls, data: dict[str, Any]) -> CacheHitPayload:
72
+ """Deserialise from a plain ``dict``."""
73
+ return cls(
74
+ key_hash=data["key_hash"],
75
+ namespace=data["namespace"],
76
+ similarity_score=float(data["similarity_score"]),
77
+ ttl_remaining_seconds=int(data["ttl_remaining_seconds"]) if "ttl_remaining_seconds" in data else None, # noqa: E501
78
+ cached_model=ModelInfo.from_dict(data["cached_model"]) if "cached_model" in data else None, # noqa: E501
79
+ cost_saved=CostBreakdown.from_dict(data["cost_saved"]) if "cost_saved" in data else None, # noqa: E501
80
+ tokens_saved=TokenUsage.from_dict(data["tokens_saved"]) if "tokens_saved" in data else None, # noqa: E501
81
+ lookup_duration_ms=float(data["lookup_duration_ms"]) if "lookup_duration_ms" in data else None, # noqa: E501
82
+ )
83
+
84
+
85
+ @dataclass
86
+ class CacheMissPayload:
87
+ """Payload for llm.cache.miss — semantic cache lookup failed."""
88
+
89
+ key_hash: str
90
+ namespace: str
91
+ best_similarity_score: float | None = None
92
+ similarity_threshold: float | None = None
93
+ lookup_duration_ms: float | None = None
94
+
95
+ def __post_init__(self) -> None:
96
+ if not self.key_hash:
97
+ raise ValueError("CacheMissPayload.key_hash must be non-empty")
98
+ if not self.namespace:
99
+ raise ValueError("CacheMissPayload.namespace must be non-empty")
100
+
101
+ def to_dict(self) -> dict[str, Any]:
102
+ """Serialise the payload to a plain ``dict``."""
103
+ d: dict[str, Any] = {"key_hash": self.key_hash, "namespace": self.namespace}
104
+ if self.best_similarity_score is not None:
105
+ d["best_similarity_score"] = self.best_similarity_score
106
+ if self.similarity_threshold is not None:
107
+ d["similarity_threshold"] = self.similarity_threshold
108
+ if self.lookup_duration_ms is not None:
109
+ d["lookup_duration_ms"] = self.lookup_duration_ms
110
+ return d
111
+
112
+ @classmethod
113
+ def from_dict(cls, data: dict[str, Any]) -> CacheMissPayload:
114
+ """Deserialise from a plain ``dict``."""
115
+ return cls(
116
+ key_hash=data["key_hash"],
117
+ namespace=data["namespace"],
118
+ best_similarity_score=float(data["best_similarity_score"]) if "best_similarity_score" in data else None, # noqa: E501
119
+ similarity_threshold=float(data["similarity_threshold"]) if "similarity_threshold" in data else None, # noqa: E501
120
+ lookup_duration_ms=float(data["lookup_duration_ms"]) if "lookup_duration_ms" in data else None, # noqa: E501
121
+ )
122
+
123
+
124
+ @dataclass
125
+ class CacheEvictedPayload:
126
+ """Payload for llm.cache.evicted — a cache entry was removed."""
127
+
128
+ key_hash: str
129
+ namespace: str
130
+ eviction_reason: str
131
+ entry_age_seconds: int | None = None
132
+
133
+ def __post_init__(self) -> None:
134
+ if not self.key_hash:
135
+ raise ValueError("CacheEvictedPayload.key_hash must be non-empty")
136
+ if not self.namespace:
137
+ raise ValueError("CacheEvictedPayload.namespace must be non-empty")
138
+ if self.eviction_reason not in _VALID_EVICTION_REASONS:
139
+ raise ValueError(
140
+ f"CacheEvictedPayload.eviction_reason must be one of {sorted(_VALID_EVICTION_REASONS)}" # noqa: E501
141
+ )
142
+
143
+ def to_dict(self) -> dict[str, Any]:
144
+ """Serialise the payload to a plain ``dict``."""
145
+ d: dict[str, Any] = {
146
+ "key_hash": self.key_hash,
147
+ "namespace": self.namespace,
148
+ "eviction_reason": self.eviction_reason,
149
+ }
150
+ if self.entry_age_seconds is not None:
151
+ d["entry_age_seconds"] = self.entry_age_seconds
152
+ return d
153
+
154
+ @classmethod
155
+ def from_dict(cls, data: dict[str, Any]) -> CacheEvictedPayload:
156
+ """Deserialise from a plain ``dict``."""
157
+ return cls(
158
+ key_hash=data["key_hash"],
159
+ namespace=data["namespace"],
160
+ eviction_reason=data["eviction_reason"],
161
+ entry_age_seconds=int(data["entry_age_seconds"]) if "entry_age_seconds" in data else None, # noqa: E501
162
+ )
163
+
164
+
165
+ @dataclass
166
+ class CacheWrittenPayload:
167
+ """Payload for llm.cache.written — a response was written to cache."""
168
+
169
+ key_hash: str
170
+ namespace: str
171
+ ttl_seconds: int
172
+ model: ModelInfo | None = None
173
+ response_token_count: int | None = None
174
+ write_duration_ms: float | None = None
175
+
176
+ def __post_init__(self) -> None:
177
+ if not self.key_hash:
178
+ raise ValueError("CacheWrittenPayload.key_hash must be non-empty")
179
+ if not self.namespace:
180
+ raise ValueError("CacheWrittenPayload.namespace must be non-empty")
181
+ if not isinstance(self.ttl_seconds, int) or self.ttl_seconds < 0:
182
+ raise ValueError("CacheWrittenPayload.ttl_seconds must be a non-negative int")
183
+
184
+ def to_dict(self) -> dict[str, Any]:
185
+ """Serialise the payload to a plain ``dict``."""
186
+ d: dict[str, Any] = {
187
+ "key_hash": self.key_hash,
188
+ "namespace": self.namespace,
189
+ "ttl_seconds": self.ttl_seconds,
190
+ }
191
+ if self.model is not None:
192
+ d["model"] = self.model.to_dict()
193
+ if self.response_token_count is not None:
194
+ d["response_token_count"] = self.response_token_count
195
+ if self.write_duration_ms is not None:
196
+ d["write_duration_ms"] = self.write_duration_ms
197
+ return d
198
+
199
+ @classmethod
200
+ def from_dict(cls, data: dict[str, Any]) -> CacheWrittenPayload:
201
+ """Deserialise from a plain ``dict``."""
202
+ return cls(
203
+ key_hash=data["key_hash"],
204
+ namespace=data["namespace"],
205
+ ttl_seconds=int(data["ttl_seconds"]),
206
+ model=ModelInfo.from_dict(data["model"]) if "model" in data else None,
207
+ response_token_count=int(data["response_token_count"]) if "response_token_count" in data else None, # noqa: E501
208
+ write_duration_ms=float(data["write_duration_ms"]) if "write_duration_ms" in data else None, # noqa: E501
209
+ )