core/generate/bridge_trace.py
Shay 310aed9ff0
chore: Refactor CLI and Governance Anchors (#926)
* docs: consolidate governance anchors and clean up test registries

* refactor(cli): decompose cli into dedicated modules

* test: fix broken test baselines and formatting

* docs: add domain boundary READMEs for governance anchors

* test: update baseline for determination lane

* test: fix capability_pass expectation

* test: fix CORE_SHOWCASE_SKIP_BUDGET enforcement

* chore: cleanup CLI extraction and unreachable code
2026-07-03 12:34:56 -07:00

292 lines
10 KiB
Python

"""generate/bridge_trace.py — per-turn articulation bridge trace.
Phase 1 of the full-sentence output mastery plan. Every call to
``articulate_with_intent()`` in ``generate/intent_bridge.py`` emits
one ``BridgeTraceRecord`` through the module-level sink when a sink
has been attached. When no sink is attached the emission is a pure
no-op with zero overhead beyond the ``is None`` check.
Follows the same trust-boundary conventions as ADR-0040
(``chat/telemetry.py``):
* **Redact-by-default.** ``recalled_words_sample`` and
``bridge_surface`` are only included in the serialized record when
the caller sets ``include_content=True``.
* **No implicit wall-clock.** ``timestamp`` is caller-provided.
* **Append-only file paths.** ``JsonlFileSink`` opens in append mode.
* **Idempotent flush.** Each ``emit()`` flushes immediately.
The record covers every diagnostic dimension named in the mastery
plan's Phase 1.3 trace specification:
intent_tag — classifier output (string name of IntentTag)
intent_subject — classifier subject slot
plan_subject — ArticulationPlan.subject
plan_predicate — ArticulationPlan.predicate
plan_object — ArticulationPlan.object (None → empty string)
recalled_words_len — len(recalled_words) passed to the bridge
recalled_words_sample — first 5 recalled words (content-gated)
pre_ground_obj — graph node obj before ground_graph() ran
post_ground_obj — graph node obj after ground_graph() ran
bridge_surface — the surface the bridge produced (content-gated)
bridge_useful — whether _is_useful_surface() passed
fallback_surface — ArticulationPlan.surface (content-gated)
See ``docs/adr/full-sentence-output-plan.md``.
"""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from pathlib import Path
from typing import IO, Protocol
# ---------------------------------------------------------------------------
# Record
# ---------------------------------------------------------------------------
@dataclass(frozen=True, slots=True)
class BridgeTraceRecord:
"""One per-turn observation record from the intent bridge.
All fields are plain Python types so serialization is trivial and
the record is safe to construct inside the hot path without any
I/O or numpy dependency.
"""
intent_tag: str # IntentTag.name (e.g. "DEFINITION")
intent_subject: str # classifier subject slot, "" if None
plan_subject: str
plan_predicate: str
plan_object: str # "" when ArticulationPlan.object is None
recalled_words_len: int
recalled_words_sample: tuple[str, ...] # first ≤5 words; () when redacted
pre_ground_obj: str # graph p0.obj before ground_graph
post_ground_obj: str # graph p0.obj after ground_graph
bridge_surface: str # "" when redacted
bridge_useful: bool
fallback_surface: str # "" when redacted
# ---------------------------------------------------------------------------
# Serializer
# ---------------------------------------------------------------------------
def serialize_bridge_trace(
record: BridgeTraceRecord,
*,
include_content: bool = False,
timestamp: str | None = None,
) -> dict[str, object]:
"""Produce a JSON-safe audit dict from a ``BridgeTraceRecord``.
Content fields (``recalled_words_sample``, ``bridge_surface``,
``fallback_surface``) are only emitted when ``include_content``
is True. The metadata fields are always emitted so aggregation
pipelines can compute grounding-rate statistics without ever
seeing raw surface text.
"""
out: dict[str, object] = {
"type": "bridge_trace",
"intent_tag": record.intent_tag,
"intent_subject_len": len(record.intent_subject),
"plan_subject_len": len(record.plan_subject),
"plan_predicate_len": len(record.plan_predicate),
"plan_object_present": bool(record.plan_object),
"recalled_words_len": record.recalled_words_len,
"pre_ground_obj_pending": record.pre_ground_obj in ("<pending>", "<prior>"),
"post_ground_obj_pending": record.post_ground_obj in ("<pending>", "<prior>"),
"bridge_useful": record.bridge_useful,
# Derived diagnostic flag: grounding changed something.
"grounding_changed_obj": record.pre_ground_obj != record.post_ground_obj,
}
if include_content:
out["intent_subject"] = record.intent_subject
out["plan_subject"] = record.plan_subject
out["plan_predicate"] = record.plan_predicate
out["plan_object"] = record.plan_object
out["recalled_words_sample"] = list(record.recalled_words_sample)
out["pre_ground_obj"] = record.pre_ground_obj
out["post_ground_obj"] = record.post_ground_obj
out["bridge_surface"] = record.bridge_surface
out["fallback_surface"] = record.fallback_surface
if timestamp is not None:
out["timestamp"] = str(timestamp)
return out
def format_bridge_trace_jsonl(
record: BridgeTraceRecord,
*,
include_content: bool = False,
timestamp: str | None = None,
) -> str:
"""Serialize one trace record as a deterministic JSONL line.
Field order is alphabetical (``sort_keys=True``) for byte-stable
replay diffing. No trailing newline — the sink owns termination.
"""
payload = serialize_bridge_trace(
record, include_content=include_content, timestamp=timestamp
)
return json.dumps(payload, sort_keys=True, separators=(",", ":"))
# ---------------------------------------------------------------------------
# Sink protocol
# ---------------------------------------------------------------------------
class BridgeTraceSink(Protocol):
"""Minimal sink contract (mirrors TurnEventSink in chat/telemetry.py).
Sinks receive one already-serialized JSONL line per
``articulate_with_intent()`` call. The sink is attached at the
module level via ``attach_bridge_trace_sink``; callers that never
attach a sink pay only the ``is None`` guard cost.
"""
def emit(self, line: str) -> None: ...
# ---------------------------------------------------------------------------
# Concrete sinks
# ---------------------------------------------------------------------------
@dataclass
class JsonlBufferSink:
"""In-memory sink that captures every emitted line.
Useful for tests and interactive session analysis where
persistence is the caller's responsibility.
"""
lines: list[str] = field(default_factory=list)
include_content: bool = False
def emit(self, line: str) -> None:
self.lines.append(line)
def records(self) -> list[dict]:
"""Parse all emitted lines and return them as dicts."""
return [json.loads(line) for line in self.lines]
def grounding_rate(self) -> float:
"""Fraction of turns where the bridge produced a useful surface.
Returns 0.0 when no lines have been emitted yet.
"""
parsed = self.records()
if not parsed:
return 0.0
return sum(1 for r in parsed if r.get("bridge_useful")) / len(parsed)
def pending_rate(self) -> float:
"""Fraction of turns where the post-ground obj was still <pending>.
Returns 0.0 when no lines have been emitted yet.
"""
parsed = self.records()
if not parsed:
return 0.0
return sum(1 for r in parsed if r.get("post_ground_obj_pending")) / len(parsed)
def recalled_words_empty_rate(self) -> float:
"""Fraction of turns where recalled_words_len == 0."""
parsed = self.records()
if not parsed:
return 0.0
return sum(1 for r in parsed if r.get("recalled_words_len", 0) == 0) / len(parsed)
def summary(self) -> dict:
"""One-shot diagnostic summary dict for operator inspection."""
parsed = self.records()
n = len(parsed)
if n == 0:
return {"turns": 0}
by_intent: dict[str, dict] = {}
for r in parsed:
tag = r.get("intent_tag", "UNKNOWN")
bucket = by_intent.setdefault(tag, {"total": 0, "useful": 0, "pending": 0, "no_recalled": 0})
bucket["total"] += 1
if r.get("bridge_useful"):
bucket["useful"] += 1
if r.get("post_ground_obj_pending"):
bucket["pending"] += 1
if r.get("recalled_words_len", 0) == 0:
bucket["no_recalled"] += 1
return {
"turns": n,
"grounding_rate": round(self.grounding_rate(), 4),
"pending_rate": round(self.pending_rate(), 4),
"recalled_words_empty_rate": round(self.recalled_words_empty_rate(), 4),
"by_intent": by_intent,
}
class JsonlFileSink:
"""Append-only JSONL file sink with eager flush.
Path fixed at construction. Each ``emit()`` flushes immediately
so a crashed turn loop still has prior turns durable on disk.
Supports context-manager usage.
"""
def __init__(
self,
path: str | Path,
*,
include_content: bool = False,
) -> None:
self._path = Path(path)
self._include_content = include_content
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()
@dataclass
class FanOutSink:
"""Forward every emitted line to N sinks in declaration order.
Fail-fast: if sink *i* raises, sinks *i+1..* are NOT called.
"""
sinks: tuple = () # tuple[BridgeTraceSink, ...]
def emit(self, line: str) -> None:
for sink in self.sinks:
sink.emit(line)
__all__ = [
"BridgeTraceRecord",
"BridgeTraceSink",
"FanOutSink",
"JsonlBufferSink",
"JsonlFileSink",
"format_bridge_trace_jsonl",
"serialize_bridge_trace",
]