mas-lab-library-lab 0.2.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.
- mas/library/lab/__init__.py +23 -0
- mas/library/lab/artifact_types.py +73 -0
- mas/library/lab/codecs/__init__.py +0 -0
- mas/library/lab/codecs/otel_codecs.py +456 -0
- mas/library/lab/eval/__init__.py +7 -0
- mas/library/lab/eval/mcev1.py +35 -0
- mas/library/lab/library.yaml +284 -0
- mas/library/lab/plots.py +408 -0
- mas/library/lab/plugins/design_patterns/__init__.py +16 -0
- mas/library/lab/plugins/design_patterns/staged_debate.py +63 -0
- mas/library/lab/plugins/design_patterns/supervised.py +61 -0
- mas/library/lab/plugins/design_patterns/verifier.py +94 -0
- mas/library/lab/plugins/design_patterns/voting.py +29 -0
- mas/library/lab/steps/README.md +36 -0
- mas/library/lab/steps/__init__.py +99 -0
- mas/library/lab/steps/_io.py +100 -0
- mas/library/lab/steps/data/analysis.py +130 -0
- mas/library/lab/steps/data/collect_dataframe.py +203 -0
- mas/library/lab/steps/data/dataset.py +67 -0
- mas/library/lab/steps/data/deserialize.py +138 -0
- mas/library/lab/steps/data/diff_trajectories.py +119 -0
- mas/library/lab/steps/data/embed_trajectories.py +221 -0
- mas/library/lab/steps/data/experiment.py +114 -0
- mas/library/lab/steps/data/gather_level.py +120 -0
- mas/library/lab/steps/data/generate_dataset.py +297 -0
- mas/library/lab/steps/data/join_dataframe.py +145 -0
- mas/library/lab/steps/data/processor.py +195 -0
- mas/library/lab/steps/data/serialize.py +326 -0
- mas/library/lab/steps/data/to_dataframe.py +196 -0
- mas/library/lab/steps/eval/annotate_metrics.py +208 -0
- mas/library/lab/steps/eval/collect_metrics.py +467 -0
- mas/library/lab/steps/eval/compute_ci.py +487 -0
- mas/library/lab/steps/eval/compute_drift.py +168 -0
- mas/library/lab/steps/eval/mce.py +502 -0
- mas/library/lab/steps/eval/metrics_files.py +86 -0
- mas/library/lab/steps/eval/metrics_to_dataframe.py +109 -0
- mas/library/lab/steps/eval/validate_outputs.py +53 -0
- mas/library/lab/steps/extract/mealy_stats.py +387 -0
- mas/library/lab/steps/extract/sys_stats.py +234 -0
- mas/library/lab/steps/extract/trace_stats.py +445 -0
- mas/library/lab/steps/extract/trajectories.py +309 -0
- mas/library/lab/steps/services/events_to_otel.py +261 -0
- mas/library/lab/steps/services/export_otel.py +196 -0
- mas/library/lab/steps/services/file_tail.py +253 -0
- mas/library/lab/steps/services/metric_event.py +67 -0
- mas/library/lab/steps/services/plot_stream.py +131 -0
- mas/library/lab/steps/services/service_start.py +168 -0
- mas/library/lab/steps/services/service_stop.py +77 -0
- mas/library/lab/steps/services/shell.py +56 -0
- mas/library/lab/steps/viz/ci_plot.py +381 -0
- mas/library/lab/steps/viz/metrics_comparison_plot.py +161 -0
- mas/library/lab/steps/viz/pipeline_diagram.py +564 -0
- mas/library/lab/steps/viz/plot.py +114 -0
- mas/library/lab/steps/viz/plot_communication_flow.py +86 -0
- mas/library/lab/steps/viz/plot_ggplot.py +189 -0
- mas/library/lab/steps/viz/plot_message_graph.py +98 -0
- mas/library/lab/steps/viz/plot_multilevel_trajectory.py +211 -0
- mas/library/lab/steps/viz/plot_trajectory.py +94 -0
- mas/library/lab/steps/viz/plotnine.py +527 -0
- mas/library/lab/ui/__init__.py +3 -0
- mas/library/lab/ui/assets/series.js +404 -0
- mas/library/lab/ui/series.py +134 -0
- mas_lab_library_lab-0.2.0.dist-info/METADATA +27 -0
- mas_lab_library_lab-0.2.0.dist-info/RECORD +66 -0
- mas_lab_library_lab-0.2.0.dist-info/WHEEL +4 -0
- mas_lab_library_lab-0.2.0.dist-info/entry_points.txt +8 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Copyright (c) 2026 Cisco Systems, Inc. and its affiliates
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""mas.library.lab package."""
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from mas._dist import installed_version
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def package_root() -> Path:
|
|
11
|
+
"""Return the directory containing ``library.yaml`` for this manifest library."""
|
|
12
|
+
here = Path(__file__).resolve().parent
|
|
13
|
+
for parent in [here, *here.parents]:
|
|
14
|
+
if (parent / "library.yaml").is_file():
|
|
15
|
+
return parent
|
|
16
|
+
return here
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
__version__ = installed_version(file=__file__)
|
|
20
|
+
|
|
21
|
+
from mas.library.lab.plots import execution_chain_graph # noqa: E402
|
|
22
|
+
|
|
23
|
+
__all__ = ["execution_chain_graph"]
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# Copyright (c) 2026 Cisco Systems, Inc. and its affiliates
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Artifact kinds declared by the lab library.
|
|
4
|
+
|
|
5
|
+
Each class is a library.yaml ``type: artifact`` plugin
|
|
6
|
+
(``type`` / ``name`` / ``module`` / ``class``).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from mas.lab.lab.config.artifact_types import ArtifactType
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Trace(ArtifactType):
|
|
15
|
+
path = "{run_dir}/traces/events.jsonl"
|
|
16
|
+
format = "jsonl"
|
|
17
|
+
stream = "native"
|
|
18
|
+
realtime = True
|
|
19
|
+
description = "Raw observability event stream from a single run."
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class RunInfo(ArtifactType):
|
|
23
|
+
path = "{run_dir}/run_info.json"
|
|
24
|
+
format = "json"
|
|
25
|
+
description = "Run metadata (hash, model, timing, status)."
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Plot(ArtifactType):
|
|
29
|
+
path = "{level_dir}/plot.png"
|
|
30
|
+
format = "png"
|
|
31
|
+
description = "Visualization output (PNG, SVG, HTML, or PDF)."
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Metrics(ArtifactType):
|
|
35
|
+
path = "{level_dir}/metrics.json"
|
|
36
|
+
format = "json"
|
|
37
|
+
schema = "artefacts/metrics.schema.json"
|
|
38
|
+
stream = "metric"
|
|
39
|
+
realtime = True
|
|
40
|
+
description = "Quality metrics computed by evaluation providers."
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ChatSnapshot(ArtifactType):
|
|
44
|
+
path = "{run_dir}/custom.chat.jsonl"
|
|
45
|
+
format = "jsonl"
|
|
46
|
+
stream = "custom.chat"
|
|
47
|
+
plot = "chat"
|
|
48
|
+
realtime = True
|
|
49
|
+
description = "Folded chat cards for the chat HTML plot."
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class TopologySnapshot(ArtifactType):
|
|
53
|
+
path = "{run_dir}/custom.topology.jsonl"
|
|
54
|
+
format = "jsonl"
|
|
55
|
+
stream = "custom.topology"
|
|
56
|
+
plot = "topology"
|
|
57
|
+
realtime = True
|
|
58
|
+
description = "Folded graph for the topology HTML plot."
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class LoopSeries(ArtifactType):
|
|
62
|
+
path = "{run_dir}/plots/series.svg"
|
|
63
|
+
format = "svg"
|
|
64
|
+
stream = "series"
|
|
65
|
+
plot = "loop"
|
|
66
|
+
realtime = True
|
|
67
|
+
description = "Generic timeseries (html live; svg/png end-state)."
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class DataFrame(ArtifactType):
|
|
71
|
+
path = "{level_dir}/data.csv"
|
|
72
|
+
format = "csv"
|
|
73
|
+
description = "Tidy CSV dataframe for analysis and plotting."
|
|
File without changes
|
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
# Copyright (c) 2026 Cisco Systems, Inc. and its affiliates
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
"""OTel codecs — encoder/decoder pairs for ``otel_traces`` and ``events`` artifact kinds.
|
|
5
|
+
|
|
6
|
+
Registered codecs
|
|
7
|
+
-----------------
|
|
8
|
+
* ``("otel_traces", "otlp")`` — Push spans to an OTLP HTTP endpoint (encode only).
|
|
9
|
+
* ``("otel_traces", "clickhouse")``— Query spans from ClickHouse (decode only).
|
|
10
|
+
* ``("otel_traces", "filesystem")``— Write/read spans as JSONL on disk (both).
|
|
11
|
+
* ``("events", "filesystem")``— Write/read raw events.jsonl on disk (both).
|
|
12
|
+
|
|
13
|
+
These codecs are registered declaratively via ``library-lab/library.yaml``'s
|
|
14
|
+
``types:``/``plugins:`` block through the runtime plugin
|
|
15
|
+
registry (see :mod:`mas.runtime.registry.bootstrap` and
|
|
16
|
+
``runtime/docs/plugin-registry-manifests.md``) — the same generic
|
|
17
|
+
manifest/fixpoint mechanism used for every other plugin category. There is
|
|
18
|
+
no bench-local codec registry; :func:`mas.lab.benchmark.codecs.get_codec`
|
|
19
|
+
is a thin lookup over ``mas.runtime.registry.get_registry()``. The classes
|
|
20
|
+
below are plain :class:`Codec` subclasses with no registration side effect
|
|
21
|
+
of their own — do not add a decorator-based registration call here, it
|
|
22
|
+
would just duplicate the manifest entry above.
|
|
23
|
+
|
|
24
|
+
Usage in a pipeline YAML::
|
|
25
|
+
|
|
26
|
+
# Push traces to OTLP collector
|
|
27
|
+
- type: serialize
|
|
28
|
+
name: push_otel
|
|
29
|
+
depends_on: [export_otel]
|
|
30
|
+
config:
|
|
31
|
+
artifact_kind: otel_traces
|
|
32
|
+
source_step: export_otel
|
|
33
|
+
source_key: spans
|
|
34
|
+
store:
|
|
35
|
+
type: otlp
|
|
36
|
+
uri: http://localhost:4318
|
|
37
|
+
|
|
38
|
+
# Read traces back from ClickHouse
|
|
39
|
+
- type: deserialize
|
|
40
|
+
name: load_traces
|
|
41
|
+
config:
|
|
42
|
+
artifact_kind: otel_traces
|
|
43
|
+
store:
|
|
44
|
+
type: clickhouse
|
|
45
|
+
host: localhost
|
|
46
|
+
port: 8123
|
|
47
|
+
user: admin
|
|
48
|
+
password_env: CLICKHOUSE_PASSWORD
|
|
49
|
+
database: default
|
|
50
|
+
opts:
|
|
51
|
+
session_id: "{session_id}"
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
import json
|
|
56
|
+
import logging
|
|
57
|
+
import os
|
|
58
|
+
import urllib.error
|
|
59
|
+
import urllib.parse
|
|
60
|
+
import urllib.request
|
|
61
|
+
import base64
|
|
62
|
+
from pathlib import Path
|
|
63
|
+
from typing import Any
|
|
64
|
+
|
|
65
|
+
from mas.lab.benchmark.codecs.base import Codec
|
|
66
|
+
from mas.lab.infra.datastore import DatastoreSpec
|
|
67
|
+
|
|
68
|
+
logger = logging.getLogger(__name__)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# ---------------------------------------------------------------------------
|
|
72
|
+
# ClickHouse row → otel_sdk_spans format
|
|
73
|
+
# (canonical ClickHouse row → otel_sdk_spans conversion)
|
|
74
|
+
# ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
def _ns_to_isoformat(ns: int) -> str:
|
|
77
|
+
"""Convert Unix nanoseconds to ISO-8601 string (microsecond precision)."""
|
|
78
|
+
import datetime
|
|
79
|
+
us = ns // 1000
|
|
80
|
+
dt = (
|
|
81
|
+
datetime.datetime(1970, 1, 1, tzinfo=datetime.timezone.utc)
|
|
82
|
+
+ datetime.timedelta(microseconds=us)
|
|
83
|
+
)
|
|
84
|
+
return dt.strftime("%Y-%m-%dT%H:%M:%S.%f") + "Z"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _ch_status_to_sdk(code: str) -> str:
|
|
88
|
+
"""Map ClickHouse OTel StatusCode string to SDK status_code string."""
|
|
89
|
+
code = code.upper()
|
|
90
|
+
if "OK" in code:
|
|
91
|
+
return "OK"
|
|
92
|
+
if "ERROR" in code:
|
|
93
|
+
return "ERROR"
|
|
94
|
+
return "UNSET"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def ch_row_to_sdk_span(row: dict[str, Any]) -> dict[str, Any]:
|
|
98
|
+
"""Convert one ClickHouse otel_traces row to otel_sdk_spans.jsonl format.
|
|
99
|
+
|
|
100
|
+
Public so downstream code (normalize_otel etc.) can import it from here.
|
|
101
|
+
"""
|
|
102
|
+
start_ns = int(row["StartNs"])
|
|
103
|
+
duration = int(row.get("Duration", 0))
|
|
104
|
+
end_ns = start_ns + duration
|
|
105
|
+
|
|
106
|
+
trace_id = "0x" + row["TraceId"].lower()
|
|
107
|
+
span_id = "0x" + row["SpanId"].lower()
|
|
108
|
+
parent_id = (
|
|
109
|
+
"0x" + row["ParentSpanId"].lower()
|
|
110
|
+
if row.get("ParentSpanId")
|
|
111
|
+
else None
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
attrs = dict(row.get("SpanAttributes") or {})
|
|
115
|
+
resource_attrs = dict(row.get("ResourceAttributes") or {})
|
|
116
|
+
resource_attrs.setdefault("service.name", row.get("ServiceName", ""))
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
"name": row["SpanName"],
|
|
120
|
+
"context": {
|
|
121
|
+
"trace_id": trace_id,
|
|
122
|
+
"span_id": span_id,
|
|
123
|
+
"trace_state": "[]",
|
|
124
|
+
},
|
|
125
|
+
"kind": "SpanKind.INTERNAL",
|
|
126
|
+
"parent_id": parent_id,
|
|
127
|
+
"start_time": _ns_to_isoformat(start_ns),
|
|
128
|
+
"end_time": _ns_to_isoformat(end_ns),
|
|
129
|
+
"status": {
|
|
130
|
+
"status_code": _ch_status_to_sdk(row.get("StatusCode", "")),
|
|
131
|
+
},
|
|
132
|
+
"attributes": attrs,
|
|
133
|
+
"events": [],
|
|
134
|
+
"links": [],
|
|
135
|
+
"resource": {
|
|
136
|
+
"attributes": resource_attrs,
|
|
137
|
+
"schema_url": "",
|
|
138
|
+
},
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# ---------------------------------------------------------------------------
|
|
143
|
+
# ClickHouse connection helpers
|
|
144
|
+
# ---------------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
def _resolve_ch_conn(store: DatastoreSpec) -> dict[str, Any]:
|
|
147
|
+
"""Resolve ClickHouse connection from DatastoreSpec + connections.yaml fallback.
|
|
148
|
+
|
|
149
|
+
Resolution order:
|
|
150
|
+
1. Fields set on *store* (host, port, user, database)
|
|
151
|
+
2. ``store.password_env`` → env var value
|
|
152
|
+
3. ``resolve_clickhouse_conn()`` for any missing fields (connections.yaml → env → defaults)
|
|
153
|
+
"""
|
|
154
|
+
from mas.lab.connections import resolve_clickhouse_conn
|
|
155
|
+
|
|
156
|
+
password = ""
|
|
157
|
+
if store.password_env:
|
|
158
|
+
password = os.environ.get(store.password_env, "")
|
|
159
|
+
|
|
160
|
+
conn = resolve_clickhouse_conn(
|
|
161
|
+
host=store.host or None,
|
|
162
|
+
port=store.port or None,
|
|
163
|
+
user=store.user or None,
|
|
164
|
+
database=store.database or None,
|
|
165
|
+
)
|
|
166
|
+
if password:
|
|
167
|
+
conn["password"] = password
|
|
168
|
+
# URI override: parse host/port from store.uri if set
|
|
169
|
+
if store.uri:
|
|
170
|
+
conn["uri"] = store.uri
|
|
171
|
+
return conn
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _clickhouse_url(conn: dict[str, Any]) -> str:
|
|
175
|
+
"""Build the ClickHouse HTTP base URL from a resolved connection dict."""
|
|
176
|
+
if conn.get("uri"):
|
|
177
|
+
return conn["uri"].rstrip("/")
|
|
178
|
+
return f"http://{conn['host']}:{conn['port']}"
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _clickhouse_query(
|
|
182
|
+
store: DatastoreSpec,
|
|
183
|
+
query: str,
|
|
184
|
+
timeout: int = 60,
|
|
185
|
+
) -> list[dict[str, Any]]:
|
|
186
|
+
"""Execute a ClickHouse query and return raw rows as dicts (FORMAT JSONEachRow)."""
|
|
187
|
+
conn = _resolve_ch_conn(store)
|
|
188
|
+
base_url = _clickhouse_url(conn)
|
|
189
|
+
database = conn.get("database", "default")
|
|
190
|
+
|
|
191
|
+
params = urllib.parse.urlencode({"database": database, "query": query})
|
|
192
|
+
url = f"{base_url}/?{params}"
|
|
193
|
+
req = urllib.request.Request(url)
|
|
194
|
+
user = conn.get("user", "")
|
|
195
|
+
password = conn.get("password", "")
|
|
196
|
+
if user or password:
|
|
197
|
+
creds = base64.b64encode(f"{user}:{password}".encode()).decode()
|
|
198
|
+
req.add_header("Authorization", f"Basic {creds}")
|
|
199
|
+
|
|
200
|
+
try:
|
|
201
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
202
|
+
body = resp.read().decode()
|
|
203
|
+
except urllib.error.HTTPError as exc:
|
|
204
|
+
body = exc.read().decode(errors="replace")
|
|
205
|
+
if exc.code == 403:
|
|
206
|
+
raise RuntimeError(
|
|
207
|
+
f"ClickHouse authentication failed at {base_url} (user={user!r}). "
|
|
208
|
+
f"Check store.password_env or set the env var."
|
|
209
|
+
) from exc
|
|
210
|
+
raise RuntimeError(f"ClickHouse HTTP {exc.code}: {body}") from exc
|
|
211
|
+
except urllib.error.URLError as exc:
|
|
212
|
+
raise RuntimeError(
|
|
213
|
+
f"Could not connect to ClickHouse at {base_url}: {exc}"
|
|
214
|
+
) from exc
|
|
215
|
+
|
|
216
|
+
return [json.loads(line) for line in body.splitlines() if line.strip()]
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
# ---------------------------------------------------------------------------
|
|
220
|
+
# ("otel_traces", "otlp") — encode only
|
|
221
|
+
# ---------------------------------------------------------------------------
|
|
222
|
+
|
|
223
|
+
class OtlpOtelCodec(Codec):
|
|
224
|
+
"""Push OTel spans to an OTLP HTTP endpoint (encode only).
|
|
225
|
+
|
|
226
|
+
The ``store`` spec must have ``type: otlp`` and ``uri`` (or ``host``/``port``)
|
|
227
|
+
pointing to the OTLP collector HTTP port (typically 4318).
|
|
228
|
+
|
|
229
|
+
``artifact`` must be either:
|
|
230
|
+
- a list of event dicts (``events.jsonl`` format) — converted to OTLP spans
|
|
231
|
+
- a ``Path`` to an ``events.jsonl`` file — read and converted
|
|
232
|
+
|
|
233
|
+
Extra ``opts``::
|
|
234
|
+
|
|
235
|
+
service_name str OTel service.name resource attribute (default: "mas-runtime")
|
|
236
|
+
batch_size int Spans per HTTP request (default: 200)
|
|
237
|
+
dry_run bool Convert but do not push (default: False)
|
|
238
|
+
"""
|
|
239
|
+
|
|
240
|
+
artifact_kind: str = "otel_traces"
|
|
241
|
+
store_type: str = "otlp"
|
|
242
|
+
|
|
243
|
+
def encode(self, artifact: Any, **opts: Any) -> None:
|
|
244
|
+
from mas.lab.telemetry.otlp_push import push_file, load_events
|
|
245
|
+
|
|
246
|
+
service_name: str = opts.get("service_name", "mas-runtime")
|
|
247
|
+
batch_size: int = int(opts.get("batch_size", 200))
|
|
248
|
+
dry_run: bool = bool(opts.get("dry_run", False))
|
|
249
|
+
|
|
250
|
+
# Determine OTLP endpoint
|
|
251
|
+
if self.store.uri:
|
|
252
|
+
endpoint = self.store.uri.rstrip("/")
|
|
253
|
+
else:
|
|
254
|
+
host = self.store.host or "localhost"
|
|
255
|
+
port = self.store.port or 4318
|
|
256
|
+
endpoint = f"http://{host}:{port}"
|
|
257
|
+
|
|
258
|
+
if isinstance(artifact, (str, Path)):
|
|
259
|
+
events = load_events(artifact)
|
|
260
|
+
elif isinstance(artifact, list):
|
|
261
|
+
events = artifact
|
|
262
|
+
else:
|
|
263
|
+
raise TypeError(
|
|
264
|
+
f"OtlpOtelCodec.encode: expected list[dict] or Path, got {type(artifact)!r}"
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
if dry_run:
|
|
268
|
+
logger.info("[dry-run] Would push %d events to %s", len(events), endpoint)
|
|
269
|
+
return
|
|
270
|
+
|
|
271
|
+
logger.info("OtlpOtelCodec: pushing %d events → %s", len(events), endpoint)
|
|
272
|
+
push_file(
|
|
273
|
+
events=events,
|
|
274
|
+
endpoint=endpoint,
|
|
275
|
+
service_name=service_name,
|
|
276
|
+
batch_size=batch_size,
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
def decode(self, **_: Any) -> Any:
|
|
280
|
+
raise NotImplementedError(
|
|
281
|
+
"OtlpOtelCodec supports encode only. "
|
|
282
|
+
"Use ClickHouseOtelCodec (store_type='clickhouse') to read OTel spans."
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
# ---------------------------------------------------------------------------
|
|
287
|
+
# ("otel_traces", "clickhouse") — decode only
|
|
288
|
+
# ---------------------------------------------------------------------------
|
|
289
|
+
|
|
290
|
+
class ClickHouseOtelCodec(Codec):
|
|
291
|
+
"""Query OTel spans from ClickHouse (decode only).
|
|
292
|
+
|
|
293
|
+
The ``store`` spec must have ``type: clickhouse`` with host/port/user/
|
|
294
|
+
password_env/database.
|
|
295
|
+
|
|
296
|
+
Extra ``opts`` for :meth:`decode`::
|
|
297
|
+
|
|
298
|
+
session_id str Filter by ``SpanAttributes['mas.session.id']``
|
|
299
|
+
run_id str Filter by ``SpanAttributes['mas.run.id']``
|
|
300
|
+
trace_id str Filter by ``TraceId`` column (hex, with/without 0x)
|
|
301
|
+
by str 'session' | 'run' | 'trace' — alternate selector
|
|
302
|
+
app_name str Filter by ``ServiceName``
|
|
303
|
+
table str ClickHouse table name (default: "otel_traces")
|
|
304
|
+
"""
|
|
305
|
+
|
|
306
|
+
artifact_kind: str = "otel_traces"
|
|
307
|
+
store_type: str = "clickhouse"
|
|
308
|
+
|
|
309
|
+
def encode(self, artifact: Any, **_: Any) -> None:
|
|
310
|
+
raise NotImplementedError(
|
|
311
|
+
"ClickHouseOtelCodec supports decode only. "
|
|
312
|
+
"Use OtlpOtelCodec (store_type='otlp') to push OTel spans."
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
def decode(self, **opts: Any) -> list[dict[str, Any]]:
|
|
316
|
+
session_id: str | None = opts.get("session_id")
|
|
317
|
+
run_id: str | None = opts.get("run_id")
|
|
318
|
+
trace_id: str | None = opts.get("trace_id")
|
|
319
|
+
by: str = opts.get("by", "session")
|
|
320
|
+
app_name: str | None = opts.get("app_name")
|
|
321
|
+
table: str = opts.get("table", "otel_traces")
|
|
322
|
+
|
|
323
|
+
# Normalise: --by trace or trace_id kwarg
|
|
324
|
+
if by == "trace" and not trace_id:
|
|
325
|
+
trace_id = session_id or run_id
|
|
326
|
+
session_id = run_id = None
|
|
327
|
+
|
|
328
|
+
if not session_id and not run_id and not trace_id:
|
|
329
|
+
raise ValueError(
|
|
330
|
+
"ClickHouseOtelCodec.decode: provide session_id, run_id, or trace_id in opts."
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
database = self.store.database or "default"
|
|
334
|
+
db_table = f"{database}.{table}"
|
|
335
|
+
app_filter = f" AND ServiceName = '{app_name}'" if app_name else ""
|
|
336
|
+
|
|
337
|
+
if trace_id:
|
|
338
|
+
hex_id = trace_id.lower().replace("0x", "").zfill(32)
|
|
339
|
+
safe_hex = hex_id.replace("'", "\\'")
|
|
340
|
+
where_clause = f"TraceId = '{safe_hex}'{app_filter}"
|
|
341
|
+
log_key = f"trace_id={trace_id}"
|
|
342
|
+
else:
|
|
343
|
+
effective_id = session_id or run_id
|
|
344
|
+
attr_key = "mas.session.id" if session_id else "mas.run.id"
|
|
345
|
+
safe_id = (effective_id or "").replace("'", "\\'")
|
|
346
|
+
where_clause = f"SpanAttributes['{attr_key}'] = '{safe_id}'{app_filter}"
|
|
347
|
+
log_key = f"session_id={session_id} run_id={run_id}"
|
|
348
|
+
|
|
349
|
+
query = (
|
|
350
|
+
f"SELECT "
|
|
351
|
+
f"toUnixTimestamp64Nano(Timestamp) AS StartNs, "
|
|
352
|
+
f"Duration, TraceId, SpanId, ParentSpanId, SpanName, SpanKind, "
|
|
353
|
+
f"ServiceName, ResourceAttributes, SpanAttributes, StatusCode "
|
|
354
|
+
f"FROM {db_table} "
|
|
355
|
+
f"WHERE {where_clause} "
|
|
356
|
+
f"ORDER BY StartNs "
|
|
357
|
+
f"FORMAT JSONEachRow"
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
logger.info(
|
|
361
|
+
"ClickHouseOtelCodec.decode: %s table=%s",
|
|
362
|
+
log_key, db_table,
|
|
363
|
+
)
|
|
364
|
+
raw_rows = _clickhouse_query(self.store, query)
|
|
365
|
+
return [ch_row_to_sdk_span(row) for row in raw_rows]
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
# ---------------------------------------------------------------------------
|
|
369
|
+
# ("otel_traces", "filesystem") — encode + decode
|
|
370
|
+
# ---------------------------------------------------------------------------
|
|
371
|
+
|
|
372
|
+
class FilesystemOtelCodec(Codec):
|
|
373
|
+
"""Write/read OTel spans as JSONL on the local filesystem.
|
|
374
|
+
|
|
375
|
+
The ``store`` spec must have ``type: filesystem`` and ``path`` pointing to
|
|
376
|
+
a directory. Individual files are named via ``opts.filename``.
|
|
377
|
+
|
|
378
|
+
Extra ``opts``::
|
|
379
|
+
|
|
380
|
+
filename str File name within store.path (default: "otel_spans.jsonl")
|
|
381
|
+
"""
|
|
382
|
+
|
|
383
|
+
artifact_kind: str = "otel_traces"
|
|
384
|
+
store_type: str = "filesystem"
|
|
385
|
+
|
|
386
|
+
def encode(self, artifact: Any, **opts: Any) -> None:
|
|
387
|
+
filename: str = opts.get("filename", "otel_spans.jsonl")
|
|
388
|
+
base = Path(self.store.path)
|
|
389
|
+
base.mkdir(parents=True, exist_ok=True)
|
|
390
|
+
out = base / filename
|
|
391
|
+
|
|
392
|
+
if isinstance(artifact, (str, Path)):
|
|
393
|
+
import shutil
|
|
394
|
+
shutil.copy2(artifact, out)
|
|
395
|
+
logger.info("FilesystemOtelCodec: copied %s → %s", artifact, out)
|
|
396
|
+
elif isinstance(artifact, list):
|
|
397
|
+
with open(out, "w") as fh:
|
|
398
|
+
for item in artifact:
|
|
399
|
+
fh.write(json.dumps(item) + "\n")
|
|
400
|
+
logger.info("FilesystemOtelCodec: wrote %d spans → %s", len(artifact), out)
|
|
401
|
+
else:
|
|
402
|
+
raise TypeError(
|
|
403
|
+
f"FilesystemOtelCodec.encode: expected list[dict] or Path, got {type(artifact)!r}"
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
def decode(self, **opts: Any) -> list[dict[str, Any]]:
|
|
407
|
+
filename: str = opts.get("filename", "otel_spans.jsonl")
|
|
408
|
+
path = Path(self.store.path) / filename
|
|
409
|
+
if not path.exists():
|
|
410
|
+
raise FileNotFoundError(f"FilesystemOtelCodec: file not found: {path}")
|
|
411
|
+
with open(path) as fh:
|
|
412
|
+
return [json.loads(line) for line in fh if line.strip()]
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
# ---------------------------------------------------------------------------
|
|
416
|
+
# ("events", "filesystem") — encode + decode
|
|
417
|
+
# ---------------------------------------------------------------------------
|
|
418
|
+
|
|
419
|
+
class FilesystemEventsCodec(Codec):
|
|
420
|
+
"""Write/read raw events (``events.jsonl`` format) on the local filesystem.
|
|
421
|
+
|
|
422
|
+
Extra ``opts``::
|
|
423
|
+
|
|
424
|
+
filename str File name within store.path (default: "events.jsonl")
|
|
425
|
+
"""
|
|
426
|
+
|
|
427
|
+
artifact_kind: str = "events"
|
|
428
|
+
store_type: str = "filesystem"
|
|
429
|
+
|
|
430
|
+
def encode(self, artifact: Any, **opts: Any) -> None:
|
|
431
|
+
filename: str = opts.get("filename", "events.jsonl")
|
|
432
|
+
base = Path(self.store.path)
|
|
433
|
+
base.mkdir(parents=True, exist_ok=True)
|
|
434
|
+
out = base / filename
|
|
435
|
+
|
|
436
|
+
if isinstance(artifact, (str, Path)):
|
|
437
|
+
import shutil
|
|
438
|
+
shutil.copy2(artifact, out)
|
|
439
|
+
logger.info("FilesystemEventsCodec: copied %s → %s", artifact, out)
|
|
440
|
+
elif isinstance(artifact, list):
|
|
441
|
+
with open(out, "w") as fh:
|
|
442
|
+
for item in artifact:
|
|
443
|
+
fh.write(json.dumps(item) + "\n")
|
|
444
|
+
logger.info("FilesystemEventsCodec: wrote %d events → %s", len(artifact), out)
|
|
445
|
+
else:
|
|
446
|
+
raise TypeError(
|
|
447
|
+
f"FilesystemEventsCodec.encode: expected list[dict] or Path, got {type(artifact)!r}"
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
def decode(self, **opts: Any) -> list[dict[str, Any]]:
|
|
451
|
+
filename: str = opts.get("filename", "events.jsonl")
|
|
452
|
+
path = Path(self.store.path) / filename
|
|
453
|
+
if not path.exists():
|
|
454
|
+
raise FileNotFoundError(f"FilesystemEventsCodec: file not found: {path}")
|
|
455
|
+
with open(path) as fh:
|
|
456
|
+
return [json.loads(line) for line in fh if line.strip()]
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Copyright (c) 2026 Cisco Systems, Inc. and its affiliates
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Public MCE v1 provider plugin entry point."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Dict, List, Optional
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class MCEv1Provider:
|
|
12
|
+
"""Delegates to mas.library.eval MCE v1 provider."""
|
|
13
|
+
|
|
14
|
+
name = "mcev1"
|
|
15
|
+
|
|
16
|
+
def __init__(self) -> None:
|
|
17
|
+
from mas.library.eval.evaluator import get_provider
|
|
18
|
+
|
|
19
|
+
self._delegate = get_provider("mce_v1")
|
|
20
|
+
|
|
21
|
+
def compute(
|
|
22
|
+
self,
|
|
23
|
+
kg_path: Path,
|
|
24
|
+
metric_names: List[str],
|
|
25
|
+
*,
|
|
26
|
+
response_agent_id: Optional[str] = None,
|
|
27
|
+
) -> Dict[str, dict]:
|
|
28
|
+
return self._delegate.compute(
|
|
29
|
+
kg_path=kg_path,
|
|
30
|
+
metric_names=metric_names,
|
|
31
|
+
response_agent_id=response_agent_id,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
def available_metrics(self) -> List[str]:
|
|
35
|
+
return self._delegate.available_metrics()
|