feat(profiler/behave_shell): output error-signal helper for Phase D
Lifts the error-signal slice of F.0 forward as a D.0 prelude. ANSI strip + canonical bash/sh error fingerprints classify each command's post-execution output window; Command gains errored / output_bytes fields. PII discipline preserved — only a bool and an int leave the helper, the stripped output text is dropped on return. Drives D.1 (cognitive_load error_rate term) and D.5–D.7 (error_resilience family). Phase F.0 will subsume this with PS1 + exit-code parsing.
This commit is contained in:
@@ -10,6 +10,7 @@ will fill ``commands`` / ``inter_cmd_iats`` / ``output_per_cmd``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Iterable
|
||||
|
||||
@@ -17,7 +18,9 @@ from decnet.profiler.behave_shell._parse import (
|
||||
AsciinemaEvent,
|
||||
Command,
|
||||
PasteBurst,
|
||||
detect_error_in_output,
|
||||
hash_token,
|
||||
strip_ansi,
|
||||
)
|
||||
from decnet.profiler.behave_shell._thresholds import (
|
||||
IKI_THINK_MAX_S,
|
||||
@@ -219,6 +222,36 @@ def _segment_commands(inputs: list[AsciinemaEvent]) -> tuple[Command, ...]:
|
||||
return tuple(cmds)
|
||||
|
||||
|
||||
def _annotate_commands_with_output(
|
||||
commands: tuple[Command, ...],
|
||||
outputs: list[AsciinemaEvent],
|
||||
) -> tuple[Command, ...]:
|
||||
"""Re-emit ``commands`` with ``errored`` / ``output_bytes`` filled.
|
||||
|
||||
The output window for ``commands[i]`` spans from its ``end_ts``
|
||||
(the ``\\r``/``\\n`` that ran it) to the ``start_ts`` of the next
|
||||
command. The last command's window is open-ended (``math.inf``)
|
||||
so output events arriving at or after ``t_end`` are still captured.
|
||||
"""
|
||||
if not commands:
|
||||
return commands
|
||||
annotated: list[Command] = []
|
||||
for i, cmd in enumerate(commands):
|
||||
win_end = commands[i + 1].start_ts if i + 1 < len(commands) else math.inf
|
||||
byte_count, errored = _output_window(outputs, cmd.end_ts, win_end)
|
||||
annotated.append(Command(
|
||||
start_ts=cmd.start_ts,
|
||||
end_ts=cmd.end_ts,
|
||||
first_token_hash=cmd.first_token_hash,
|
||||
tab_count=cmd.tab_count,
|
||||
shortcut_count=cmd.shortcut_count,
|
||||
pipe_count=cmd.pipe_count,
|
||||
errored=errored,
|
||||
output_bytes=byte_count,
|
||||
))
|
||||
return tuple(annotated)
|
||||
|
||||
|
||||
def _per_command_iats(
|
||||
commands: tuple[Command, ...],
|
||||
inputs: list[AsciinemaEvent],
|
||||
@@ -252,6 +285,32 @@ def _output_bytes_between(
|
||||
return sum(len(d) for t, _k, d in outputs if start <= t < end)
|
||||
|
||||
|
||||
def _output_window(
|
||||
outputs: list[AsciinemaEvent],
|
||||
start: float,
|
||||
end: float,
|
||||
) -> tuple[int, bool]:
|
||||
"""Walk output events in ``[start, end)`` once.
|
||||
|
||||
Returns ``(byte_count, errored)``. ``byte_count`` is the raw byte
|
||||
count (pre-strip); ``errored`` is the canonical-error-pattern match
|
||||
over the ANSI-stripped concatenation. The stripped text is dropped
|
||||
on return — PII discipline: only an int and a bool leave this
|
||||
helper. The full output bytes never enter ``Command`` or the
|
||||
``SessionContext``.
|
||||
"""
|
||||
chunks: list[str] = []
|
||||
byte_count = 0
|
||||
for t, _k, d in outputs:
|
||||
if start <= t < end:
|
||||
byte_count += len(d)
|
||||
chunks.append(d)
|
||||
if not chunks:
|
||||
return 0, False
|
||||
stripped = strip_ansi("".join(chunks))
|
||||
return byte_count, detect_error_in_output(stripped)
|
||||
|
||||
|
||||
def build_session_context(
|
||||
events: Iterable[AsciinemaEvent],
|
||||
*,
|
||||
@@ -290,6 +349,7 @@ def build_session_context(
|
||||
typing_bursts = _split_typing_bursts(iats)
|
||||
backspace_count, backspace_iats, kill_line_count = _scan_correction_signals(inputs)
|
||||
commands = _segment_commands(inputs)
|
||||
commands = _annotate_commands_with_output(commands, outputs)
|
||||
inter_cmd_iats = tuple(
|
||||
max(0.0, commands[i + 1].start_ts - commands[i].end_ts)
|
||||
for i in range(len(commands) - 1)
|
||||
|
||||
@@ -15,6 +15,7 @@ from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Iterable, Iterator, Literal, Tuple
|
||||
|
||||
@@ -22,6 +23,47 @@ EventKind = Literal["i", "o"]
|
||||
AsciinemaEvent = Tuple[float, EventKind, str]
|
||||
|
||||
|
||||
# CSI / OSC / SGR / single-char escape sweeper. One pass, then we drop the
|
||||
# stripped text on the floor — only the boolean error verdict (and the byte
|
||||
# count, computed before stripping) leaves the helper. Full prompt-string
|
||||
# parsing lives in Phase F.0; this is the slice cognitive.error_resilience.*
|
||||
# needs to ship correctly.
|
||||
_ANSI_RE = re.compile(
|
||||
r"""
|
||||
\x1B # ESC
|
||||
(?:
|
||||
\[ [0-?]* [ -/]* [@-~] # CSI
|
||||
| \] [^\x07\x1B]* (?:\x07|\x1B\\)? # OSC, ST-or-BEL terminated
|
||||
| [@-Z\\-_] # 2-byte escapes (ESC followed by 0x40-0x5F)
|
||||
)
|
||||
""",
|
||||
re.VERBOSE,
|
||||
)
|
||||
|
||||
|
||||
def strip_ansi(data: str) -> str:
|
||||
"""Remove ANSI escape sequences. Used pre-error-pattern match."""
|
||||
return _ANSI_RE.sub("", data)
|
||||
|
||||
|
||||
# Canonical bash/sh error fingerprints. v0.1 heuristic — Phase F.0's prompt
|
||||
# parser will subsume this with PS1 + exit-code sniff. Any change here must
|
||||
# leave the calibration grid green.
|
||||
_OUTPUT_ERROR_PATTERNS: tuple[re.Pattern[str], ...] = (
|
||||
re.compile(r"command not found"),
|
||||
re.compile(r"No such file or directory"),
|
||||
re.compile(r"Permission denied"),
|
||||
re.compile(r": cannot "),
|
||||
re.compile(r"Operation not permitted"),
|
||||
re.compile(r"syntax error near unexpected token"),
|
||||
)
|
||||
|
||||
|
||||
def detect_error_in_output(stripped: str) -> bool:
|
||||
"""True if any canonical error fingerprint matches the stripped output."""
|
||||
return any(p.search(stripped) for p in _OUTPUT_ERROR_PATTERNS)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PasteBurst:
|
||||
"""Contiguous run of paste-class input events."""
|
||||
@@ -53,6 +95,16 @@ class Command:
|
||||
byte sweep. They feed the ``motor.shell_mastery.*`` primitives
|
||||
(Phase C). The raw bytes themselves are read once during the
|
||||
sweep and discarded — only the counters are retained.
|
||||
|
||||
``errored`` (Step D.0) is set when the output stream between this
|
||||
command and the next contains a canonical bash/sh error fingerprint
|
||||
(see :func:`detect_error_in_output`). ``output_bytes`` is the byte
|
||||
count of that same window. Both are populated in the segmentation
|
||||
walk; the underlying output text is stripped of ANSI then matched,
|
||||
and the stripped text is discarded — only the bool and the int
|
||||
leave the segmentation pass. Drives the ``cognitive.error_resilience.*``
|
||||
family (Phase D) and the ``error_rate`` term of
|
||||
``cognitive.cognitive_load``.
|
||||
"""
|
||||
|
||||
start_ts: float
|
||||
@@ -61,6 +113,8 @@ class Command:
|
||||
tab_count: int = 0
|
||||
shortcut_count: int = 0
|
||||
pipe_count: int = 0
|
||||
errored: bool = False
|
||||
output_bytes: int = 0
|
||||
|
||||
|
||||
def hash_token(token: str) -> str:
|
||||
|
||||
@@ -76,6 +76,17 @@ FEEDBACK_MIN_PAIRS: int = 5
|
||||
PAUSE_CV_METRONOMIC_MAX: float = 0.40
|
||||
PAUSE_CV_BIMODAL_MIN: float = 1.50
|
||||
|
||||
# ── output error-signal helper (Step D.0) ──────────────────────────────────
|
||||
# The canonical bash/sh error fingerprints live in ``_parse.py`` as
|
||||
# ``_OUTPUT_ERROR_PATTERNS`` (compiled regexes). They're not threshold
|
||||
# numbers, so they live next to the helper that uses them rather than
|
||||
# here. This v0.1 heuristic will be subsumed by Phase F.0's prompt
|
||||
# parser (PS1 echo + exit-code sniff), at which point this comment and
|
||||
# the patterns block move to ``_parse.py``'s prompt section. Until then,
|
||||
# any drift in registry value definitions for ``error_resilience.*`` or
|
||||
# ``cognitive_load`` must be reflected by editing the patterns tuple
|
||||
# (not a constant, so no boundary-band logic applies).
|
||||
|
||||
# ── motor.keystroke_cadence (Step B.1) ──────────────────────────────────────
|
||||
# Typing bursts split at gaps > IKI_THINK_MAX_S so think-pauses between
|
||||
# commands don't inflate the within-burst CV. Mirrors the prototype's
|
||||
|
||||
Reference in New Issue
Block a user