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,92 @@
1
+ """spanforge.namespaces.consent — Consent namespace payload types (RFC-0001 SPANFORGE).
2
+
3
+ Classes
4
+ -------
5
+ ConsentPayload consent.granted / consent.revoked / consent.violation
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+ from typing import Any, Literal
12
+
13
+ __all__ = ["ConsentPayload"]
14
+
15
+ _VALID_STATUSES = frozenset({"granted", "revoked", "violation"})
16
+ _VALID_LEGAL_BASES = frozenset(
17
+ {
18
+ "consent",
19
+ "contract",
20
+ "legal_obligation",
21
+ "vital_interest",
22
+ "public_task",
23
+ "legitimate_interest",
24
+ }
25
+ )
26
+
27
+
28
+ @dataclass
29
+ class ConsentPayload:
30
+ """RFC-0001 SPANFORGE — payload for consent.* events.
31
+
32
+ Tracks data-subject consent grants, revocations, and boundary violations
33
+ for GDPR Art. 6/7 and EU AI Act compliance (U — User Rights).
34
+ """
35
+
36
+ subject_id: str
37
+ scope: str
38
+ purpose: str
39
+ status: Literal["granted", "revoked", "violation"]
40
+ legal_basis: str = "consent"
41
+ expiry: str | None = None # ISO 8601 timestamp
42
+ agent_id: str | None = None
43
+ violation_detail: str | None = None
44
+ data_categories: list[str] = field(default_factory=list)
45
+
46
+ def __post_init__(self) -> None:
47
+ if not self.subject_id:
48
+ raise ValueError("ConsentPayload.subject_id must be non-empty")
49
+ if not self.scope:
50
+ raise ValueError("ConsentPayload.scope must be non-empty")
51
+ if not self.purpose:
52
+ raise ValueError("ConsentPayload.purpose must be non-empty")
53
+ if self.status not in _VALID_STATUSES:
54
+ raise ValueError(f"ConsentPayload.status must be one of {sorted(_VALID_STATUSES)}")
55
+ if self.legal_basis not in _VALID_LEGAL_BASES:
56
+ raise ValueError(
57
+ f"ConsentPayload.legal_basis must be one of {sorted(_VALID_LEGAL_BASES)}"
58
+ )
59
+
60
+ def to_dict(self) -> dict[str, Any]:
61
+ """Serialise to a plain dict."""
62
+ d: dict[str, Any] = {
63
+ "subject_id": self.subject_id,
64
+ "scope": self.scope,
65
+ "purpose": self.purpose,
66
+ "status": self.status,
67
+ "legal_basis": self.legal_basis,
68
+ }
69
+ if self.expiry is not None:
70
+ d["expiry"] = self.expiry
71
+ if self.agent_id is not None:
72
+ d["agent_id"] = self.agent_id
73
+ if self.violation_detail is not None:
74
+ d["violation_detail"] = self.violation_detail
75
+ if self.data_categories:
76
+ d["data_categories"] = list(self.data_categories)
77
+ return d
78
+
79
+ @classmethod
80
+ def from_dict(cls, data: dict[str, Any]) -> ConsentPayload:
81
+ """Deserialise from a plain dict."""
82
+ return cls(
83
+ subject_id=data["subject_id"],
84
+ scope=data["scope"],
85
+ purpose=data["purpose"],
86
+ status=data["status"],
87
+ legal_basis=data.get("legal_basis", "consent"),
88
+ expiry=data.get("expiry"),
89
+ agent_id=data.get("agent_id"),
90
+ violation_detail=data.get("violation_detail"),
91
+ data_categories=list(data.get("data_categories", [])),
92
+ )
@@ -0,0 +1,179 @@
1
+ """spanforge.namespaces.cost — Cost payload types (RFC-0001 §9).
2
+
3
+ Classes
4
+ -------
5
+ CostTokenRecordedPayload
6
+ RFC §9.1 — cost recorded for a single model call.
7
+ CostSessionRecordedPayload
8
+ RFC §9.2 — aggregate cost across a session.
9
+ CostAttributedPayload
10
+ RFC §9.3 — cost attributed to a specific target.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass, field
16
+ from typing import Any
17
+
18
+ from spanforge.namespaces.trace import (
19
+ CostBreakdown,
20
+ ModelInfo,
21
+ PricingTier,
22
+ TokenUsage,
23
+ )
24
+
25
+ __all__ = [
26
+ "CostAttributedPayload",
27
+ "CostSessionRecordedPayload",
28
+ "CostTokenRecordedPayload",
29
+ ]
30
+
31
+ _VALID_ATTRIBUTION_TYPES = frozenset({"direct", "proportional", "estimated", "manual"})
32
+
33
+
34
+ @dataclass
35
+ class CostTokenRecordedPayload:
36
+ """RFC-0001 §9.1 — Cost recorded for a single model call (one span).
37
+
38
+ Used with event type: ``llm.cost.token.recorded``.
39
+ """
40
+
41
+ cost: CostBreakdown
42
+ token_usage: TokenUsage
43
+ model: ModelInfo
44
+ pricing_tier: PricingTier | None = None
45
+ span_id: str | None = None
46
+ agent_run_id: str | None = None
47
+
48
+ def __post_init__(self) -> None:
49
+ if not isinstance(self.cost, CostBreakdown):
50
+ raise TypeError("CostTokenRecordedPayload.cost must be a CostBreakdown")
51
+ if not isinstance(self.token_usage, TokenUsage):
52
+ raise TypeError("CostTokenRecordedPayload.token_usage must be a TokenUsage")
53
+ if not isinstance(self.model, ModelInfo):
54
+ raise TypeError("CostTokenRecordedPayload.model must be a ModelInfo")
55
+
56
+ def to_dict(self) -> dict[str, Any]:
57
+ """Serialise the payload to a plain ``dict``."""
58
+ d: dict[str, Any] = {
59
+ "cost": self.cost.to_dict(),
60
+ "token_usage": self.token_usage.to_dict(),
61
+ "model": self.model.to_dict(),
62
+ }
63
+ if self.pricing_tier is not None:
64
+ d["pricing_tier"] = self.pricing_tier.to_dict()
65
+ if self.span_id is not None:
66
+ d["span_id"] = self.span_id
67
+ if self.agent_run_id is not None:
68
+ d["agent_run_id"] = self.agent_run_id
69
+ return d
70
+
71
+ @classmethod
72
+ def from_dict(cls, data: dict[str, Any]) -> CostTokenRecordedPayload:
73
+ """Deserialise from a plain ``dict``."""
74
+ return cls(
75
+ cost=CostBreakdown.from_dict(data["cost"]),
76
+ token_usage=TokenUsage.from_dict(data["token_usage"]),
77
+ model=ModelInfo.from_dict(data["model"]),
78
+ pricing_tier=PricingTier.from_dict(data["pricing_tier"])
79
+ if "pricing_tier" in data
80
+ else None,
81
+ span_id=data.get("span_id"),
82
+ agent_run_id=data.get("agent_run_id"),
83
+ )
84
+
85
+
86
+ @dataclass
87
+ class CostSessionRecordedPayload:
88
+ """RFC-0001 §9.2 — Aggregate cost across a session.
89
+
90
+ Used with event type: ``llm.cost.session.recorded``.
91
+ A session is any arbitrary grouping (user session, request batch, experiment run).
92
+ """
93
+
94
+ total_cost: CostBreakdown
95
+ total_token_usage: TokenUsage
96
+ call_count: int
97
+ session_duration_ms: float | None = None
98
+ models_used: list[str] = field(default_factory=list)
99
+
100
+ def __post_init__(self) -> None:
101
+ if not isinstance(self.total_cost, CostBreakdown):
102
+ raise TypeError("CostSessionRecordedPayload.total_cost must be a CostBreakdown")
103
+ if not isinstance(self.total_token_usage, TokenUsage):
104
+ raise TypeError("CostSessionRecordedPayload.total_token_usage must be a TokenUsage")
105
+ if not isinstance(self.call_count, int) or self.call_count < 0:
106
+ raise ValueError("CostSessionRecordedPayload.call_count must be a non-negative int")
107
+ if self.session_duration_ms is not None and self.session_duration_ms < 0:
108
+ raise ValueError("CostSessionRecordedPayload.session_duration_ms must be non-negative")
109
+
110
+ def to_dict(self) -> dict[str, Any]:
111
+ """Serialise the payload to a plain ``dict``."""
112
+ d: dict[str, Any] = {
113
+ "total_cost": self.total_cost.to_dict(),
114
+ "total_token_usage": self.total_token_usage.to_dict(),
115
+ "call_count": self.call_count,
116
+ }
117
+ if self.session_duration_ms is not None:
118
+ d["session_duration_ms"] = self.session_duration_ms
119
+ if self.models_used:
120
+ d["models_used"] = list(self.models_used)
121
+ return d
122
+
123
+ @classmethod
124
+ def from_dict(cls, data: dict[str, Any]) -> CostSessionRecordedPayload:
125
+ """Deserialise from a plain ``dict``."""
126
+ return cls(
127
+ total_cost=CostBreakdown.from_dict(data["total_cost"]),
128
+ total_token_usage=TokenUsage.from_dict(data["total_token_usage"]),
129
+ call_count=int(data["call_count"]),
130
+ session_duration_ms=float(data["session_duration_ms"])
131
+ if "session_duration_ms" in data
132
+ else None,
133
+ models_used=list(data.get("models_used", [])),
134
+ )
135
+
136
+
137
+ @dataclass
138
+ class CostAttributedPayload:
139
+ """RFC-0001 §9.3 — Cost attributed to a specific target.
140
+
141
+ Used with event type: ``llm.cost.attributed``.
142
+ ``attribution_type`` describes how the cost share was computed.
143
+ """
144
+
145
+ cost: CostBreakdown
146
+ attribution_target: str
147
+ attribution_type: str # "direct"|"proportional"|"estimated"|"manual"
148
+ source_event_ids: list[str] = field(default_factory=list)
149
+
150
+ def __post_init__(self) -> None:
151
+ if not isinstance(self.cost, CostBreakdown):
152
+ raise TypeError("CostAttributedPayload.cost must be a CostBreakdown")
153
+ if not isinstance(self.attribution_target, str) or not self.attribution_target:
154
+ raise ValueError("CostAttributedPayload.attribution_target must be a non-empty string")
155
+ if self.attribution_type not in _VALID_ATTRIBUTION_TYPES:
156
+ raise ValueError(
157
+ f"CostAttributedPayload.attribution_type must be one of {sorted(_VALID_ATTRIBUTION_TYPES)}"
158
+ )
159
+
160
+ def to_dict(self) -> dict[str, Any]:
161
+ """Serialise the payload to a plain ``dict``."""
162
+ d: dict[str, Any] = {
163
+ "cost": self.cost.to_dict(),
164
+ "attribution_target": self.attribution_target,
165
+ "attribution_type": self.attribution_type,
166
+ }
167
+ if self.source_event_ids:
168
+ d["source_event_ids"] = list(self.source_event_ids)
169
+ return d
170
+
171
+ @classmethod
172
+ def from_dict(cls, data: dict[str, Any]) -> CostAttributedPayload:
173
+ """Deserialise from a plain ``dict``."""
174
+ return cls(
175
+ cost=CostBreakdown.from_dict(data["cost"]),
176
+ attribution_target=data["attribution_target"],
177
+ attribution_type=data["attribution_type"],
178
+ source_event_ids=list(data.get("source_event_ids", [])),
179
+ )
@@ -0,0 +1,143 @@
1
+ """spanforge.namespaces.decision — Decision namespace payload types (RFC-0001 SPANFORGE).
2
+
3
+ Classes
4
+ -------
5
+ DecisionDriver Factor contributing to a decision (T \u2014 Transparency)
6
+ DecisionPayload decision.made / decision.revised / decision.rejected
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass, field
12
+ from typing import Any
13
+
14
+ __all__ = [
15
+ "DecisionDriver",
16
+ "DecisionPayload",
17
+ ]
18
+
19
+ _VALID_DECISION_TYPES = frozenset(
20
+ {
21
+ "classification",
22
+ "routing",
23
+ "generation",
24
+ "tool_selection",
25
+ "other",
26
+ }
27
+ )
28
+
29
+
30
+ @dataclass
31
+ class DecisionDriver:
32
+ """A single factor that contributed to an agent decision (T \u2014 Transparency).
33
+
34
+ ``weight`` and ``confidence`` must be in the range [0.0, 1.0].
35
+ The sum of all ``weight`` values in a list should equal 1.0 but is
36
+ not enforced here (enforcement is the caller's responsibility).
37
+ """
38
+
39
+ factor_name: str
40
+ weight: float # 0.0\u20131.0; fractional contribution to the overall decision
41
+ contribution: float # signed contribution to the final decision score
42
+ evidence: str # human-readable evidence string
43
+ confidence: float # 0.0\u20131.0
44
+
45
+ def __post_init__(self) -> None:
46
+ if not self.factor_name:
47
+ raise ValueError("DecisionDriver.factor_name must be non-empty")
48
+ if not (0.0 <= self.weight <= 1.0):
49
+ raise ValueError("DecisionDriver.weight must be in [0.0, 1.0]")
50
+ if not (0.0 <= self.confidence <= 1.0):
51
+ raise ValueError("DecisionDriver.confidence must be in [0.0, 1.0]")
52
+
53
+ def to_dict(self) -> dict[str, Any]:
54
+ """Serialise to a plain dict."""
55
+ return {
56
+ "factor_name": self.factor_name,
57
+ "weight": self.weight,
58
+ "contribution": self.contribution,
59
+ "evidence": self.evidence,
60
+ "confidence": self.confidence,
61
+ }
62
+
63
+ @classmethod
64
+ def from_dict(cls, data: dict[str, Any]) -> DecisionDriver:
65
+ """Deserialise from a plain dict."""
66
+ return cls(
67
+ factor_name=data["factor_name"],
68
+ weight=float(data["weight"]),
69
+ contribution=float(data["contribution"]),
70
+ evidence=data["evidence"],
71
+ confidence=float(data["confidence"]),
72
+ )
73
+
74
+
75
+ @dataclass
76
+ class DecisionPayload:
77
+ """RFC-0001 SPANFORGE \u2014 payload for decision.* events.
78
+
79
+ Captures every individual agent decision at inference time, including
80
+ the full set of contributing decision drivers for T \u2014 Transparency.
81
+
82
+ ``actor`` is an optional dict representation of an ActorContext and is
83
+ intentionally typed as ``dict | None`` to avoid a circular import.
84
+ """
85
+
86
+ decision_id: str # ULID
87
+ agent_id: str
88
+ decision_type: str # classification | routing | generation | tool_selection | other
89
+ input_summary: str
90
+ output_summary: str
91
+ confidence: float # 0.0\u20131.0
92
+ latency_ms: float
93
+ rationale_hash: str # SHA-256 of the full rationale text
94
+ decision_drivers: list[DecisionDriver] = field(default_factory=list)
95
+ actor: dict[str, Any] | None = None
96
+
97
+ def __post_init__(self) -> None:
98
+ if not self.decision_id:
99
+ raise ValueError("DecisionPayload.decision_id must be non-empty")
100
+ if not self.agent_id:
101
+ raise ValueError("DecisionPayload.agent_id must be non-empty")
102
+ if self.decision_type not in _VALID_DECISION_TYPES:
103
+ raise ValueError(
104
+ f"DecisionPayload.decision_type must be one of {sorted(_VALID_DECISION_TYPES)}"
105
+ )
106
+ if not (0.0 <= self.confidence <= 1.0):
107
+ raise ValueError("DecisionPayload.confidence must be in [0.0, 1.0]")
108
+ if self.latency_ms < 0:
109
+ raise ValueError("DecisionPayload.latency_ms must be >= 0")
110
+
111
+ def to_dict(self) -> dict[str, Any]:
112
+ """Serialise to a plain dict."""
113
+ d: dict[str, Any] = {
114
+ "decision_id": self.decision_id,
115
+ "agent_id": self.agent_id,
116
+ "decision_type": self.decision_type,
117
+ "input_summary": self.input_summary,
118
+ "output_summary": self.output_summary,
119
+ "confidence": self.confidence,
120
+ "latency_ms": self.latency_ms,
121
+ "rationale_hash": self.rationale_hash,
122
+ "decision_drivers": [d.to_dict() for d in self.decision_drivers],
123
+ }
124
+ if self.actor is not None:
125
+ d["actor"] = self.actor
126
+ return d
127
+
128
+ @classmethod
129
+ def from_dict(cls, data: dict[str, Any]) -> DecisionPayload:
130
+ """Deserialise from a plain dict."""
131
+ drivers = [DecisionDriver.from_dict(dd) for dd in data.get("decision_drivers", [])]
132
+ return cls(
133
+ decision_id=data["decision_id"],
134
+ agent_id=data["agent_id"],
135
+ decision_type=data["decision_type"],
136
+ input_summary=data["input_summary"],
137
+ output_summary=data["output_summary"],
138
+ confidence=float(data["confidence"]),
139
+ latency_ms=float(data["latency_ms"]),
140
+ rationale_hash=data["rationale_hash"],
141
+ decision_drivers=drivers,
142
+ actor=data.get("actor"),
143
+ )
@@ -0,0 +1,157 @@
1
+ """spanforge.namespaces.diff — Diff payload types (RFC-0001).
2
+
3
+ Classes
4
+ -------
5
+ DiffComputedPayload llm.diff.computed
6
+ DiffRegressionFlaggedPayload llm.diff.regression.flagged
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+ from typing import Any
13
+
14
+ __all__ = [
15
+ "DiffComputedPayload",
16
+ "DiffRegressionFlaggedPayload",
17
+ ]
18
+
19
+ _VALID_DIFF_TYPES = frozenset({"prompt", "response", "template", "token_usage", "cost"})
20
+ _VALID_ALGORITHMS = frozenset(
21
+ {"embedding_cosine", "levenshtein", "token_edit", "lcs", "semantic_embedding"}
22
+ )
23
+ _VALID_SEVERITIES = frozenset({"low", "medium", "high", "critical"})
24
+
25
+
26
+ @dataclass
27
+ class DiffComputedPayload:
28
+ """RFC-0001 — A diff was computed between two events."""
29
+
30
+ ref_event_id: str
31
+ target_event_id: str
32
+ diff_type: str # "prompt"|"response"|"template"|"token_usage"|"cost"
33
+ similarity_score: float
34
+ added_tokens: int | None = None
35
+ removed_tokens: int | None = None
36
+ diff_algorithm: str | None = None
37
+ ref_content_hash: str | None = None # 64 hex chars
38
+ target_content_hash: str | None = None # 64 hex chars
39
+ computation_duration_ms: float | None = None
40
+
41
+ def __post_init__(self) -> None:
42
+ if not self.ref_event_id:
43
+ raise ValueError("DiffComputedPayload.ref_event_id must be non-empty")
44
+ if not self.target_event_id:
45
+ raise ValueError("DiffComputedPayload.target_event_id must be non-empty")
46
+ if self.diff_type not in _VALID_DIFF_TYPES:
47
+ raise ValueError(
48
+ f"DiffComputedPayload.diff_type must be one of {sorted(_VALID_DIFF_TYPES)}"
49
+ )
50
+ if not (0.0 <= self.similarity_score <= 1.0):
51
+ raise ValueError("DiffComputedPayload.similarity_score must be in [0,1]")
52
+ if self.diff_algorithm is not None and self.diff_algorithm not in _VALID_ALGORITHMS:
53
+ raise ValueError(
54
+ f"DiffComputedPayload.diff_algorithm must be one of {sorted(_VALID_ALGORITHMS)}"
55
+ )
56
+
57
+ def to_dict(self) -> dict[str, Any]:
58
+ """Serialise the payload to a plain ``dict``."""
59
+ d: dict[str, Any] = {
60
+ "ref_event_id": self.ref_event_id,
61
+ "target_event_id": self.target_event_id,
62
+ "diff_type": self.diff_type,
63
+ "similarity_score": self.similarity_score,
64
+ }
65
+ if self.added_tokens is not None:
66
+ d["added_tokens"] = self.added_tokens
67
+ if self.removed_tokens is not None:
68
+ d["removed_tokens"] = self.removed_tokens
69
+ if self.diff_algorithm is not None:
70
+ d["diff_algorithm"] = self.diff_algorithm
71
+ if self.ref_content_hash is not None:
72
+ d["ref_content_hash"] = self.ref_content_hash
73
+ if self.target_content_hash is not None:
74
+ d["target_content_hash"] = self.target_content_hash
75
+ if self.computation_duration_ms is not None:
76
+ d["computation_duration_ms"] = self.computation_duration_ms
77
+ return d
78
+
79
+ @classmethod
80
+ def from_dict(cls, data: dict[str, Any]) -> DiffComputedPayload:
81
+ """Deserialise from a plain ``dict``."""
82
+ return cls(
83
+ ref_event_id=data["ref_event_id"],
84
+ target_event_id=data["target_event_id"],
85
+ diff_type=data["diff_type"],
86
+ similarity_score=float(data["similarity_score"]),
87
+ added_tokens=int(data["added_tokens"]) if "added_tokens" in data else None,
88
+ removed_tokens=int(data["removed_tokens"]) if "removed_tokens" in data else None,
89
+ diff_algorithm=data.get("diff_algorithm"),
90
+ ref_content_hash=data.get("ref_content_hash"),
91
+ target_content_hash=data.get("target_content_hash"),
92
+ computation_duration_ms=float(data["computation_duration_ms"])
93
+ if "computation_duration_ms" in data
94
+ else None,
95
+ )
96
+
97
+
98
+ @dataclass
99
+ class DiffRegressionFlaggedPayload:
100
+ """RFC-0001 — A diff score fell below the similarity threshold."""
101
+
102
+ ref_event_id: str
103
+ target_event_id: str
104
+ diff_type: str
105
+ similarity_score: float
106
+ threshold: float
107
+ severity: str # "low"|"medium"|"high"|"critical"
108
+ diff_event_id: str | None = None
109
+ alert_target: str | None = None
110
+
111
+ def __post_init__(self) -> None:
112
+ if not self.ref_event_id:
113
+ raise ValueError("DiffRegressionFlaggedPayload.ref_event_id must be non-empty")
114
+ if not self.target_event_id:
115
+ raise ValueError("DiffRegressionFlaggedPayload.target_event_id must be non-empty")
116
+ if self.diff_type not in _VALID_DIFF_TYPES:
117
+ raise ValueError(
118
+ f"DiffRegressionFlaggedPayload.diff_type must be one of {sorted(_VALID_DIFF_TYPES)}"
119
+ )
120
+ if not (0.0 <= self.similarity_score <= 1.0):
121
+ raise ValueError("DiffRegressionFlaggedPayload.similarity_score must be in [0,1]")
122
+ if not (0.0 <= self.threshold <= 1.0):
123
+ raise ValueError("DiffRegressionFlaggedPayload.threshold must be in [0,1]")
124
+ if self.severity not in _VALID_SEVERITIES:
125
+ raise ValueError(
126
+ f"DiffRegressionFlaggedPayload.severity must be one of {sorted(_VALID_SEVERITIES)}"
127
+ )
128
+
129
+ def to_dict(self) -> dict[str, Any]:
130
+ """Serialise the payload to a plain ``dict``."""
131
+ d: dict[str, Any] = {
132
+ "ref_event_id": self.ref_event_id,
133
+ "target_event_id": self.target_event_id,
134
+ "diff_type": self.diff_type,
135
+ "similarity_score": self.similarity_score,
136
+ "threshold": self.threshold,
137
+ "severity": self.severity,
138
+ }
139
+ if self.diff_event_id is not None:
140
+ d["diff_event_id"] = self.diff_event_id
141
+ if self.alert_target is not None:
142
+ d["alert_target"] = self.alert_target
143
+ return d
144
+
145
+ @classmethod
146
+ def from_dict(cls, data: dict[str, Any]) -> DiffRegressionFlaggedPayload:
147
+ """Deserialise from a plain ``dict``."""
148
+ return cls(
149
+ ref_event_id=data["ref_event_id"],
150
+ target_event_id=data["target_event_id"],
151
+ diff_type=data["diff_type"],
152
+ similarity_score=float(data["similarity_score"]),
153
+ threshold=float(data["threshold"]),
154
+ severity=data["severity"],
155
+ diff_event_id=data.get("diff_event_id"),
156
+ alert_target=data.get("alert_target"),
157
+ )
@@ -0,0 +1,80 @@
1
+ """spanforge.namespaces.drift \u2014 Drift namespace payload types (RFC-0001 SPANFORGE).
2
+
3
+ Classes
4
+ -------
5
+ DriftPayload drift.detected / drift.threshold_breach / drift.resolved
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ from typing import Any, Literal
12
+
13
+ __all__ = ["DriftPayload"]
14
+
15
+ _VALID_STATUSES = frozenset({"detected", "threshold_breach", "resolved"})
16
+
17
+
18
+ @dataclass
19
+ class DriftPayload:
20
+ """RFC-0001 SPANFORGE \u2014 payload for drift.* events.
21
+
22
+ Captures Z-score and KL-divergence statistical drift signals against the
23
+ deployment baseline (T \u2014 Traceability).
24
+ """
25
+
26
+ metric_name: str
27
+ agent_id: str
28
+ current_value: float
29
+ baseline_mean: float
30
+ baseline_stddev: float
31
+ z_score: float
32
+ threshold: float
33
+ window_seconds: int
34
+ status: Literal["detected", "threshold_breach", "resolved"]
35
+ kl_divergence: float | None = None
36
+
37
+ def __post_init__(self) -> None:
38
+ if not self.metric_name:
39
+ raise ValueError("DriftPayload.metric_name must be non-empty")
40
+ if not self.agent_id:
41
+ raise ValueError("DriftPayload.agent_id must be non-empty")
42
+ if self.status not in _VALID_STATUSES:
43
+ raise ValueError(f"DriftPayload.status must be one of {sorted(_VALID_STATUSES)}")
44
+ if self.window_seconds <= 0:
45
+ raise ValueError("DriftPayload.window_seconds must be > 0")
46
+ if self.baseline_stddev < 0:
47
+ raise ValueError("DriftPayload.baseline_stddev must be >= 0")
48
+
49
+ def to_dict(self) -> dict[str, Any]:
50
+ """Serialise to a plain dict."""
51
+ d: dict[str, Any] = {
52
+ "metric_name": self.metric_name,
53
+ "agent_id": self.agent_id,
54
+ "current_value": self.current_value,
55
+ "baseline_mean": self.baseline_mean,
56
+ "baseline_stddev": self.baseline_stddev,
57
+ "z_score": self.z_score,
58
+ "threshold": self.threshold,
59
+ "window_seconds": self.window_seconds,
60
+ "status": self.status,
61
+ }
62
+ if self.kl_divergence is not None:
63
+ d["kl_divergence"] = self.kl_divergence
64
+ return d
65
+
66
+ @classmethod
67
+ def from_dict(cls, data: dict[str, Any]) -> DriftPayload:
68
+ """Deserialise from a plain dict."""
69
+ return cls(
70
+ metric_name=data["metric_name"],
71
+ agent_id=data["agent_id"],
72
+ current_value=float(data["current_value"]),
73
+ baseline_mean=float(data["baseline_mean"]),
74
+ baseline_stddev=float(data["baseline_stddev"]),
75
+ z_score=float(data["z_score"]),
76
+ threshold=float(data["threshold"]),
77
+ window_seconds=int(data["window_seconds"]),
78
+ status=data["status"],
79
+ kl_divergence=data.get("kl_divergence"),
80
+ )