activegraph 1.0.0rc2__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.
- activegraph/__init__.py +222 -0
- activegraph/__main__.py +5 -0
- activegraph/behaviors/__init__.py +1 -0
- activegraph/behaviors/base.py +153 -0
- activegraph/behaviors/decorators.py +218 -0
- activegraph/cli/__init__.py +17 -0
- activegraph/cli/main.py +793 -0
- activegraph/cli/quickstart.py +556 -0
- activegraph/core/__init__.py +1 -0
- activegraph/core/clock.py +46 -0
- activegraph/core/event.py +33 -0
- activegraph/core/graph.py +846 -0
- activegraph/core/ids.py +135 -0
- activegraph/core/patch.py +47 -0
- activegraph/core/view.py +59 -0
- activegraph/errors.py +342 -0
- activegraph/frame.py +15 -0
- activegraph/llm/__init__.py +51 -0
- activegraph/llm/anthropic.py +339 -0
- activegraph/llm/cache.py +180 -0
- activegraph/llm/errors.py +257 -0
- activegraph/llm/prompt.py +420 -0
- activegraph/llm/provider.py +66 -0
- activegraph/llm/recorded.py +270 -0
- activegraph/llm/types.py +130 -0
- activegraph/observability/__init__.py +61 -0
- activegraph/observability/logging.py +205 -0
- activegraph/observability/metrics.py +219 -0
- activegraph/observability/migration.py +404 -0
- activegraph/observability/prometheus.py +101 -0
- activegraph/observability/status.py +83 -0
- activegraph/packs/__init__.py +999 -0
- activegraph/packs/diligence/__init__.py +86 -0
- activegraph/packs/diligence/behaviors.py +447 -0
- activegraph/packs/diligence/fixtures/__init__.py +364 -0
- activegraph/packs/diligence/fixtures/companies.py +511 -0
- activegraph/packs/diligence/object_types.py +163 -0
- activegraph/packs/diligence/settings.py +60 -0
- activegraph/packs/diligence/tools.py +124 -0
- activegraph/packs/loader.py +709 -0
- activegraph/packs/scaffold.py +317 -0
- activegraph/policy.py +20 -0
- activegraph/runtime/__init__.py +1 -0
- activegraph/runtime/behavior_graph.py +151 -0
- activegraph/runtime/budget.py +120 -0
- activegraph/runtime/config_errors.py +124 -0
- activegraph/runtime/diff.py +145 -0
- activegraph/runtime/errors.py +216 -0
- activegraph/runtime/exec_errors.py +232 -0
- activegraph/runtime/patterns.py +946 -0
- activegraph/runtime/queue.py +27 -0
- activegraph/runtime/registration_errors.py +291 -0
- activegraph/runtime/registry.py +111 -0
- activegraph/runtime/runtime.py +2441 -0
- activegraph/runtime/scheduler.py +206 -0
- activegraph/runtime/view_builder.py +65 -0
- activegraph/store/__init__.py +41 -0
- activegraph/store/base.py +66 -0
- activegraph/store/conformance.py +161 -0
- activegraph/store/errors.py +84 -0
- activegraph/store/memory.py +118 -0
- activegraph/store/postgres.py +609 -0
- activegraph/store/serde.py +202 -0
- activegraph/store/sqlite.py +446 -0
- activegraph/store/url.py +230 -0
- activegraph/tools/__init__.py +64 -0
- activegraph/tools/base.py +57 -0
- activegraph/tools/cache.py +157 -0
- activegraph/tools/context.py +48 -0
- activegraph/tools/decorators.py +70 -0
- activegraph/tools/errors.py +320 -0
- activegraph/tools/graph_query.py +94 -0
- activegraph/tools/recorded.py +205 -0
- activegraph/tools/web_fetch.py +80 -0
- activegraph/trace/__init__.py +1 -0
- activegraph/trace/causal.py +123 -0
- activegraph/trace/printer.py +495 -0
- activegraph-1.0.0rc2.dist-info/METADATA +228 -0
- activegraph-1.0.0rc2.dist-info/RECORD +82 -0
- activegraph-1.0.0rc2.dist-info/WHEEL +5 -0
- activegraph-1.0.0rc2.dist-info/entry_points.txt +5 -0
- activegraph-1.0.0rc2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Structured logging. CONTRACT v0.8 #6–#7, #16.
|
|
2
|
+
|
|
3
|
+
The framework emits structured logs through stdlib ``logging``. We do
|
|
4
|
+
NOT auto-configure on import — a library that does is hostile. By
|
|
5
|
+
default the framework attaches to ``logging.getLogger("activegraph")``
|
|
6
|
+
and lets the operator's config handle output.
|
|
7
|
+
|
|
8
|
+
If the operator wants the opinionated setup, ``configure_logging`` adds
|
|
9
|
+
a JSON-line handler with the documented schema.
|
|
10
|
+
|
|
11
|
+
The schema is the operator contract. Dashboards built against these
|
|
12
|
+
field names keep working across framework versions.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import logging
|
|
19
|
+
import sys
|
|
20
|
+
import time
|
|
21
|
+
from typing import Any, Callable, Optional
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
LOGGER_ROOT = "activegraph"
|
|
25
|
+
|
|
26
|
+
# The documented operator-facing log schema. Fields appear when
|
|
27
|
+
# applicable; fields that don't apply are omitted (not nulled).
|
|
28
|
+
LOG_FIELDS: tuple[str, ...] = (
|
|
29
|
+
"timestamp",
|
|
30
|
+
"level",
|
|
31
|
+
"logger",
|
|
32
|
+
"message",
|
|
33
|
+
"run_id",
|
|
34
|
+
"event_id",
|
|
35
|
+
"behavior",
|
|
36
|
+
"tool",
|
|
37
|
+
"model",
|
|
38
|
+
"cache_hit",
|
|
39
|
+
"cost_usd",
|
|
40
|
+
"latency_seconds",
|
|
41
|
+
"reason",
|
|
42
|
+
"error_type",
|
|
43
|
+
"error_message",
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
# Reserved attributes on every LogRecord (stdlib internals). Any
|
|
47
|
+
# `extra=` field that collides is renamed in extras-pickup logic below.
|
|
48
|
+
_RESERVED_RECORD_ATTRS = frozenset(
|
|
49
|
+
{
|
|
50
|
+
"args", "asctime", "created", "exc_info", "exc_text", "filename",
|
|
51
|
+
"funcName", "levelname", "levelno", "lineno", "message", "module",
|
|
52
|
+
"msecs", "msg", "name", "pathname", "process", "processName",
|
|
53
|
+
"relativeCreated", "stack_info", "thread", "threadName",
|
|
54
|
+
"taskName", # 3.12+
|
|
55
|
+
}
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
_payload_redactor_state: dict[str, Optional[Callable[[dict], dict]]] = {
|
|
60
|
+
"fn": None
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def set_payload_redactor(fn: Optional[Callable[[dict], dict]]) -> None:
|
|
65
|
+
"""Install a redactor that runs on any payload before it enters a log
|
|
66
|
+
record's ``extra`` dict. Idempotent. Pass None to remove.
|
|
67
|
+
"""
|
|
68
|
+
_payload_redactor_state["fn"] = fn
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def redact_payload(payload: dict) -> dict:
|
|
72
|
+
"""Apply the configured redactor (or identity)."""
|
|
73
|
+
fn = _payload_redactor_state["fn"]
|
|
74
|
+
return fn(payload) if fn is not None else payload
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class JsonLineFormatter(logging.Formatter):
|
|
78
|
+
"""One JSON object per log record, on one line.
|
|
79
|
+
|
|
80
|
+
Populates the documented LOG_FIELDS from the record's reserved
|
|
81
|
+
attributes plus any ``extra=`` dict the caller passed. Omits fields
|
|
82
|
+
that aren't present.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
def __init__(self) -> None:
|
|
86
|
+
super().__init__()
|
|
87
|
+
|
|
88
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
89
|
+
out: dict[str, Any] = {
|
|
90
|
+
"timestamp": _iso_utc(record.created),
|
|
91
|
+
"level": record.levelname,
|
|
92
|
+
"logger": record.name,
|
|
93
|
+
"message": record.getMessage(),
|
|
94
|
+
}
|
|
95
|
+
# Pluck extras: any record attribute not in the reserved set is
|
|
96
|
+
# treated as a documented field. We only emit fields documented
|
|
97
|
+
# in LOG_FIELDS so the schema is stable.
|
|
98
|
+
for k in LOG_FIELDS:
|
|
99
|
+
if k in out:
|
|
100
|
+
continue
|
|
101
|
+
v = getattr(record, k, _MISSING)
|
|
102
|
+
if v is _MISSING or v is None:
|
|
103
|
+
continue
|
|
104
|
+
out[k] = v
|
|
105
|
+
# Allow exc_info to flow into error_type / error_message even if
|
|
106
|
+
# the caller didn't pass them explicitly.
|
|
107
|
+
if record.exc_info and "error_type" not in out:
|
|
108
|
+
etype, evalue, _ = record.exc_info
|
|
109
|
+
if etype is not None:
|
|
110
|
+
out["error_type"] = etype.__name__
|
|
111
|
+
if evalue is not None and "error_message" not in out:
|
|
112
|
+
out["error_message"] = str(evalue)
|
|
113
|
+
return json.dumps(out, separators=(",", ":"), ensure_ascii=False)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class _MISSING_T: # sentinel
|
|
117
|
+
pass
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
_MISSING = _MISSING_T()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _iso_utc(ts: float) -> str:
|
|
124
|
+
# Avoid datetime import overhead per record.
|
|
125
|
+
lt = time.gmtime(ts)
|
|
126
|
+
ms = int((ts - int(ts)) * 1000)
|
|
127
|
+
return (
|
|
128
|
+
f"{lt.tm_year:04d}-{lt.tm_mon:02d}-{lt.tm_mday:02d}T"
|
|
129
|
+
f"{lt.tm_hour:02d}:{lt.tm_min:02d}:{lt.tm_sec:02d}.{ms:03d}Z"
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def get_logger(name: str = LOGGER_ROOT) -> logging.Logger:
|
|
134
|
+
"""Get a logger in the activegraph namespace.
|
|
135
|
+
|
|
136
|
+
``get_logger("runtime")`` → ``logging.getLogger("activegraph.runtime")``.
|
|
137
|
+
``get_logger("activegraph")`` → root activegraph logger.
|
|
138
|
+
"""
|
|
139
|
+
if name == LOGGER_ROOT or name.startswith(LOGGER_ROOT + "."):
|
|
140
|
+
return logging.getLogger(name)
|
|
141
|
+
return logging.getLogger(f"{LOGGER_ROOT}.{name}")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def runtime_log_extra(**fields: Any) -> dict[str, Any]:
|
|
145
|
+
"""Build an ``extra=`` dict for a log call, dropping None values and
|
|
146
|
+
rejecting reserved LogRecord attribute names.
|
|
147
|
+
|
|
148
|
+
Use:
|
|
149
|
+
log.info("event emitted", extra=runtime_log_extra(
|
|
150
|
+
run_id=rt.run_id, event_id=e.id, behavior=b.name,
|
|
151
|
+
))
|
|
152
|
+
"""
|
|
153
|
+
out: dict[str, Any] = {}
|
|
154
|
+
for k, v in fields.items():
|
|
155
|
+
if v is None:
|
|
156
|
+
continue
|
|
157
|
+
if k in _RESERVED_RECORD_ATTRS:
|
|
158
|
+
# Don't smash stdlib internals; prefix the key.
|
|
159
|
+
out[f"ag_{k}"] = v
|
|
160
|
+
continue
|
|
161
|
+
out[k] = v
|
|
162
|
+
return out
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def configure_logging(
|
|
166
|
+
level: str | int = "INFO",
|
|
167
|
+
*,
|
|
168
|
+
json_output: bool = True,
|
|
169
|
+
stream: Any = None,
|
|
170
|
+
payload_redactor: Optional[Callable[[dict], dict]] = None,
|
|
171
|
+
) -> logging.Logger:
|
|
172
|
+
"""Configure the activegraph logger hierarchy.
|
|
173
|
+
|
|
174
|
+
Idempotent: repeated calls replace the existing handler rather than
|
|
175
|
+
stacking. Returns the activegraph root logger.
|
|
176
|
+
|
|
177
|
+
Args:
|
|
178
|
+
level: numeric or string level name.
|
|
179
|
+
json_output: True for the documented JSON-line format; False for
|
|
180
|
+
the stdlib default (one human-readable line).
|
|
181
|
+
stream: where to write. Defaults to stderr (the logging default).
|
|
182
|
+
payload_redactor: optional callable(dict) -> dict applied to any
|
|
183
|
+
payload before it's added to a log record's extra fields.
|
|
184
|
+
"""
|
|
185
|
+
set_payload_redactor(payload_redactor)
|
|
186
|
+
logger = logging.getLogger(LOGGER_ROOT)
|
|
187
|
+
logger.setLevel(level)
|
|
188
|
+
# Replace existing activegraph handlers so calls are idempotent.
|
|
189
|
+
for h in list(logger.handlers):
|
|
190
|
+
if getattr(h, "_activegraph", False):
|
|
191
|
+
logger.removeHandler(h)
|
|
192
|
+
handler = logging.StreamHandler(stream or sys.stderr)
|
|
193
|
+
handler._activegraph = True # type: ignore[attr-defined]
|
|
194
|
+
handler.setFormatter(
|
|
195
|
+
JsonLineFormatter()
|
|
196
|
+
if json_output
|
|
197
|
+
else logging.Formatter(
|
|
198
|
+
"%(asctime)s %(levelname)s %(name)s %(message)s"
|
|
199
|
+
)
|
|
200
|
+
)
|
|
201
|
+
logger.addHandler(handler)
|
|
202
|
+
# Don't propagate up to root if we own a handler — operators with
|
|
203
|
+
# their own root handler would double-print.
|
|
204
|
+
logger.propagate = False
|
|
205
|
+
return logger
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""Metrics protocol + NoOp default. CONTRACT v0.8 #8–#10.
|
|
2
|
+
|
|
3
|
+
Three methods. No timers (use a histogram with a latency value), no
|
|
4
|
+
summaries (Prometheus-specific), no custom types. Adding a metric is
|
|
5
|
+
a public API change — the standard metric list below is the operator
|
|
6
|
+
contract.
|
|
7
|
+
|
|
8
|
+
Cardinality rule (locked, CONTRACT v0.8 #C4):
|
|
9
|
+
|
|
10
|
+
run_id MAY appear as a tag on gauges of active state (cardinality
|
|
11
|
+
is bounded by the number of concurrently active runs).
|
|
12
|
+
run_id MUST NOT appear as a tag on counters or histograms.
|
|
13
|
+
|
|
14
|
+
The ``METRIC_NAMES`` table enforces this at import time — any standard
|
|
15
|
+
metric whose tag set violates the rule fails the test.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from typing import Protocol, runtime_checkable
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# ---- the protocol --------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@runtime_checkable
|
|
28
|
+
class Metrics(Protocol):
|
|
29
|
+
"""Three methods, all best-effort, all non-throwing.
|
|
30
|
+
|
|
31
|
+
Implementations MUST tolerate unknown metric names. Unknown tag keys
|
|
32
|
+
are also accepted; cardinality discipline is the caller's job.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def counter(self, name: str, tags: dict[str, str], value: float = 1.0) -> None: ...
|
|
36
|
+
def histogram(self, name: str, tags: dict[str, str], value: float) -> None: ...
|
|
37
|
+
def gauge(self, name: str, tags: dict[str, str], value: float) -> None: ...
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# ---- the no-op default ---------------------------------------------------
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class NoOpMetrics:
|
|
44
|
+
"""Default Metrics implementation. Does nothing.
|
|
45
|
+
|
|
46
|
+
Three method bodies, each a single ``return``. The runtime is fully
|
|
47
|
+
functional with NoOpMetrics. Profile-checked for zero allocation
|
|
48
|
+
pressure under steady load.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
__slots__ = ()
|
|
52
|
+
|
|
53
|
+
def counter(self, name: str, tags: dict[str, str], value: float = 1.0) -> None:
|
|
54
|
+
return
|
|
55
|
+
|
|
56
|
+
def histogram(self, name: str, tags: dict[str, str], value: float) -> None:
|
|
57
|
+
return
|
|
58
|
+
|
|
59
|
+
def gauge(self, name: str, tags: dict[str, str], value: float) -> None:
|
|
60
|
+
return
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# ---- the documented metric table ----------------------------------------
|
|
64
|
+
|
|
65
|
+
# Type tags: c=counter, h=histogram, g=gauge.
|
|
66
|
+
# Source of truth for the standard metric list. Conformance tests pin
|
|
67
|
+
# this table; adding/removing a row is a public API change.
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True)
|
|
71
|
+
class MetricSpec:
|
|
72
|
+
name: str
|
|
73
|
+
kind: str # "counter" | "histogram" | "gauge"
|
|
74
|
+
tags: tuple[str, ...]
|
|
75
|
+
description: str
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
METRIC_NAMES: tuple[MetricSpec, ...] = (
|
|
79
|
+
MetricSpec(
|
|
80
|
+
"activegraph_events_emitted_total",
|
|
81
|
+
"counter",
|
|
82
|
+
("event_type",),
|
|
83
|
+
"Every event that lands in the graph's event log.",
|
|
84
|
+
),
|
|
85
|
+
MetricSpec(
|
|
86
|
+
"activegraph_behaviors_invoked_total",
|
|
87
|
+
"counter",
|
|
88
|
+
("behavior",),
|
|
89
|
+
"Each behavior invocation. Increments before the handler runs.",
|
|
90
|
+
),
|
|
91
|
+
MetricSpec(
|
|
92
|
+
"activegraph_behaviors_failed_total",
|
|
93
|
+
"counter",
|
|
94
|
+
("behavior", "reason"),
|
|
95
|
+
"Behavior invocations that produced a behavior.failed event.",
|
|
96
|
+
),
|
|
97
|
+
MetricSpec(
|
|
98
|
+
"activegraph_behaviors_duration_seconds",
|
|
99
|
+
"histogram",
|
|
100
|
+
("behavior",),
|
|
101
|
+
"Wall-clock duration of a behavior invocation (handler only).",
|
|
102
|
+
),
|
|
103
|
+
MetricSpec(
|
|
104
|
+
"activegraph_llm_calls_total",
|
|
105
|
+
"counter",
|
|
106
|
+
("model",),
|
|
107
|
+
"Every llm.requested event (cached and non-cached).",
|
|
108
|
+
),
|
|
109
|
+
MetricSpec(
|
|
110
|
+
"activegraph_llm_cache_hits_total",
|
|
111
|
+
"counter",
|
|
112
|
+
("model",),
|
|
113
|
+
"LLM calls served from the recorded-response cache.",
|
|
114
|
+
),
|
|
115
|
+
MetricSpec(
|
|
116
|
+
"activegraph_llm_failed_total",
|
|
117
|
+
"counter",
|
|
118
|
+
("model", "reason"),
|
|
119
|
+
"LLM calls that failed before producing a usable response.",
|
|
120
|
+
),
|
|
121
|
+
MetricSpec(
|
|
122
|
+
"activegraph_llm_tokens_in",
|
|
123
|
+
"histogram",
|
|
124
|
+
("model",),
|
|
125
|
+
"Input tokens reported by the provider per llm.responded.",
|
|
126
|
+
),
|
|
127
|
+
MetricSpec(
|
|
128
|
+
"activegraph_llm_tokens_out",
|
|
129
|
+
"histogram",
|
|
130
|
+
("model",),
|
|
131
|
+
"Output tokens reported by the provider per llm.responded.",
|
|
132
|
+
),
|
|
133
|
+
MetricSpec(
|
|
134
|
+
"activegraph_llm_cost_usd",
|
|
135
|
+
"histogram",
|
|
136
|
+
("model",),
|
|
137
|
+
"Per-call cost in USD as reported by the provider.",
|
|
138
|
+
),
|
|
139
|
+
MetricSpec(
|
|
140
|
+
"activegraph_tools_calls_total",
|
|
141
|
+
"counter",
|
|
142
|
+
("tool",),
|
|
143
|
+
"Every tool.requested event (cached and non-cached).",
|
|
144
|
+
),
|
|
145
|
+
MetricSpec(
|
|
146
|
+
"activegraph_tools_cache_hits_total",
|
|
147
|
+
"counter",
|
|
148
|
+
("tool",),
|
|
149
|
+
"Tool calls served from the recorded-response cache.",
|
|
150
|
+
),
|
|
151
|
+
MetricSpec(
|
|
152
|
+
"activegraph_tools_failed_total",
|
|
153
|
+
"counter",
|
|
154
|
+
("tool", "reason"),
|
|
155
|
+
"Tool calls that produced a tool.failed event.",
|
|
156
|
+
),
|
|
157
|
+
MetricSpec(
|
|
158
|
+
"activegraph_tools_duration_seconds",
|
|
159
|
+
"histogram",
|
|
160
|
+
("tool",),
|
|
161
|
+
"Wall-clock duration of a tool invocation.",
|
|
162
|
+
),
|
|
163
|
+
MetricSpec(
|
|
164
|
+
"activegraph_queue_depth",
|
|
165
|
+
"gauge",
|
|
166
|
+
(),
|
|
167
|
+
"Current depth of the runtime's event queue.",
|
|
168
|
+
),
|
|
169
|
+
MetricSpec(
|
|
170
|
+
"activegraph_budget_cost_remaining_usd",
|
|
171
|
+
"gauge",
|
|
172
|
+
("run_id",),
|
|
173
|
+
"Remaining cost budget for an active run, USD.",
|
|
174
|
+
),
|
|
175
|
+
MetricSpec(
|
|
176
|
+
"activegraph_budget_events_remaining",
|
|
177
|
+
"gauge",
|
|
178
|
+
("run_id",),
|
|
179
|
+
"Remaining event budget for an active run.",
|
|
180
|
+
),
|
|
181
|
+
MetricSpec(
|
|
182
|
+
"activegraph_patterns_evaluated_total",
|
|
183
|
+
"counter",
|
|
184
|
+
(),
|
|
185
|
+
"Pattern evaluations across all behaviors.",
|
|
186
|
+
),
|
|
187
|
+
MetricSpec(
|
|
188
|
+
"activegraph_patterns_evaluation_duration_seconds",
|
|
189
|
+
"histogram",
|
|
190
|
+
(),
|
|
191
|
+
"Per-evaluation duration of a pattern subscription.",
|
|
192
|
+
),
|
|
193
|
+
MetricSpec(
|
|
194
|
+
"activegraph_replay_divergence_detected_total",
|
|
195
|
+
"counter",
|
|
196
|
+
("reason",),
|
|
197
|
+
"Replay-strict re-runs that diverged from the recorded log.",
|
|
198
|
+
),
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
METRIC_BY_NAME: dict[str, MetricSpec] = {m.name: m for m in METRIC_NAMES}
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def validate_cardinality_rule(metrics: tuple[MetricSpec, ...] = METRIC_NAMES) -> None:
|
|
205
|
+
"""Enforce CONTRACT v0.8 #C4: run_id only appears on gauges.
|
|
206
|
+
|
|
207
|
+
Called from the conformance test. Raises if any counter or histogram
|
|
208
|
+
declares run_id as a tag.
|
|
209
|
+
"""
|
|
210
|
+
for spec in metrics:
|
|
211
|
+
if "run_id" in spec.tags and spec.kind != "gauge":
|
|
212
|
+
raise AssertionError(
|
|
213
|
+
f"metric {spec.name!r} ({spec.kind}) lists run_id as a tag — "
|
|
214
|
+
f"forbidden by the cardinality rule (run_id is gauge-only)."
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
# Validate at import time so any in-tree edit fails loud.
|
|
219
|
+
validate_cardinality_rule()
|