nirizan 0.1.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 (43) hide show
  1. nirizan/__init__.py +18 -0
  2. nirizan/_logging.py +113 -0
  3. nirizan/gate/__init__.py +18 -0
  4. nirizan/gate/ci.py +80 -0
  5. nirizan/gate/verdict.py +170 -0
  6. nirizan/instrumentation/__init__.py +6 -0
  7. nirizan/instrumentation/exporters.py +47 -0
  8. nirizan/instrumentation/sdk.py +117 -0
  9. nirizan/instrumentation/sessions.py +18 -0
  10. nirizan/instrumentation/spans.py +62 -0
  11. nirizan/instrumentation/tracer.py +132 -0
  12. nirizan/metrics/__init__.py +6 -0
  13. nirizan/metrics/base.py +38 -0
  14. nirizan/metrics/behavioral_anchor.py +71 -0
  15. nirizan/metrics/lightweight_judge.py +87 -0
  16. nirizan/metrics/llm_judge.py +89 -0
  17. nirizan/metrics/rag_triad.py +107 -0
  18. nirizan/metrics/statistical_gating.py +203 -0
  19. nirizan/orchestrator/__init__.py +5 -0
  20. nirizan/orchestrator/collector.py +98 -0
  21. nirizan/orchestrator/dispatcher.py +23 -0
  22. nirizan/orchestrator/scheduler.py +76 -0
  23. nirizan/regression/__init__.py +22 -0
  24. nirizan/regression/comparator.py +275 -0
  25. nirizan/regression/thresholds.py +113 -0
  26. nirizan/reporting/__init__.py +5 -0
  27. nirizan/reporting/dashboard.py +113 -0
  28. nirizan/reporting/health_score.py +22 -0
  29. nirizan/reporting/judge_reliability.py +176 -0
  30. nirizan/storage/__init__.py +4 -0
  31. nirizan/storage/baselines.py +100 -0
  32. nirizan/storage/experiment_store.py +122 -0
  33. nirizan/storage/models.py +115 -0
  34. nirizan/storage/run_repository.py +26 -0
  35. nirizan/storage/session_repository.py +26 -0
  36. nirizan/storage/trace_repository.py +252 -0
  37. nirizan/trust/__init__.py +5 -0
  38. nirizan/trust/anchor_set.py +22 -0
  39. nirizan/trust/attribution.py +67 -0
  40. nirizan-0.1.0.dist-info/METADATA +108 -0
  41. nirizan-0.1.0.dist-info/RECORD +43 -0
  42. nirizan-0.1.0.dist-info/WHEEL +4 -0
  43. nirizan-0.1.0.dist-info/licenses/LICENSE +674 -0
nirizan/__init__.py ADDED
@@ -0,0 +1,18 @@
1
+ # src/nirizan/__init__.py
2
+ """NiriZan: Continuous evaluation infrastructure for production AI systems."""
3
+ from nirizan._logging import (
4
+ disable_logging,
5
+ enable_logging,
6
+ get_logger,
7
+ set_log_level,
8
+ )
9
+
10
+ __version__ = "0.1.0"
11
+
12
+ __all__ = [
13
+ "__version__",
14
+ "disable_logging",
15
+ "enable_logging",
16
+ "get_logger",
17
+ "set_log_level",
18
+ ]
nirizan/_logging.py ADDED
@@ -0,0 +1,113 @@
1
+ # src/nirizan/_logging.py
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+ import os
6
+ import sys
7
+ from typing import TextIO, Union
8
+
9
+ _ROOT_LOGGER_NAME = "nirizan"
10
+ _ENV_VAR_LOG_LEVEL = "NIRIZAN_LOG_LEVEL"
11
+
12
+ LogLevel = Union[int, str]
13
+
14
+
15
+ class _NiriZanStreamHandler(logging.StreamHandler[TextIO]):
16
+ """Internal StreamHandler subclass used to identify NiriZan-managed handlers."""
17
+
18
+
19
+ # Library-safe default: silence by default, let the host app opt in.
20
+ logging.getLogger(_ROOT_LOGGER_NAME).addHandler(logging.NullHandler())
21
+
22
+
23
+ def get_logger(module_name: str) -> logging.Logger:
24
+ """Return a logger scoped under the `nirizan` hierarchy.
25
+
26
+ Call as `get_logger(__name__)` from any module inside `nirizan/`.
27
+ """
28
+ return logging.getLogger(module_name)
29
+
30
+
31
+ class NiriZanFormatter(logging.Formatter):
32
+ """Formats a record with millisecond precision and full exception trace support:
33
+
34
+ [INFO] 2026-08-07 14:40:23.279 [NiriZan] tracer.py:182 Started trace 4baf5d17
35
+ """
36
+
37
+ def format(self, record: logging.LogRecord) -> str:
38
+ ts = self.formatTime(record, "%Y-%m-%d %H:%M:%S")
39
+ ts = f"{ts}.{int(record.msecs):03d}"
40
+
41
+ msg = (
42
+ f"[{record.levelname}] {ts} [NiriZan] "
43
+ f"{record.filename}:{record.lineno} {record.getMessage()}"
44
+ )
45
+
46
+ if record.exc_info:
47
+ if not record.exc_text:
48
+ record.exc_text = self.formatException(record.exc_info)
49
+ msg = f"{msg}\n{record.exc_text}"
50
+
51
+ if record.stack_info:
52
+ msg = f"{msg}\n{self.formatStack(record.stack_info)}"
53
+
54
+ return msg
55
+
56
+
57
+ def _parse_level(level: LogLevel | None) -> int:
58
+ """Parse int, string, or environment variable into a valid logging level."""
59
+ if level is None:
60
+ env_val = os.getenv(_ENV_VAR_LOG_LEVEL, "INFO").upper()
61
+ return getattr(logging, env_val, logging.INFO)
62
+
63
+ if isinstance(level, str):
64
+ numeric_level = getattr(logging, level.upper(), None)
65
+ if not isinstance(numeric_level, int):
66
+ raise ValueError(f"Invalid log level string: {level!r}")
67
+ return numeric_level
68
+
69
+ return level
70
+
71
+
72
+ def enable_logging(
73
+ level: LogLevel | None = None,
74
+ stream: TextIO | None = None,
75
+ ) -> logging.Logger:
76
+ """Opt-in logging configuration for notebooks, CLI runs, or host apps.
77
+
78
+ Accepts both string ("DEBUG", "INFO") and integer (logging.INFO) levels.
79
+ If no level is supplied, checks the `NIRIZAN_LOG_LEVEL` environment variable,
80
+ defaulting to `INFO`.
81
+
82
+ Idempotent: replaces only NiriZan-managed handlers without touching
83
+ handlers attached by host applications.
84
+ """
85
+ target_level = _parse_level(level)
86
+ root = logging.getLogger(_ROOT_LOGGER_NAME)
87
+ root.setLevel(target_level)
88
+
89
+ # Clean up prior NiriZan-managed handlers
90
+ for existing in list(root.handlers):
91
+ if isinstance(existing, _NiriZanStreamHandler):
92
+ root.removeHandler(existing)
93
+
94
+ handler = _NiriZanStreamHandler(stream or sys.stderr)
95
+ handler.setFormatter(NiriZanFormatter())
96
+ root.addHandler(handler)
97
+
98
+ return root
99
+
100
+
101
+ def set_log_level(level: LogLevel) -> None:
102
+ """Dynamically update log level for all NiriZan loggers without re-attaching handlers."""
103
+ target_level = _parse_level(level)
104
+ root = logging.getLogger(_ROOT_LOGGER_NAME)
105
+ root.setLevel(target_level)
106
+
107
+
108
+ def disable_logging() -> None:
109
+ """Remove all NiriZan-managed handlers and silence output."""
110
+ root = logging.getLogger(_ROOT_LOGGER_NAME)
111
+ for existing in list(root.handlers):
112
+ if isinstance(existing, _NiriZanStreamHandler):
113
+ root.removeHandler(existing)
@@ -0,0 +1,18 @@
1
+ """Deployment-aware CI/CD gating layer.
2
+
3
+ Evaluates regression verdicts alongside statistical confidence intervals to produce
4
+ release deployment signals.
5
+ """
6
+ # src/nirizan/gate/__init__.py
7
+
8
+ from nirizan.gate.verdict import (
9
+ GateVerdict,
10
+ evaluate_gate,
11
+ select_decision_metric,
12
+ )
13
+
14
+ __all__ = [
15
+ "GateVerdict",
16
+ "evaluate_gate",
17
+ "select_decision_metric",
18
+ ]
nirizan/gate/ci.py ADDED
@@ -0,0 +1,80 @@
1
+ # src/nirizan/gate/ci.py
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from typing import TextIO
6
+
7
+ from nirizan._logging import get_logger
8
+ from nirizan.gate.verdict import GateVerdict
9
+
10
+ logger = get_logger(__name__)
11
+
12
+
13
+ def format_gate_summary(verdict: GateVerdict) -> str:
14
+ lines = [
15
+ "| Metric | Severity | P-Value | Effect Size |",
16
+ "|---|---|---:|---:|",
17
+ ]
18
+
19
+ for regression in verdict.regression_verdicts:
20
+ p_value = (
21
+ f"{regression.p_value:.4e}"
22
+ if regression.p_value is not None
23
+ else "n/a"
24
+ )
25
+
26
+ effect_size = (
27
+ f"{regression.effect_size:.3f}"
28
+ if regression.effect_size is not None
29
+ else "n/a"
30
+ )
31
+
32
+ lines.append(
33
+ f"| {regression.metric_name} "
34
+ f"| {regression.severity.value} "
35
+ f"| {p_value} "
36
+ f"| {effect_size} |"
37
+ )
38
+
39
+ lines.append("")
40
+ lines.append(
41
+ f"**Gate:** {'PASS' if verdict.passed else 'BLOCK'}"
42
+ )
43
+ lines.append(
44
+ "**95% bootstrap CI:** "
45
+ f"`{verdict.confidence_interval[0]:.6f}, "
46
+ f"{verdict.confidence_interval[1]:.6f}`"
47
+ )
48
+
49
+ return "\n".join(lines)
50
+
51
+
52
+ def write_github_summary(
53
+ verdict: GateVerdict,
54
+ *,
55
+ output: TextIO,
56
+ ) -> None:
57
+ logger.info(
58
+ "Writing GitHub CI summary for run_id=%s (passed=%s)",
59
+ verdict.run_id,
60
+ verdict.passed,
61
+ )
62
+ output.write(format_gate_summary(verdict))
63
+ output.write("\n")
64
+
65
+
66
+ def gate_exit_code(verdict: GateVerdict) -> int:
67
+ if verdict.passed:
68
+ logger.info("CI Gate PASSED for run_id=%s", verdict.run_id)
69
+ return 0
70
+ else:
71
+ logger.error("CI Gate BLOCKED for run_id=%s", verdict.run_id)
72
+ return 1
73
+
74
+
75
+ def serialize_gate_verdict(verdict: GateVerdict) -> str:
76
+ logger.debug("Serializing GateVerdict for run_id=%s", verdict.run_id)
77
+ return json.dumps(
78
+ verdict.model_dump(mode="json"),
79
+ indent=2,
80
+ )
@@ -0,0 +1,170 @@
1
+ # src/nirizan/gate/verdict.py
2
+ from __future__ import annotations
3
+
4
+ from uuid import UUID
5
+
6
+ import numpy as np
7
+ from pydantic import BaseModel, ConfigDict, Field
8
+
9
+ from nirizan._logging import get_logger
10
+ from nirizan.regression.comparator import (
11
+ RegressionSeverity,
12
+ RegressionVerdict,
13
+ )
14
+
15
+ logger = get_logger(__name__)
16
+
17
+
18
+ class GateVerdict(BaseModel):
19
+ model_config = ConfigDict(strict=True)
20
+
21
+ passed: bool
22
+ confidence_interval: tuple[float, float]
23
+ regression_verdicts: list[RegressionVerdict] = Field(
24
+ default_factory=list
25
+ )
26
+ run_id: UUID
27
+
28
+
29
+ SEVERITY_WEIGHT = {
30
+ RegressionSeverity.BLOCKING: 3,
31
+ RegressionSeverity.WARNING: 2,
32
+ RegressionSeverity.NONE: 1,
33
+ }
34
+
35
+
36
+ def bootstrap_delta_ci(
37
+ candidate: np.ndarray,
38
+ baseline: np.ndarray,
39
+ *,
40
+ n_bootstrap: int = 5000,
41
+ confidence: float = 0.95,
42
+ seed: int = 42,
43
+ ) -> tuple[float, float]:
44
+ if candidate.size == 0 or baseline.size == 0:
45
+ raise ValueError(
46
+ "Both distributions must contain observations."
47
+ )
48
+
49
+ if n_bootstrap < 1:
50
+ raise ValueError("n_bootstrap must be positive.")
51
+
52
+ if not 0.0 < confidence < 1.0:
53
+ raise ValueError(
54
+ "confidence must be between 0 and 1."
55
+ )
56
+
57
+ logger.debug(
58
+ "Computing bootstrap delta CI: n_bootstrap=%d, confidence=%.2f, candidate_n=%d, baseline_n=%d",
59
+ n_bootstrap,
60
+ confidence,
61
+ candidate.size,
62
+ baseline.size,
63
+ )
64
+
65
+ rng = np.random.default_rng(seed)
66
+
67
+ candidate_samples = rng.choice(
68
+ candidate,
69
+ size=(n_bootstrap, candidate.size),
70
+ replace=True,
71
+ )
72
+
73
+ baseline_samples = rng.choice(
74
+ baseline,
75
+ size=(n_bootstrap, baseline.size),
76
+ replace=True,
77
+ )
78
+
79
+ deltas = (
80
+ candidate_samples.mean(axis=1)
81
+ - baseline_samples.mean(axis=1)
82
+ )
83
+
84
+ alpha = 1.0 - confidence
85
+
86
+ ci = (
87
+ float(np.quantile(deltas, alpha / 2.0)),
88
+ float(np.quantile(deltas, 1.0 - alpha / 2.0)),
89
+ )
90
+ logger.debug("Bootstrap CI computed: [%.6f, %.6f]", ci[0], ci[1])
91
+ return ci
92
+
93
+
94
+ def select_decision_metric(
95
+ verdicts: list[RegressionVerdict],
96
+ ) -> RegressionVerdict:
97
+ if not verdicts:
98
+ raise ValueError("At least one verdict is required.")
99
+
100
+ selected = min(
101
+ verdicts,
102
+ key=lambda verdict: (
103
+ -SEVERITY_WEIGHT[verdict.severity],
104
+ (
105
+ verdict.effect_size
106
+ if verdict.effect_size is not None
107
+ else 0.0
108
+ ),
109
+ ),
110
+ )
111
+ logger.debug(
112
+ "Selected decision metric '%s' (severity=%s, effect_size=%s)",
113
+ selected.metric_name,
114
+ selected.severity.value,
115
+ selected.effect_size,
116
+ )
117
+ return selected
118
+
119
+
120
+ def evaluate_gate(
121
+ *,
122
+ verdicts: list[RegressionVerdict],
123
+ scores_by_metric: dict[
124
+ str,
125
+ tuple[np.ndarray, np.ndarray],
126
+ ],
127
+ ) -> GateVerdict:
128
+ if not verdicts:
129
+ raise ValueError(
130
+ "Gate requires at least one regression verdict."
131
+ )
132
+
133
+ logger.info("Evaluating gate across %d regression verdict(s)", len(verdicts))
134
+
135
+ decision_metric = select_decision_metric(verdicts)
136
+
137
+ candidate_scores, baseline_scores = scores_by_metric[
138
+ decision_metric.metric_name
139
+ ]
140
+
141
+ confidence_interval = bootstrap_delta_ci(
142
+ candidate_scores,
143
+ baseline_scores,
144
+ )
145
+
146
+ blocking_verdicts = [
147
+ verdict
148
+ for verdict in verdicts
149
+ if verdict.severity == RegressionSeverity.BLOCKING
150
+ ]
151
+ passed = len(blocking_verdicts) == 0
152
+
153
+ if passed:
154
+ logger.info(
155
+ "Gate evaluation result: PASSED for run_id=%s",
156
+ decision_metric.run_id,
157
+ )
158
+ else:
159
+ logger.warning(
160
+ "Gate evaluation result: BLOCKED for run_id=%s (%d blocking regression(s))",
161
+ decision_metric.run_id,
162
+ len(blocking_verdicts),
163
+ )
164
+
165
+ return GateVerdict(
166
+ passed=passed,
167
+ confidence_interval=confidence_interval,
168
+ regression_verdicts=verdicts,
169
+ run_id=decision_metric.run_id,
170
+ )
@@ -0,0 +1,6 @@
1
+ # src/nirizan/instrumentation/__init__.py
2
+ """Instrumentation layer for capturing OpenTelemetry-style spans and traces.
3
+
4
+ Provides atomic tracing primitives, decorators, SDK context managers,
5
+ and asynchronous trace exporters.
6
+ """
@@ -0,0 +1,47 @@
1
+ from abc import ABC, abstractmethod
2
+ import logging
3
+
4
+ from nirizan.instrumentation.spans import Trace
5
+
6
+ logger = logging.getLogger(__name__)
7
+
8
+
9
+ class BaseExporter(ABC):
10
+ """Abstract base class for all trace exporters."""
11
+
12
+ @abstractmethod
13
+ async def export(self, trace: Trace) -> None:
14
+ """Export a completed Trace to storage or a remote collector."""
15
+ pass
16
+
17
+ async def shutdown(self) -> None:
18
+ """Release underlying connections or background workers."""
19
+ pass
20
+
21
+
22
+ class InMemoryExporter(BaseExporter):
23
+ """In-memory trace collector designed for unit tests and local experiments."""
24
+
25
+ def __init__(self) -> None:
26
+ self._traces: list[Trace] = []
27
+
28
+ async def export(self, trace: Trace) -> None:
29
+ self._traces.append(trace)
30
+
31
+ def get_traces(self) -> list[Trace]:
32
+ return list(self._traces)
33
+
34
+ def clear(self) -> None:
35
+ self._traces.clear()
36
+
37
+
38
+ class ConsoleExporter(BaseExporter):
39
+ """Logs trace telemetry directly to standard logger output."""
40
+
41
+ async def export(self, trace: Trace) -> None:
42
+ logger.info(
43
+ "Trace Exported | ID: %s | App: %s | Spans: %d",
44
+ trace.trace_id,
45
+ trace.application_name,
46
+ len(trace.spans),
47
+ )
@@ -0,0 +1,117 @@
1
+ # src/nirizan/instrumentation/sdk.py
2
+ from __future__ import annotations
3
+
4
+ import functools
5
+ from typing import Any, Callable, Coroutine, Optional, ParamSpec, TypeVar
6
+ from uuid import UUID
7
+
8
+ from nirizan._logging import get_logger
9
+ from nirizan.instrumentation.exporters import BaseExporter
10
+ from nirizan.instrumentation.spans import SpanKind
11
+ from nirizan.instrumentation.tracer import Tracer
12
+
13
+ logger = get_logger(__name__)
14
+
15
+ _GLOBAL_TRACER: Optional[Tracer] = None
16
+
17
+ P = ParamSpec("P")
18
+ R = TypeVar("R")
19
+
20
+
21
+ def init_tracer(
22
+ application_name: str, exporter: Optional[BaseExporter] = None
23
+ ) -> Tracer:
24
+ """Initialize and register the global tracer instance."""
25
+ global _GLOBAL_TRACER
26
+ tracer = Tracer(application_name=application_name, exporter=exporter)
27
+ _GLOBAL_TRACER = tracer
28
+ logger.info("Initialized global tracer for application '%s'", application_name)
29
+ return tracer
30
+
31
+
32
+ def get_tracer() -> Optional[Tracer]:
33
+ """Return the currently configured global tracer instance."""
34
+ return _GLOBAL_TRACER
35
+
36
+
37
+ def start_session(session_id: Optional[UUID] = None) -> Any:
38
+ """SDK-level context manager pass-through for grouping traces into a session."""
39
+ tracer = get_tracer()
40
+ if tracer is None:
41
+ raise RuntimeError("Tracer is not initialized. Call init_tracer() first.")
42
+ logger.debug("Starting SDK session (session_id=%s)", session_id)
43
+ return tracer.session(session_id=session_id)
44
+
45
+
46
+ def _format_input_payload(args: tuple[Any, ...], kwargs: dict[str, Any]) -> str | None:
47
+ if args:
48
+ return str(args[0])
49
+ if kwargs:
50
+ return str(next(iter(kwargs.values())))
51
+ return None
52
+
53
+
54
+ def trace_span(
55
+ kind: SpanKind,
56
+ name: Optional[str] = None,
57
+ tracer: Optional[Tracer] = None,
58
+ ) -> Callable[[Callable[P, Coroutine[Any, Any, R]]], Callable[P, Coroutine[Any, Any, R]]]:
59
+ """Decorator to instrument an async function with full signature preservation."""
60
+ def decorator(
61
+ func: Callable[P, Coroutine[Any, Any, R]]
62
+ ) -> Callable[P, Coroutine[Any, Any, R]]:
63
+ span_name = name or func.__name__
64
+
65
+ @functools.wraps(func)
66
+ async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
67
+ active_tracer = tracer or get_tracer()
68
+ if active_tracer is None:
69
+ raise RuntimeError(
70
+ "Tracer is not initialized. Call init_tracer() before executing traced code."
71
+ )
72
+
73
+ input_payload = _format_input_payload(args, kwargs)
74
+
75
+ async with active_tracer.start_span(
76
+ name=span_name, kind=kind, input_payload=input_payload
77
+ ) as handle:
78
+ result = await func(*args, **kwargs)
79
+ if result is not None and handle.output_payload is None:
80
+ handle.output_payload = str(result)
81
+ return result
82
+
83
+ return wrapper
84
+
85
+ return decorator
86
+
87
+
88
+ def planning(
89
+ name: Optional[str] = None,
90
+ tracer: Optional[Tracer] = None,
91
+ ) -> Callable[[Callable[P, Coroutine[Any, Any, R]]], Callable[P, Coroutine[Any, Any, R]]]:
92
+ """Convenience decorator for PLANNING span instrumentation."""
93
+ return trace_span(kind=SpanKind.PLANNING, name=name, tracer=tracer)
94
+
95
+
96
+ def retrieval(
97
+ name: Optional[str] = None,
98
+ tracer: Optional[Tracer] = None,
99
+ ) -> Callable[[Callable[P, Coroutine[Any, Any, R]]], Callable[P, Coroutine[Any, Any, R]]]:
100
+ """Convenience decorator for RETRIEVAL span instrumentation."""
101
+ return trace_span(kind=SpanKind.RETRIEVAL, name=name, tracer=tracer)
102
+
103
+
104
+ def generation(
105
+ name: Optional[str] = None,
106
+ tracer: Optional[Tracer] = None,
107
+ ) -> Callable[[Callable[P, Coroutine[Any, Any, R]]], Callable[P, Coroutine[Any, Any, R]]]:
108
+ """Convenience decorator for GENERATION span instrumentation."""
109
+ return trace_span(kind=SpanKind.GENERATION, name=name, tracer=tracer)
110
+
111
+
112
+ def tool_use(
113
+ name: Optional[str] = None,
114
+ tracer: Optional[Tracer] = None,
115
+ ) -> Callable[[Callable[P, Coroutine[Any, Any, R]]], Callable[P, Coroutine[Any, Any, R]]]:
116
+ """Convenience decorator for TOOL_USE span instrumentation."""
117
+ return trace_span(kind=SpanKind.TOOL_USE, name=name, tracer=tracer)
@@ -0,0 +1,18 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime
4
+ from uuid import UUID
5
+
6
+ from pydantic import BaseModel, ConfigDict, Field
7
+
8
+
9
+ class Session(BaseModel):
10
+ """Groups multiple Trace ids belonging to one multi-turn conversation; not frozen, open until ended."""
11
+
12
+ model_config = ConfigDict(strict=True)
13
+
14
+ session_id: UUID
15
+ application_name: str = Field(min_length=1)
16
+ trace_ids: list[UUID] = Field(default_factory=list)
17
+ started_at: datetime
18
+ ended_at: datetime | None = None
@@ -0,0 +1,62 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime
4
+ from enum import Enum
5
+ from uuid import UUID
6
+
7
+ from pydantic import BaseModel, ConfigDict, Field, model_validator
8
+
9
+
10
+ class SpanKind(str, Enum):
11
+ """The functional role of an execution span."""
12
+
13
+ PLANNING = "planning"
14
+ RETRIEVAL = "retrieval"
15
+ TOOL_USE = "tool_use"
16
+ GENERATION = "generation"
17
+
18
+
19
+ class Span(BaseModel):
20
+ """The atomic unit of instrumentation: one step in an AI execution graph."""
21
+
22
+ model_config = ConfigDict(frozen=True, strict=True)
23
+
24
+ span_id: UUID
25
+ trace_id: UUID
26
+ parent_span_id: UUID | None = None
27
+ kind: SpanKind
28
+ name: str = Field(min_length=1, max_length=200)
29
+ started_at: datetime
30
+ ended_at: datetime
31
+ attributes: dict[str, str | int | float | bool] = Field(default_factory=dict)
32
+ input_payload: str | None = None
33
+ output_payload: str | None = None
34
+
35
+
36
+ class Trace(BaseModel):
37
+ """An ordered collection of spans belonging to a single invocation."""
38
+
39
+ model_config = ConfigDict(strict=True)
40
+
41
+ trace_id: UUID
42
+ application_name: str = Field(min_length=1)
43
+ spans: list[Span] = Field(default_factory=list)
44
+ created_at: datetime
45
+ code_commit: str | None = None # Phase 3: stamped by collector.py at ingest
46
+ data_snapshot_id: str | None = None # Phase 3: stamped by collector.py at ingest
47
+ session_id: UUID | None = None # Phase 3: set when captured inside Tracer.session(...)
48
+
49
+ @model_validator(mode="after")
50
+ def validate_span_trace_ids(self) -> Trace:
51
+ """Ensure all spans in the trace share the trace's trace_id."""
52
+ for span in self.spans:
53
+ if span.trace_id != self.trace_id:
54
+ raise ValueError(
55
+ f"Span {span.span_id} trace_id ({span.trace_id}) "
56
+ f"does not match Trace trace_id ({self.trace_id})"
57
+ )
58
+ return self
59
+
60
+ def spans_of_kind(self, kind: SpanKind) -> list[Span]:
61
+ """Return all spans matching a specific SpanKind."""
62
+ return [s for s in self.spans if s.kind == kind]