core/chat/telemetry.py

482 lines
17 KiB
Python

"""ADR-0040 — structured-logging sink for turn-event audit.
Consumes ``TurnEvent`` records that ADR-0039 makes uniform across
main and stub paths. Emits one JSON-line per turn with deterministic
field ordering, suitable for log aggregation, replay, and offline
audit pipelines.
Trust boundary (per CLAUDE.md):
* **Metadata-only by default.** Surface text and input tokens are
redacted unless the caller explicitly opts in via
``include_content=True``. Audit needs counts, ids, and flags —
not raw content — and the redact-by-default stance prevents
accidental PII leakage when sinks point at shared log stores.
* **No implicit wall-clock.** Timestamps are caller-provided so
emission is reproducible under replay. The runtime never reaches
for ``datetime.now()`` here.
* **Append-only file paths.** ``JsonlFileSink`` opens the target in
append mode and never truncates. Path is fixed at construction;
the sink does not interpret user-controlled paths at emit time.
* **Idempotent flush.** Each ``emit()`` flushes immediately so a
crashed turn loop still has its prior turns durable on disk.
See ``docs/adr/ADR-0040-telemetry-sink.md``.
"""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from pathlib import Path
from typing import IO, Protocol
from core.epistemic_state import (
coerce_epistemic_state,
coerce_normative_clearance,
)
_UNKNOWN_DOMAIN_SURFACE = "I don't know — insufficient grounding for that yet."
# ---------- pure serializer ----------
def serialize_turn_event(
event,
*,
safety_pack_id: str = "",
ethics_pack_id: str = "",
identity_pack_id: str = "",
include_content: bool = False,
timestamp: str | None = None,
) -> dict[str, object]:
"""Produce a JSON-safe audit dict from a ``TurnEvent``.
Pack ids are passed as kwargs because ``TurnEvent`` does not
carry them — the runtime knows them. Fields are typed
deliberately at the boundary so an upstream change to
``TurnEvent`` doesn't silently break the wire format; missing or
differently-typed values fall back to safe defaults.
"""
verdicts = getattr(event, "verdicts", None)
out: dict[str, object] = {
"turn": int(getattr(event, "turn", 0)),
"safety_pack_id": str(safety_pack_id),
"ethics_pack_id": str(ethics_pack_id),
"identity_pack_id": str(identity_pack_id),
"epistemic_state": coerce_epistemic_state(
getattr(event, "epistemic_state", None)
).value,
"normative_clearance": coerce_normative_clearance(
getattr(event, "normative_clearance", None)
).value,
"normative_detail": str(
getattr(event, "normative_detail", "") or ""
),
"refusal_emitted": bool(getattr(verdicts, "refusal_emitted", False)),
"hedge_injected": bool(getattr(verdicts, "hedge_injected", False)),
"versor_condition": float(getattr(event, "versor_condition", 0.0)),
"vault_hits": int(getattr(event, "vault_hits", 0)),
"cycle_cost_total": float(getattr(event, "cycle_cost_total", 0.0)),
"flagged": bool(getattr(event, "flagged", False)),
"stub_path": getattr(event, "walk_surface", "") == _UNKNOWN_DOMAIN_SURFACE,
"dialogue_role": str(getattr(event, "dialogue_role", "")),
# ADR-0072 (R5) — operator-visible register identity per turn.
# Empty strings on pre-R5 events / UNREGISTERED runtimes / empty
# marker buckets, so the wire format degrades cleanly.
"register_id": str(getattr(event, "register_id", "") or ""),
"register_variant_id": str(getattr(event, "register_variant_id", "") or ""),
# ADR-0073d (L1.4) — operator-visible anchor-lens identity per
# turn. Empty strings on pre-L1.4 events / UNANCHORED runtimes /
# turns where the lens did not engage.
"anchor_lens_id": str(getattr(event, "anchor_lens_id", "") or ""),
"anchor_lens_mode_label": str(getattr(event, "anchor_lens_mode_label", "") or ""),
# ADR-0075 (C1) — realizer slot-type guard verdict per turn.
# Empty strings on pre-C1 events; closed enums otherwise.
"realizer_guard_status": str(getattr(event, "realizer_guard_status", "") or ""),
"realizer_guard_rule": str(getattr(event, "realizer_guard_rule", "") or ""),
"composer_graph_atom_status": str(
getattr(event, "composer_graph_atom_status", "") or ""
),
"composer_atom_set_hash": str(
getattr(event, "composer_atom_set_hash", "") or ""
),
"graph_atom_set_hash": str(
getattr(event, "graph_atom_set_hash", "") or ""
),
"composer_graph_atom_overlap_count": int(
getattr(event, "composer_graph_atom_overlap_count", 0) or 0
),
}
safety = getattr(event, "safety_verdict", None)
if safety is not None:
out["safety_violated"] = sorted(
getattr(safety, "violated_boundaries", ()) or ()
)
out["safety_runtime_checkable_count"] = int(
getattr(safety, "runtime_checkable_count", 0)
)
out["safety_upheld"] = bool(getattr(safety, "upheld", True))
ethics = getattr(event, "ethics_verdict", None)
if ethics is not None:
out["ethics_violated"] = sorted(
getattr(ethics, "violated_commitments", ()) or ()
)
out["ethics_runtime_checkable_count"] = int(
getattr(ethics, "runtime_checkable_count", 0)
)
out["ethics_upheld"] = bool(getattr(ethics, "upheld", True))
identity_score = getattr(event, "identity_score", None)
if identity_score is not None:
out["identity_alignment"] = float(
getattr(identity_score, "alignment", 0.0)
)
out["identity_flagged"] = bool(getattr(identity_score, "flagged", False))
out["identity_deviation_axes"] = sorted(
getattr(identity_score, "deviation_axes", ()) or ()
)
if include_content:
out["input_tokens"] = list(getattr(event, "input_tokens", ()))
out["surface"] = str(getattr(event, "surface", ""))
out["walk_surface"] = str(getattr(event, "walk_surface", ""))
out["articulation_surface"] = str(
getattr(event, "articulation_surface", "")
)
# ADR-0077 (R6) — register canonical surface (pre-substantive,
# pre-decoration). Content-gated like the other surfaces.
out["register_canonical_surface"] = str(
getattr(event, "register_canonical_surface", "") or ""
)
if timestamp is not None:
out["timestamp"] = str(timestamp)
return out
def format_turn_event_jsonl(event, **kwargs) -> str:
"""Serialize a turn event as one deterministic JSONL line.
Field order is alphabetical (``sort_keys=True``) so two emissions
of the same logical event produce byte-identical lines. No
trailing newline — the sink owns line termination.
"""
payload = serialize_turn_event(event, **kwargs)
return json.dumps(payload, sort_keys=True, separators=(",", ":"))
# ---------- ADR-0059 — correction-event serializer ----------
def serialize_correction_event(
correction_result,
*,
target_turn: int,
identity_pack_id: str = "",
safety_pack_id: str = "",
ethics_pack_id: str = "",
timestamp: str | None = None,
) -> dict[str, object]:
"""Produce a JSON-safe audit dict from a ``CorrectionResult``.
Distinct from a turn event: this records the *backward* update to
a session graph triggered by ``ChatRuntime.correct()``. The
forward regen turn that follows still emits its own turn event;
this event documents the perturbation itself so audit consumers
can answer "which past turns moved, by how much, and toward what".
Trust boundary (per CLAUDE.md):
* **Metadata-only.** Versor coordinates are NOT emitted — only
the L2-delta-norm-per-record and a SHA-256 digest of the
correction versor's float32 bytes (deterministic identifier).
* **No implicit wall-clock.** ``timestamp`` is caller-provided.
* **Deterministic.** Same ``CorrectionResult`` → byte-identical
serialized line. ``records`` are traversed in their tuple
order (deterministic, matches insertion order from
``CorrectionPass.apply``).
"""
import hashlib
import math
records = getattr(correction_result, "records", ()) or ()
correction_versor = getattr(correction_result, "correction_versor", None)
# Per-record L2 deltas + the max across records.
deltas: list[float] = []
turn_idxs: list[int] = []
for r in records:
old_v = getattr(r, "old_versor", None)
new_v = getattr(r, "new_versor", None)
if old_v is None or new_v is None:
continue
# numpy.ndarray subtraction + norm; pure stdlib fallback would
# be slower but the runtime already imports numpy on this path.
import numpy as np
delta = float(np.linalg.norm(np.asarray(new_v) - np.asarray(old_v)))
if math.isfinite(delta):
deltas.append(delta)
turn_idxs.append(int(getattr(r, "turn_idx", 0)))
# SHA-256 digest of the correction versor's float32 bytes — gives
# a stable identifier for the perturbation without leaking
# coordinates. Falls back to empty string when missing.
digest = ""
if correction_versor is not None:
import numpy as np
digest = hashlib.sha256(
np.asarray(correction_versor, dtype=np.float32).tobytes()
).hexdigest()
out: dict[str, object] = {
"type": "correction",
"target_turn": int(target_turn),
"identity_pack_id": str(identity_pack_id),
"safety_pack_id": str(safety_pack_id),
"ethics_pack_id": str(ethics_pack_id),
"records_count": int(getattr(correction_result, "turns_affected", len(records))),
"turns_skipped": int(getattr(correction_result, "turns_skipped", 0)),
"turn_idxs_affected": sorted(turn_idxs),
"max_delta_norm": max(deltas) if deltas else 0.0,
"mean_delta_norm": (sum(deltas) / len(deltas)) if deltas else 0.0,
"correction_versor_digest": digest,
}
if timestamp is not None:
out["timestamp"] = str(timestamp)
return out
def format_correction_event_jsonl(correction_result, **kwargs) -> str:
"""Serialize a correction event as one deterministic JSONL line.
The ``"type": "correction"`` field discriminates this line from
turn events at consume time without changing the sink contract.
"""
payload = serialize_correction_event(correction_result, **kwargs)
return json.dumps(payload, sort_keys=True, separators=(",", ":"))
# ---------- ADR-0158 — reboot-event serializer ----------
def serialize_reboot_event(
*,
restored_turn_count: int,
stored_revision: str,
current_revision: str,
recognizers_count: int,
candidates_count: int,
timestamp: str | None = None,
) -> dict[str, object]:
"""Produce a JSON-safe audit dict for an engine-state reboot recovery.
Written to the telemetry JSONL when ``ChatRuntime`` loads an existing
checkpoint at startup (L10 scope §Sub-question 3). Lets future audit
reconstruct when this engine instance lost and regained its lifetime.
``revision_matched`` is True iff both revisions are known and equal,
indicating the checkpoint was written by the same code that is running.
False means the operator should verify the loaded state is still valid
(the W-023 ``RuntimeWarning`` will already have fired).
Trust boundary: no surface text or tokens; metadata only.
"""
revision_matched = bool(
stored_revision not in ("unknown", "")
and current_revision not in ("unknown", "")
and stored_revision == current_revision
)
out: dict[str, object] = {
"type": "reboot",
"restored_turn_count": int(restored_turn_count),
"stored_revision": str(stored_revision),
"current_revision": str(current_revision),
"revision_matched": revision_matched,
"recognizers_count": int(recognizers_count),
"candidates_count": int(candidates_count),
}
if timestamp is not None:
out["timestamp"] = str(timestamp)
return out
def format_reboot_event_jsonl(
*,
restored_turn_count: int,
stored_revision: str,
current_revision: str,
recognizers_count: int,
candidates_count: int,
timestamp: str | None = None,
) -> str:
"""Serialize a reboot event as one deterministic JSONL line.
``"type": "reboot"`` discriminates this line from turn and correction
events at consume time without changing the sink contract.
"""
payload = serialize_reboot_event(
restored_turn_count=restored_turn_count,
stored_revision=stored_revision,
current_revision=current_revision,
recognizers_count=recognizers_count,
candidates_count=candidates_count,
timestamp=timestamp,
)
return json.dumps(payload, sort_keys=True, separators=(",", ":"))
# ---------- sink protocol ----------
class TurnEventSink(Protocol):
"""Minimal sink contract.
Sinks receive one already-serialized JSONL line per turn. The
runtime calls ``emit()`` after each ``turn_log.append()`` — see
``ChatRuntime._emit_turn_event``.
"""
def emit(self, line: str) -> None: ...
# ---------- concrete sinks ----------
@dataclass
class JsonlBufferSink:
"""In-memory sink that captures every emitted line.
Useful for tests, replay diffing, and small-volume audit where
persistence is the caller's responsibility.
"""
lines: list[str] = field(default_factory=list)
def emit(self, line: str) -> None:
self.lines.append(line)
class JsonlFileSink:
"""Append-only JSONL file sink with eager flush.
The path is fixed at construction. Each ``emit()`` flushes
immediately so a crashed runtime still has its prior turns
durable on disk. Supports context-manager usage.
"""
def __init__(self, path: str | Path) -> None:
self._path = Path(path)
self._fh: IO[str] | None = None
def emit(self, line: str) -> None:
if self._fh is None:
self._path.parent.mkdir(parents=True, exist_ok=True)
self._fh = self._path.open("a", encoding="utf-8")
self._fh.write(line)
self._fh.write("\n")
self._fh.flush()
def close(self) -> None:
if self._fh is not None:
self._fh.close()
self._fh = None
def __enter__(self) -> "JsonlFileSink":
return self
def __exit__(self, *exc_info) -> None:
self.close()
# ---------- fan-out ----------
@dataclass
class FanOutSink:
"""Forward every emitted line to N sinks in declaration order.
ADR-0041. Composes with any combination of sinks — typically
``JsonlFileSink`` (durable) + ``JsonlBufferSink`` (in-memory
audit), or two file sinks (local + shadow).
**Error semantics:** fail-fast. If sink *i* raises, sinks *i+1..*
are NOT called and the exception propagates to the caller. This
is consistent with the single-sink contract: telemetry failures
surface, never silently drop audit signal. Callers wanting
partial-success semantics wrap individual sinks in their own
error-tolerant shim.
"""
sinks: tuple = () # tuple[TurnEventSink, ...]
def emit(self, line: str) -> None:
for sink in self.sinks:
sink.emit(line)
# ---------- operator-facing summary formatter ----------
def format_verdict_summary(verdicts) -> str:
"""ADR-0041 — one-line human-readable summary of a TurnVerdicts bundle.
Used by ``core chat --show-verdicts`` to print a per-turn audit
line to the operator. Distinct from ``format_turn_event_jsonl``
(machine-facing): this is dense, terse, and skims the high-signal
fields. Empty string when ``verdicts`` is None.
Format::
[identity=0.83 safety=ok ethics=ok refusal=- hedge=-]
[identity=0.42 safety=VIOLATED:preserve_versor_closure ethics=ok refusal=YES hedge=-]
"""
if verdicts is None:
return ""
parts: list[str] = []
identity = getattr(verdicts, "identity_score", None)
if identity is not None:
alignment = float(getattr(identity, "alignment", 0.0))
parts.append(f"identity={alignment:.2f}")
else:
parts.append("identity=-")
safety = getattr(verdicts, "safety_verdict", None)
parts.append(_format_verdict_short(
safety, "safety", id_attr="violated_boundaries",
))
ethics = getattr(verdicts, "ethics_verdict", None)
parts.append(_format_verdict_short(
ethics, "ethics", id_attr="violated_commitments",
))
parts.append(
"refusal=YES" if getattr(verdicts, "refusal_emitted", False)
else "refusal=-"
)
parts.append(
"hedge=YES" if getattr(verdicts, "hedge_injected", False)
else "hedge=-"
)
return "[" + " ".join(parts) + "]"
def _format_verdict_short(verdict, label: str, *, id_attr: str) -> str:
if verdict is None:
return f"{label}=-"
violated = sorted(getattr(verdict, id_attr, ()) or ())
if not violated:
return f"{label}=ok"
return f"{label}=VIOLATED:{','.join(violated)}"
__all__ = [
"FanOutSink",
"JsonlBufferSink",
"JsonlFileSink",
"TurnEventSink",
"format_correction_event_jsonl",
"format_reboot_event_jsonl",
"format_turn_event_jsonl",
"format_verdict_summary",
"serialize_correction_event",
"serialize_reboot_event",
"serialize_turn_event",
]