suvradeepp's picture
Publish Tiny Hinglish Turn Detector development preview
35d483e verified
Raw
History Blame Contribute Delete
7.96 kB
"""Continuous checkpoint replay for product-level turn metrics."""
from __future__ import annotations
import hashlib
import json
import math
from collections.abc import Iterable
from dataclasses import asdict, dataclass
from pathlib import Path
from statistics import median
from typing import Any
from .controller import ControllerConfig, TurnController
from .types import Prediction, TurnDecision
_EVIDENCE_NOTES = {
"synthetic_integration": (
"Synthetic controller integration fixture only: probabilities and targets are "
"hand-authored and latency values are synthetic. This is not model, conversation, "
"latency, or product-quality evidence."
),
"unqualified_sequence": (
"User-supplied sequence replay; source provenance and annotation quality are not "
"verified by this tool."
),
}
@dataclass(frozen=True, slots=True)
class PauseCheckpoint:
turn_id: str
timestamp_ms: float
silence_ms: float
endpoint_probability: float
target_endpoint: bool
inference_ms: float = 0.0
@dataclass(frozen=True, slots=True)
class ReplayRecord:
checkpoint: PauseCheckpoint
decision: TurnDecision
def replay_checkpoints(
checkpoints: Iterable[PauseCheckpoint],
config: ControllerConfig | None = None,
) -> list[ReplayRecord]:
"""Replay ordered VAD checkpoints, resetting state between user turns."""
controller = TurnController(config)
records: list[ReplayRecord] = []
current_turn: str | None = None
last_timestamp = -math.inf
for checkpoint in checkpoints:
if checkpoint.turn_id != current_turn:
controller.reset()
current_turn = checkpoint.turn_id
last_timestamp = -math.inf
if checkpoint.timestamp_ms < last_timestamp:
raise ValueError(f"checkpoints are not ordered within turn {checkpoint.turn_id!r}")
last_timestamp = checkpoint.timestamp_ms
prediction = Prediction(
endpoint_probability=checkpoint.endpoint_probability,
inference_ms=checkpoint.inference_ms,
model_name="replay",
)
decision = controller.evaluate_pause(
prediction,
silence_ms=checkpoint.silence_ms,
timestamp_ms=checkpoint.timestamp_ms,
)
records.append(ReplayRecord(checkpoint=checkpoint, decision=decision))
return records
def _percentile(values: list[float], quantile: float) -> float | None:
if not values:
return None
ordered = sorted(values)
location = (len(ordered) - 1) * quantile
lower = math.floor(location)
upper = math.ceil(location)
if lower == upper:
return ordered[lower]
weight = location - lower
return ordered[lower] * (1.0 - weight) + ordered[upper] * weight
def _file_evidence(path: Path) -> dict[str, int | str]:
digest = hashlib.sha256()
with path.open("rb") as handle:
for block in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(block)
return {"bytes": path.stat().st_size, "sha256": digest.hexdigest()}
def summarize_replay(records: Iterable[ReplayRecord]) -> dict[str, float | int | None]:
"""Compute checkpoint and per-turn interruption/latency metrics."""
materialized = list(records)
tp = fp = tn = fn = 0
turns: dict[str, list[ReplayRecord]] = {}
for record in materialized:
predicted = record.decision.emit_response
actual = record.checkpoint.target_endpoint
tp += int(predicted and actual)
fp += int(predicted and not actual)
tn += int(not predicted and not actual)
fn += int(not predicted and actual)
turns.setdefault(record.checkpoint.turn_id, []).append(record)
interrupted_turns = 0
detected_turns = 0
delays: list[float] = []
for turn_records in turns.values():
if any(
item.decision.emit_response and not item.checkpoint.target_endpoint
for item in turn_records
):
interrupted_turns += 1
true_endpoint_times = [
item.checkpoint.timestamp_ms for item in turn_records if item.checkpoint.target_endpoint
]
if not true_endpoint_times:
continue
true_time = min(true_endpoint_times)
end_times = [
item.checkpoint.timestamp_ms
for item in turn_records
if item.decision.emit_response and item.checkpoint.timestamp_ms >= true_time
]
if end_times:
detected_turns += 1
delays.append(min(end_times) - true_time)
false_interruption_rate = fp / (fp + tn) if fp + tn else None
missed_end_rate = fn / (fn + tp) if fn + tp else None
turn_count = len(turns)
endpoint_turn_count = sum(
1 for values in turns.values() if any(item.checkpoint.target_endpoint for item in values)
)
emissions_by_turn = [
sum(item.decision.emit_response for item in values) for values in turns.values()
]
return {
"checkpoints": len(materialized),
"turns": turn_count,
"true_positives": tp,
"false_positives": fp,
"true_negatives": tn,
"false_negatives": fn,
"false_interruption_rate": false_interruption_rate,
"missed_end_rate": missed_end_rate,
"turns_with_premature_interruption": interrupted_turns,
"response_emissions": sum(emissions_by_turn),
"duplicate_response_emissions": sum(max(0, count - 1) for count in emissions_by_turn),
"premature_interruption_probability_per_turn": (
interrupted_turns / turn_count if turn_count else None
),
"endpoint_turn_detection_rate": (
detected_turns / endpoint_turn_count if endpoint_turn_count else None
),
"endpoint_delay_p50_ms": median(delays) if delays else None,
"endpoint_delay_p90_ms": _percentile(delays, 0.90),
"endpoint_delay_p95_ms": _percentile(delays, 0.95),
}
def replay_jsonl(
source: str | Path,
destination: str | Path,
config: ControllerConfig | None = None,
*,
evidence_scope: str = "unqualified_sequence",
) -> dict[str, Any]:
if evidence_scope not in _EVIDENCE_NOTES:
raise ValueError(f"unsupported replay evidence scope: {evidence_scope!r}")
source_path = Path(source)
checkpoints = []
with source_path.open(encoding="utf-8") as handle:
for line_number, line in enumerate(handle, 1):
if not line.strip():
continue
try:
checkpoints.append(PauseCheckpoint(**json.loads(line)))
except (TypeError, ValueError, json.JSONDecodeError) as exc:
raise ValueError(f"invalid checkpoint JSON on line {line_number}") from exc
resolved_config = config or ControllerConfig()
records = replay_checkpoints(checkpoints, resolved_config)
output = Path(destination)
output.parent.mkdir(parents=True, exist_ok=True)
with output.open("w", encoding="utf-8") as handle:
for record in records:
handle.write(
json.dumps(
{
"checkpoint": asdict(record.checkpoint),
"decision": asdict(record.decision),
},
default=lambda value: value.value,
sort_keys=True,
)
+ "\n"
)
summary = summarize_replay(records)
summary["format_version"] = 1
summary["controller_config"] = asdict(resolved_config)
summary["evidence_scope"] = evidence_scope
summary["evidence_note"] = _EVIDENCE_NOTES[evidence_scope]
summary["input"] = _file_evidence(source_path)
summary["decisions"] = _file_evidence(output)
output.with_suffix(".summary.json").write_text(
json.dumps(summary, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
return summary