oc_chat
Loading...
Searching...
No Matches
projection.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_chat/core/projection.py
4
5"""
6Incremental projection of the M2 event stream into readable state — plan 190 T2.
7
8Purpose
9-------
10The UI synthesis (§2/§4/§5) calls the projection/reducer "the keystone
11deliverable": the thing that turns the append-only ``TurnEvent`` stream
12into the state a reader wants, *before* any UI exists. One fold serves
13three consumers that would otherwise each grow their own ad-hoc
14accounting — a live dashboard, verification-backpressure observability,
15and cache governance.
16
17The fold is deliberately plain: :meth:`EventProjection.feed` takes one
18event and updates internal counters; :meth:`EventProjection.snapshot`
19returns an immutable value. Because it is pure and incremental, the same
20events fed live and replayed from the durable NDJSON log produce the
21identical snapshot — the property plan 190 T3 witnesses, and the reason
22``/trace``, ``/packet`` and replay can share one implementation.
23
24Responsibilities
25----------------
26- Fold cost, token and cache/ctx gauge inputs from
27 ``provider_budget_consumed``.
28- Track the live tool lifecycle (started → progress → completed) keyed by
29 ``tool_call_id``, plus proposals and permission decisions.
30- Track the sub-agent board, session/provider markers, prompt-render
31 layer hashes, and error / compaction markers.
32- Return all of it as one frozen :class:`ProjectionSnapshot`.
33
34What this module is NOT
35-----------------------
36No I/O, no presenter or ``oc_ui`` import, and **no bus attachment** — the
37module never subscribes to anything. A caller that wants live state
38attaches ``projection.feed`` to its own bus subscription; a caller
39replaying a log calls ``feed`` in a loop. Keeping attachment outside is
40what lets the same object serve both without a code path difference.
41
42Plan 188's ``core/presenter_events.py`` is untouched and unrelated: it
43translates events into presenter calls. Unifying the two is a named
44follow-up, not this module's job.
45
46Diagnostics
47-----------
48Domain: OC_CHAT.MAIN
49Levels:
50 L2 — lifecycle events (none emitted here)
51 L3 — semantic details (none emitted here)
52 L4 — module-load coverage marker
53
54Contracts
55---------
56- ``feed`` never raises on an unrecognised ``kind``: the v0.1 enum is
57 closed, but a replayed log may predate or postdate this build, and a
58 reducer that dies on an unknown line is useless for forensics.
59- ``snapshot`` returns a value: taking it does not freeze the reducer, and
60 feeding more events does not mutate a snapshot already returned.
61- Every number reported is one the stream actually carries. Where the
62 current 20 kinds cannot express something a reader wants, this module
63 reports nothing rather than an inferred figure. The three such gaps are
64 documented on :class:`ProjectionSnapshot` and asserted by the tests.
65
66Dependencies
67------------
68- oc_diagnostics (structured debug logging via _dbg)
69- option_c_agent_contracts (lazy; TurnEvent type hint for the feed method)
70"""
71from __future__ import annotations
72
73from dataclasses import dataclass, field
74from typing import TYPE_CHECKING, Any, Optional
75
76from oc_diagnostics import _dbg
77
78if TYPE_CHECKING: # pragma: no cover - typing only
79 from option_c_agent_contracts import TurnEvent
80
81_dbg("OC_CHAT.MAIN", "projection", "module", 4)
82
83
84#: Tool names that drive the plan / queue workflow. Used ONLY to offer a
85#: filtered view of real tool activity (see ``plan_tool_calls``) — never to
86#: synthesise plan state, which the v0.1 enum cannot express.
87_PLAN_TOOL_NAMES: frozenset = frozenset({
88 "create_plan", "mark_step", "get_status", "stop_plan",
89})
90
91
92@dataclass(frozen=True, slots=True)
94 """The cost + gauge inputs derivable from ``provider_budget_consumed``."""
95
96 total_usd: float = 0.0
97 # OCT-LINT: disable=no_hardcoded_secrets reason="Dataclass field docstring prose trips the Shannon entropy heuristic (L98, 'Sum of the ``cost_usd`` of the samples that carried one.', 4.50 bits/char - re-measured for plan 203, which rewrote this docstring; the prior waiver still quoted the pre-203 text 'Sum of the published samples...' at 4.60 bits/char); no secret material - this module is a pure in-memory fold holding no credentials, tokens, URLs or connection strings. File-scoped because that is the only waiver scope oct implements (quality_gate.py discards the line number)."
98 """Sum of the ``cost_usd`` of the samples that carried one.
99
100 EXACT when :attr:`unpriced_samples` is 0; a LOWER BOUND when it is not —
101 and, since plan 203, the reader can tell which. Plan 190 had to document
102 this as an unconditional lower bound: ``cost_usd`` was a non-optional
103 ``float``, so an unpriceable sample could not be published at all and its
104 absence was indistinguishable from no spend. The contract now types it
105 ``Optional[float]``, the unpriceable sample publishes with ``None``, and
106 the count of those lands in :attr:`unpriced_samples` instead of vanishing.
107 """
108 input_tokens: int = 0
109 output_tokens: int = 0
110 samples: int = 0
111 unpriced_samples: int = 0
112 """How many of :attr:`samples` published a ``cost_usd`` of ``None``.
113
114 The unknown-vs-zero distinction: a sample that reported ``0.0`` is
115 priced-and-free and is NOT counted here, while one that reported ``None``
116 could not be priced. Non-zero means :attr:`total_usd` understates real
117 spend by an amount this fold cannot know — which is a fact about the
118 stream, not a defect in it.
119 """
120 max_input_tokens: int = 0
121 """The ctx gauge input: the LARGEST single prompt seen, not the sum.
122
123 ``ctx:%`` asks how close one request came to the context window, so a
124 running total would be meaningless for it.
125 """
126
127
128@dataclass(frozen=True, slots=True)
130 """One tool call's live lifecycle, keyed by ``tool_call_id``."""
131
132 tool_call_id: str
133 tool_name: str
134 status: str
135 """``"started"`` → ``"running"`` (a progress event arrived) → ``"completed"``."""
136 outcome: Optional[str] = None
137 progress: tuple = ()
138 result_artifact_id: Optional[str] = None
139
140
141@dataclass(frozen=True, slots=True)
143 """A proposal, deliberately WITHOUT a ``tool_call_id``.
144
145 ``tool_call_proposed`` carries a tool name and the requested args;
146 the call id does not exist until the provider assigns one, so neither
147 its payload nor its envelope can carry it. The reducer records the
148 proposal and declines to guess which started call it became — ADR-5's
149 ordering makes a positional guess *usually* right, which is exactly
150 what makes it dangerous to encode as fact.
151 """
152
153 tool_name: str
154 turn_id: Optional[str] = None
155
156
157@dataclass(frozen=True, slots=True)
159 """A permission decision, deliberately WITHOUT a tool linkage.
160
161 LIMITATION (plan 190, documented not faked): ``permission_decision``
162 sets ``permission_decision_id`` on payload and envelope, but no
163 ``tool_call_id`` on either — so a decision cannot be attributed to the
164 call it gated. Ordering within a turn is suggestive, not evidence.
165 """
166
167 permission_decision_id: str
168 decision: str
169 decided_by: str
170 turn_id: Optional[str] = None
171
172
173@dataclass(frozen=True, slots=True)
175 """One row of the sub-agent board, keyed by ``subagent_session_id``."""
176
177 subagent_session_id: str
178 status: str
179 outcome: Optional[str] = None
180
181
182@dataclass(frozen=True, slots=True)
184 """Compaction markers.
185
186 STEP-0 found no compaction publisher in the product, so a live stream
187 will not carry these today. The fold handles them anyway: a replayed
188 log from another build must not break the reducer.
189 """
190
191 started: int = 0
192 completed: int = 0
193 active: bool = False
194 last_reason: Optional[str] = None
195
196
197@dataclass(frozen=True, slots=True)
199 """The folded state at one point in the stream — an immutable value.
200
201 Three things a reader may want are NOT here, because the current 20
202 kinds cannot express them and an inferred number is worse than none:
203
204 1. **Cache hit ratio.** ``provider_budget_consumed`` carries
205 ``input_tokens`` / ``output_tokens`` but no hit/miss split, so the
206 ``cache:%`` gauge cannot be derived from the stream (the ctx gauge
207 input can — see :attr:`CostView.max_input_tokens`).
208 2. **Plan / queue state.** The v0.1 enum has no plan or queue kinds.
209 :attr:`plan_tool_calls` is a filter over real tool events — it
210 reports plan-tool *activity*, and is not a claim about plan state.
211 3. **Proposal → call and permission → call linkage.** See
212 :class:`ProposedCallView` and :class:`PermissionDecisionView`.
213 """
214
215 events_seen: int = 0
216 session_id: Optional[str] = None
217 session_status: Optional[str] = None
218 session_outcome: Optional[str] = None
219 turn_ids: tuple = ()
220 cost: CostView = field(default_factory=CostView)
221 prompt_versions: tuple = ()
222 layer_hashes: dict = field(default_factory=dict)
223 tool_calls: tuple = ()
224 proposed_calls: tuple = ()
225 permission_decisions: tuple = ()
226 subagents: tuple = ()
227 plan_tool_calls: tuple = ()
228 errors: tuple = ()
229 branch_summaries: tuple = ()
230 hooks: tuple = ()
231 compaction: CompactionView = field(default_factory=CompactionView)
232 rate_limited: int = 0
233 retried: int = 0
234 user_inputs: int = 0
235 assistant_messages: int = 0
236
237
239 """Fold a ``TurnEvent`` stream into a :class:`ProjectionSnapshot`.
240
241 Usage is two lines, live or replayed::
242
243 proj = EventProjection()
244 bus.subscribe("*", proj.feed) # live — attachment is the CALLER's
245 for event in replayed: proj.feed(event) # replayed
246
247 Both produce the same snapshot for the same events, which is the
248 property plan 190 T3 witnesses end-to-end.
249 """
250
251 def __init__(self) -> None:
252 self._events = 0
253 self._session_id: Optional[str] = None
254 self._session_status: Optional[str] = None
255 self._session_outcome: Optional[str] = None
256 self._turn_ids: dict = {}
257 self._usd = 0.0
258 self._in_tokens = 0
259 self._out_tokens = 0
260 self._samples = 0
261 self._unpriced = 0
262 self._max_in = 0
263 self._prompt_versions: list = []
264 self._layer_hashes: dict = {}
265 self._tools: dict = {}
266 self._proposed: list = []
267 self._permissions: list = []
268 self._subagents: dict = {}
269 self._plan_tools: list = []
270 self._errors: list = []
271 self._branch_summaries: list = []
272 self._hooks: list = []
275 self._compaction_reason: Optional[str] = None
277 self._retried = 0
280
281 # -- the fold ------------------------------------------------------------
282 def feed(self, event: "TurnEvent") -> None:
283 """Fold one event into the running state.
284
285 Unrecognised kinds advance the counter and are otherwise ignored
286 (see the module Contracts) — a reducer that dies on an unknown
287 line cannot be used to investigate the log that contains it.
288 """
289 self._events += 1
290 kind = getattr(event, "kind", "")
291 payload = getattr(event, "payload", None)
292
293 sid = getattr(event, "session_id", None)
294 if sid and self._session_id is None:
295 self._session_id = sid
296 turn_id = getattr(event, "turn_id", None)
297 if turn_id:
298 self._turn_ids[turn_id] = True
299
300 handler = _HANDLERS.get(kind)
301 if handler is not None:
302 handler(self, event, payload)
303
304 # -- per-kind handlers ---------------------------------------------------
305 def _on_session_started(self, _event: Any, _payload: Any) -> None:
306 self._session_status = "started"
307
308 def _on_session_ended(self, _event: Any, payload: Any) -> None:
309 self._session_status = "ended"
310 self._session_outcome = getattr(payload, "outcome", None)
311
312 def _on_user_input(self, _event: Any, _payload: Any) -> None:
313 self._user_inputs += 1
314
315 def _on_assistant_message(self, _event: Any, _payload: Any) -> None:
316 self._assistant_messages += 1
317
318 def _on_prompt_rendered(self, _event: Any, payload: Any) -> None:
319 version = getattr(payload, "prompt_version", None)
320 if version:
321 self._prompt_versions.append(version)
322 hashes = getattr(payload, "layer_hashes", None)
323 if hashes:
324 # Last render wins: cache governance asks "what is the prefix
325 # NOW", and a superseded hash is history, not state.
326 self._layer_hashes = dict(hashes)
327
328 def _on_budget(self, _event: Any, payload: Any) -> None:
329 # Plan 203: None is UNKNOWN, not zero. The previous ``or 0.0`` folded
330 # both into the same number, which is the coercion that made a running
331 # total an unqualified lower bound. A missing attribute reads as
332 # unknown too — a foreign build that never set it has told us nothing.
333 cost = getattr(payload, "cost_usd", None)
334 if cost is None:
335 self._unpriced += 1
336 else:
337 self._usd += float(cost)
338 inp = int(getattr(payload, "input_tokens", 0) or 0)
339 self._in_tokens += inp
340 self._out_tokens += int(getattr(payload, "output_tokens", 0) or 0)
341 self._max_in = max(self._max_in, inp)
342 self._samples += 1
343
344 def _on_rate_limited(self, _event: Any, _payload: Any) -> None:
345 self._rate_limited += 1
346
347 def _on_retried(self, _event: Any, _payload: Any) -> None:
348 self._retried += 1
349
350 def _on_proposed(self, event: Any, payload: Any) -> None:
351 self._proposed.append(ProposedCallView(
352 tool_name=getattr(payload, "tool_name", ""),
353 turn_id=getattr(event, "turn_id", None),
354 ))
355
356 def _on_permission(self, event: Any, payload: Any) -> None:
358 permission_decision_id=getattr(payload, "permission_decision_id", ""),
359 decision=getattr(payload, "decision", ""),
360 decided_by=getattr(payload, "decided_by", ""),
361 turn_id=getattr(event, "turn_id", None),
362 ))
363
364 def _on_tool_started(self, _event: Any, payload: Any) -> None:
365 call_id = getattr(payload, "tool_call_id", "")
366 name = getattr(payload, "tool_name", "")
367 self._tools[call_id] = ToolCallView(
368 tool_call_id=call_id, tool_name=name, status="started")
369 if name in _PLAN_TOOL_NAMES:
370 self._plan_tools.append(name)
371
372 def _on_tool_progress(self, _event: Any, payload: Any) -> None:
373 call_id = getattr(payload, "tool_call_id", "")
374 node = self._tools.get(call_id)
375 message = getattr(payload, "message", "")
376 if node is None:
377 # Progress without a start: a truncated or mid-stream log. Record
378 # the call rather than dropping evidence that it ran.
379 self._tools[call_id] = ToolCallView(
380 tool_call_id=call_id, tool_name="", status="running",
381 progress=(message,))
382 return
383 self._tools[call_id] = ToolCallView(
384 tool_call_id=node.tool_call_id, tool_name=node.tool_name,
385 status="running" if node.status != "completed" else node.status,
386 outcome=node.outcome, progress=node.progress + (message,),
387 result_artifact_id=node.result_artifact_id)
388
389 def _on_tool_completed(self, _event: Any, payload: Any) -> None:
390 call_id = getattr(payload, "tool_call_id", "")
391 node = self._tools.get(call_id)
392 outcome = getattr(payload, "outcome", None)
393 artifact = getattr(payload, "result_artifact_id", None)
394 if node is None:
395 self._tools[call_id] = ToolCallView(
396 tool_call_id=call_id, tool_name="", status="completed",
397 outcome=outcome, result_artifact_id=artifact)
398 return
399 self._tools[call_id] = ToolCallView(
400 tool_call_id=node.tool_call_id, tool_name=node.tool_name,
401 status="completed", outcome=outcome, progress=node.progress,
402 result_artifact_id=artifact)
403
404 def _on_subagent_started(self, _event: Any, payload: Any) -> None:
405 said = getattr(payload, "subagent_session_id", "")
406 self._subagents[said] = SubagentView(
407 subagent_session_id=said, status="started")
408
409 def _on_subagent_completed(self, _event: Any, payload: Any) -> None:
410 said = getattr(payload, "subagent_session_id", "")
411 self._subagents[said] = SubagentView(
412 subagent_session_id=said, status="completed",
413 outcome=getattr(payload, "outcome", None))
414
415 def _on_compaction_started(self, _event: Any, payload: Any) -> None:
416 self._compaction_started += 1
417 self._compaction_reason = getattr(payload, "reason", None)
418
419 def _on_compaction_completed(self, _event: Any, _payload: Any) -> None:
420 self._compaction_completed += 1
421
422 def _on_branch_summary(self, _event: Any, payload: Any) -> None:
423 self._branch_summaries.append(getattr(payload, "summary", ""))
424
425 def _on_error(self, _event: Any, payload: Any) -> None:
426 self._errors.append(getattr(payload, "error_summary", ""))
427
428 def _on_hook(self, _event: Any, payload: Any) -> None:
429 self._hooks.append((getattr(payload, "hook_name", ""),
430 int(getattr(payload, "exit_code", 0) or 0)))
431
432 # -- the read ------------------------------------------------------------
433 def snapshot(self) -> ProjectionSnapshot:
434 """Return the folded state as an immutable value.
435
436 Copies every mutable container, so the returned snapshot is
437 unaffected by later :meth:`feed` calls.
438 """
439 return ProjectionSnapshot(
440 events_seen=self._events,
441 session_id=self._session_id,
442 session_status=self._session_status,
443 session_outcome=self._session_outcome,
444 turn_ids=tuple(self._turn_ids),
445 cost=CostView(
446 total_usd=round(self._usd, 10),
447 input_tokens=self._in_tokens,
448 output_tokens=self._out_tokens,
449 samples=self._samples,
450 unpriced_samples=self._unpriced,
451 max_input_tokens=self._max_in,
452 ),
453 prompt_versions=tuple(self._prompt_versions),
454 layer_hashes=dict(self._layer_hashes),
455 tool_calls=tuple(self._tools.values()),
456 proposed_calls=tuple(self._proposed),
457 permission_decisions=tuple(self._permissions),
458 subagents=tuple(self._subagents.values()),
459 plan_tool_calls=tuple(self._plan_tools),
460 errors=tuple(self._errors),
461 branch_summaries=tuple(self._branch_summaries),
462 hooks=tuple(self._hooks),
463 compaction=CompactionView(
464 started=self._compaction_started,
465 completed=self._compaction_completed,
467 last_reason=self._compaction_reason,
468 ),
469 rate_limited=self._rate_limited,
470 retried=self._retried,
471 user_inputs=self._user_inputs,
472 assistant_messages=self._assistant_messages,
473 )
474
475
476#: Kind → bound-method dispatch, built once. A dict beats an if/elif chain
477#: here because the fold runs per event on the hot path of a live stream.
478_HANDLERS: dict = {
479 "session_started": EventProjection._on_session_started,
480 "session_ended": EventProjection._on_session_ended,
481 "user_input_received": EventProjection._on_user_input,
482 "assistant_message": EventProjection._on_assistant_message,
483 "prompt_rendered": EventProjection._on_prompt_rendered,
484 "provider_rate_limited": EventProjection._on_rate_limited,
485 "provider_retried": EventProjection._on_retried,
486 "provider_budget_consumed": EventProjection._on_budget,
487 "tool_call_proposed": EventProjection._on_proposed,
488 "permission_decision": EventProjection._on_permission,
489 "tool_call_started": EventProjection._on_tool_started,
490 "tool_call_progress": EventProjection._on_tool_progress,
491 "tool_call_completed": EventProjection._on_tool_completed,
492 "subagent_started": EventProjection._on_subagent_started,
493 "subagent_completed": EventProjection._on_subagent_completed,
494 "compaction_started": EventProjection._on_compaction_started,
495 "compaction_completed": EventProjection._on_compaction_completed,
496 "branch_summary": EventProjection._on_branch_summary,
497 "error": EventProjection._on_error,
498 "hook_executed": EventProjection._on_hook,
499}
500
501__all__ = [
502 "CompactionView",
503 "CostView",
504 "EventProjection",
505 "PermissionDecisionView",
506 "ProjectionSnapshot",
507 "ProposedCallView",
508 "SubagentView",
509 "ToolCallView",
510]
None _on_hook(self, Any _event, Any payload)
ProjectionSnapshot snapshot(self)
None _on_assistant_message(self, Any _event, Any _payload)
None _on_retried(self, Any _event, Any _payload)
None _on_subagent_started(self, Any _event, Any payload)
None _on_user_input(self, Any _event, Any _payload)
None _on_branch_summary(self, Any _event, Any payload)
None _on_prompt_rendered(self, Any _event, Any payload)
None _on_tool_started(self, Any _event, Any payload)
None _on_tool_completed(self, Any _event, Any payload)
None _on_error(self, Any _event, Any payload)
None _on_proposed(self, Any event, Any payload)
None _on_compaction_completed(self, Any _event, Any _payload)
None _on_session_started(self, Any _event, Any _payload)
None _on_compaction_started(self, Any _event, Any payload)
None _on_rate_limited(self, Any _event, Any _payload)
None _on_session_ended(self, Any _event, Any payload)
None _on_permission(self, Any event, Any payload)
None feed(self, "TurnEvent" event)
None _on_tool_progress(self, Any _event, Any payload)
None _on_budget(self, Any _event, Any payload)
None _on_subagent_completed(self, Any _event, Any payload)