diff --git a/changelog.d/sui-t36.5.md b/changelog.d/sui-t36.5.md new file mode 100644 index 0000000..3247c5a --- /dev/null +++ b/changelog.d/sui-t36.5.md @@ -0,0 +1,8 @@ +### Added + +- `StatifierUI.EventLog.build/1` folds a trace message stream into a log + grouped by `(macrostep, round)`, ordered by the producer's stamps rather + than arrival, and `StatifierUI.EventLog.Markdown.render/2` renders it as + collapsible Markdown for `Kino.Markdown`, with wire-format indexes + resolved to state and transition names by + `StatifierUI.EventLog.Labels`. diff --git a/docs/plans/260822-sui-t36.5-event-log-pane.md b/docs/plans/260822-sui-t36.5-event-log-pane.md new file mode 100644 index 0000000..44a4217 --- /dev/null +++ b/docs/plans/260822-sui-t36.5-event-log-pane.md @@ -0,0 +1,1001 @@ +--- +date: 2026-08-22 +issue: sui-t36.5 +title: Event log pane +status: draft +tags: [plan, kino, trace, wire-format, event-log] +--- + +# Event log pane implementation plan + +## Overview + +Turn the normalized trace stream into a readable log keyed by +`(macrostep, round)`: one row per round, headed by the event that round +dequeued, carrying that round's selected transitions, exit and entry +sequences, executed content, and - where the dequeued event was raised by +the platform rather than sent from outside - the cause that raised it. +Macrosteps are the collapse unit. Ordering comes entirely from the stamps +the producer put on each message, never from arrival order. + +Five modules land in three groups, all pure and all free of `Kino`: a +fold (`EventLog` plus its `Round` and `Macrostep` structs) from +`[%StatifierUI.Trace.Message{}]` into a grouped model, an index resolver +that turns wire-format integers into human labels using the `session.start` +manifest already on the stream, and a Markdown renderer over the two. + +Beads issue: `sui-t36.5` (parent `sui-t36`, the Livebook inspector; +blocked-by `sui-t36.3` and `sui-bpb`, both closed; blocks `sui-t36.8`, +the widget assembly that composes the panes). + +## Current State Analysis + +`lib/statifier_ui/` holds the fixtures core (`Fixtures`, `Fixtures.Source`, +`Fixtures.Sidecar`, `Value`, `Shape`) and the trace plumbing under +`lib/statifier_ui/trace/` (`Message`, `Normalizer`, `Manifest`, `Buffer`, +`Json`, `Subscriber`). There is no pane of any kind in the repository: +`docs/architecture.md:94` says so outright, and the sui-t36.3 plan's scope +exclusions (`docs/plans/260817-sui-t36.3-session-subscriber-and-trace-normalizer.md:201-202`) +name `sui-t36.4`/`.5`/`.6`/`.7`/`.8` as the beads that own rendering: +"This bead ships the data they read." + +A sibling pane is in flight and sets the convention this plan follows. +`sui-t36.4` (PR #28, branch `sui-t36.4-config-renderer`) adds +`StatifierUI.Diagram` at `lib/statifier_ui/diagram.ex` - **top level, not +under a `kino/` directory** - as a pure function returning a `String.t()` +of Mermaid source. It references `Kino` only in moduledoc prose. That is +the whole optional-dependency strategy at this layer: do not touch the +optional dep at all, and let `sui-t36.8` own the `Kino` wrapper. This plan +does the same. + +**Those files are not in this worktree.** `lib/statifier_ui/diagram.ex`, +`test/statifier_ui/diagram_test.exs`, and `changelog.d/sui-t36.4.md` live +only on `sui-t36.4-config-renderer` until PR #28 merges. Every rule this +plan borrows from them is restated inline below, so nothing here requires +reading them - but an implementer who wants to see the originals runs +`git fetch origin sui-t36.4-config-renderer` and then +`git show origin/sui-t36.4-config-renderer:lib/statifier_ui/diagram.ex`, +or `gh pr diff 28`. Do not branch from or merge that branch; the two beads +share no files. + +### What is already decided and must be honored + +- **ADR-0005** fixes the wire format's envelope and its value discipline. + This plan consumes the format and adds nothing to it. +- **ADR-0011** settles that `trace.exit_set.indexes` and + `trace.entry_set.indexes` are *sequences in the engine's own emission + order*, never re-sorted - exit is inner-to-outer, entry is document + order. The record cites "the sequence the event-log pane renders to a + user stepping through a run" + (`docs/adr/0011-exit-and-entry-sets-are-sequences.md:40`) as the reason + the distinction matters. Sorting either list in this pane would report + the reverse of what happened. +- **ADR-0004** keeps `kino` and `phoenix_live_view` optional and enforced + at compile time. Nothing this plan writes references either. +- **statifier ADR-0044** (upstream, `st-r6l9`): arrival order is now + non-decreasing in `(macrostep, round)` and `session.halted` is terminal + per session id, but `docs/wire-format.md:118-122` still directs + consumers to reconstruct the timeline by sorting on `(macrostep, round)` + rather than trusting delivery. ADR-0044 also allows **more than one + `trace.macrostep_stable` per macrostep, exactly one per + `(macrostep, round)`, the last-arriving one being that macrostep's + quiescence** (`docs/wire-format.md:111-114`). +- **statifier ADR-0050**: a subscriber can observe an invoke tree of + related sessions on one mailbox, so a message list is not guaranteed to + be single-session. + +### What the wire format actually provides, verified + +- Every `trace.*` message carries `macrostep`, `microstep`, and `round` + (`docs/wire-format.md:396-398`). Every `session.*` message carries none + of the three (`docs/wire-format.md:663-670`). +- **`effect.*` messages carry `macrostep` and `microstep` but not + `round` - with exactly one exception.** + `effect.budget_exhausted` "is the one core effect that does carry + `round`" (`docs/wire-format.md:546-547`), and the producer passes it + through: `lib/statifier_ui/trace/normalizer.ex:275` supplies `p.round` + where every other `core_message/1` clause supplies `nil`. A model that + assumes "`effect.*` implies `round == nil`" is wrong about the one + effect that most needs a round position - the message that explains why + a macrostep never reached quiescence. +- `trace.event_dequeued` carries `event` and `from` + (`"external"`/`"internal"`); the event object carries `cause` only for + internal/platform events the platform itself raised + (`docs/wire-format.md:400-429`). A cause object is + `{origin, macrostep, microstep, round}` - the coordinates of the moment + that raised the event, distinct from the dequeue's own stamps. `origin` + is one of eight `"kind"`-tagged shapes (`docs/wire-format.md:625-645`). +- `trace.transitions_selected` carries `t_indexes` (empty when nothing was + selected) and an `event` key whose *absence* marks an eventless (NULL) + round (`docs/wire-format.md:436-439`). +- **The initialize burst has no `trace.event_dequeued` at all.** The worked + example's macrostep 1 (`docs/wire-format.md:778-781`) is + `trace.entry_set` at round 0, then `trace.transitions_selected`, + `trace.invoke_pass`, and `trace.macrostep_stable` at round 1 - nothing + was dequeued, because initialize is not event-driven. "One row per + dequeued event" therefore needs a headless row, not an exception. +- **A round spans microsteps.** In the worked example's macrostep 2, round + 0 covers microstep 0 (dequeue, finalize/autoforward, selection) *and* + microstep 1 (exit set, content, entry set); round 1 is the eventless + probe that selects nothing and reaches quiescence + (`docs/wire-format.md:782-790`). So a round is not a microstep and the + two counters are not interchangeable - but note that on *this* fixture + `microstep` never decreases as `round` increases, so the fixture alone + cannot tell a `(macrostep, round, ...)` ordering apart from a + `(macrostep, microstep, ...)` one. Decision 1 buckets by + `(macrostep, round)` rather than picking between two flat sorts, which + makes the distinction structural instead of fixture-dependent. +- `StatifierUI.Trace.Buffer` drops oldest at capacity and deliberately + emits no drop notification: "a consumer detects loss by reading the + first buffered message's `seq` directly" + (`lib/statifier_ui/trace/buffer.ex:12-16`). A pane that renders a + truncated buffer without saying so is lying to its reader. + +### What already exists here to build on + +- `StatifierUI.Trace.Subscriber.messages/1` + (`lib/statifier_ui/trace/subscriber.ex:194-195`) returns + `[%Message{}]` oldest-first - the input this pane folds. +- `StatifierUI.Trace.Manifest` (`lib/statifier_ui/trace/manifest.ex`) + already puts `states`, `transitions`, `contents`, and `data` tables on + the `session.start` payload, each entry carrying an index, a kind, and a + `location`. Everything the label resolver needs is already on the wire; + it reads the manifest, it does not extend it. +- `test/support/trace/session_case.ex` and + `test/support/trace/two_state.jsonl` give a live-session path and a + byte-exact reference trace. The worked example in + `docs/wire-format.md:775-791` is that same trace, readable as struct + literals. + +### Key Discoveries + +- Pane modules live at the top of `lib/statifier_ui/`, not under `kino/`: + `lib/statifier_ui/diagram.ex` on branch `sui-t36.4-config-renderer`. +- Bucketing by `(macrostep, round)` makes the sort key almost irrelevant: + buckets are emitted in `(macrostep, round)` order because that is the + format's own timeline key, and only *within* a bucket does anything else + order messages. `seq` is a producer stamp + (`docs/wire-format.md:84-89`), so using it as a within-bucket tiebreak + is still ordering from the stamps, not from arrival. +- `effect.*` messages other than `effect.budget_exhausted` have no + `round`, so they cannot be placed in a round bucket. They are placed in + a macrostep bucket instead. +- Every private function in `lib/statifier_ui/diagram.ex` carries a + `@spec`; `.doctor.exs` requires 100% moduledoc coverage and 75% doc/spec + coverage; `.quality.exs` runs Credo in `strict: true`. +- `docs/wire-format.md:63` and `:856` both name `sui-t36.5` as the bead + tracking `round` propagation onto `effect.*`. See open question 2: this + plan does not do that work. + +## Desired End State + +After this plan, the repository can turn a list of trace messages into a +readable, ordered, collapsible log without any Kino dependency: + +1. `StatifierUI.EventLog.build/1` takes `[%StatifierUI.Trace.Message{}]` + in any order and returns `{:ok, %StatifierUI.EventLog{}}` whose + macrosteps and rounds are ordered by their stamps alone. Feeding the + same list shuffled produces an identical struct. +2. Every message in the input is reachable from the result: anything + carrying a `round` in that round's `messages`, anything stamped with a + macrostep but no round in that macrostep's `effects`, and `session.*` + in the log's `session_messages`. Nothing is silently dropped. +3. A round exposes its dequeued `event` (or `nil` for the initialize + burst and for eventless rounds), its `from`, its raised `cause` when + present, its `t_indexes`, its `exited` and `entered` sequences in the + engine's order, its executed content, and its quiescence + `configuration` when that round carried one. +4. A macrostep exposes its rounds, its `effects`, and the configuration of + its *last* `trace.macrostep_stable` - the ADR-0044 quiescence rule. +5. `StatifierUI.EventLog.Labels` resolves a state index, a `t_index`, a + `c_index`, and a `d_index` to a human string using the `session.start` + manifest when the stream carries one, and degrades to the bare index + when it does not. +6. `StatifierUI.EventLog.Markdown.render/2` returns a `String.t()` a host + can hand to `Kino.Markdown.new/1`, with one collapsible block per + macrostep and one table row per round. +7. `mix quality` is green, and `mix xref callers Kino` lists none of the + new modules - they name `Kino` in prose and call it nowhere. + +## What We're NOT Doing + +- **No `Kino` module, no widget, no notebook.** `sui-t36.8` owns widget + assembly and the demo notebook. This bead ships the pane component the + widget mounts. +- **No changes to the wire format, the normalizer's type vocabulary, or + the fixtures contract.** In particular, this plan does **not** propagate + the engine's `round` onto `effect.*` messages (`st-xb2b` / ADR-0046), + even though `docs/wire-format.md:63` and `:856` name this bead as the + tracker for it. That work edits `lib/statifier_ui/trace/normalizer.ex` + and the `effect.*` schemas - the plumbing layer, not a pane - and it is + recorded as open question 2 for the operator to re-file. The model this + plan builds is forward-compatible with it at zero cost: the fold routes + on round-presence rather than type prefix, so those messages relocate to + their round buckets on their own once the producer stamps them. +- **No live subscription, no process.** `build/1` is a fold over a list. + Wiring it to `Subscriber.add_listener/2` for incremental updates is + `sui-t36.8`'s composition concern. +- **No incremental/streaming fold.** `build/1` rebuilds from the whole + list. A buffer is capacity-bounded by construction + (`lib/statifier_ui/trace/buffer.ex`), so the list is small, and an + incremental API would be a second contract to keep consistent with the + batch one for no measured gain. +- **No multi-session merging.** `build/1` refuses a list carrying more + than one session id rather than merging two runs onto one timeline, + which `docs/wire-format.md:96-102` forbids. Splitting an ADR-0050 + invoke tree into per-session logs is `sui-t36.8`'s job. +- **No source-text rendering.** `session.start` may carry `source`, and + every manifest entry carries a `location`, so a future pane could show + the SCXML line that fired. `sui-czr` owns source-span work; this pane + renders labels, not code. +- **No filtering, search, or paging controls.** Those are interactive + concerns that need a live widget to be meaningful. +- **No datamodel rendering.** `session.datamodel` lands in + `session_messages`, reachable but unrendered by the Markdown pane. + `sui-t36.7` owns the explorer, and its snapshot is all-`$undefined` + anyway (`docs/wire-format.md:686-694`), so rendering it here would show + a reader nothing true. + +## Implementation Approach + +Three phases, split on module boundaries so each is independently +committable and independently green. + +The split mirrors how `Normalizer` (pure) is split from `Subscriber` +(process): the part that decides *what the log says* is separated from the +part that decides *how it looks*. Phase 1 is the model, phase 2 is the +vocabulary, phase 3 is the presentation. A later swap of the renderer - +`Kino.DataTable`, a LiveView component, an elkjs-driven timeline - touches +phase 3 only. + +Files, all new: + +``` +lib/statifier_ui/event_log.ex # StatifierUI.EventLog + build/1 +lib/statifier_ui/event_log/round.ex # StatifierUI.EventLog.Round +lib/statifier_ui/event_log/macrostep.ex # StatifierUI.EventLog.Macrostep +lib/statifier_ui/event_log/labels.ex # StatifierUI.EventLog.Labels +lib/statifier_ui/event_log/markdown.ex # StatifierUI.EventLog.Markdown +test/statifier_ui/event_log_test.exs +test/statifier_ui/event_log/labels_test.exs +test/statifier_ui/event_log/markdown_test.exs +changelog.d/sui-t36.5.md +``` + +### Six design decisions this plan settles + +1. **Buckets are ordered by `(macrostep, round)`; messages inside a + bucket are ordered by `(microstep, seq)`.** Not one flat sort - two + levels, so the ordering rule is the format's own stated timeline key + (`docs/wire-format.md:91-94`) rather than a composite this plan + invented. + + An earlier draft justified a flat `{macrostep, round, microstep, seq}` + key by claiming `{macrostep, microstep, round}` would interleave + rounds. **That claim is false for the worked example**: macrostep 2's + stamps are `(2,0,0) x3`, `(2,1,0) x3`, `(2,1,1) x3`, and both keys + produce the identical order, because `microstep` never decreases as + `round` increases within a macrostep. Whether it *can* decrease is not + something this repo can settle - the interpreter is upstream and + read-only from here (`.claude/wurk/codebase.md`). Two-level bucketing + sidesteps the question entirely: round 1's messages cannot land inside + round 0's list no matter what microstep they carry. `seq` remains the + final within-bucket tiebreak and is itself a producer stamp + (`docs/wire-format.md:84-89`), not arrival order. + +2. **The grouping is macrostep -> round -> messages, and a round is a + bucket rather than a run of messages after a dequeue.** The bead names + `(macrostep, round)` as the key and the wire format calls it "the + timeline key" (`docs/wire-format.md:91-94`). Bucketing is also + idempotent under shuffling, which is what makes criterion 1 of the + desired end state testable. + +3. **A round with no `trace.event_dequeued` is a normal row with + `event: nil`, not a special case.** The initialize burst and every + eventless (NULL) round are the same shape: work happened, no event + triggered it. `trace.transitions_selected` distinguishes them further + by omitting its own `event` key on an eventless round + (`docs/wire-format.md:439`), which the renderer can show as + "(eventless)". + +4. **`effect.*` messages attach to the macrostep, except + `effect.budget_exhausted`, which attaches to its round.** Eight of the + nine carry no `round` (`docs/wire-format.md:57-69`), and inferring one + from `microstep` would be a guess this repo's own wire format + explicitly refuses to bake in ("inventing a value on the wire ahead of + the propagation work would be a guess", + `docs/wire-format.md:64-66`). Placing those one level up is honest and + forward-compatible. + + `effect.budget_exhausted` is the exception the format names outright - + "the one core effect that does carry `round`" + (`docs/wire-format.md:546-547`), passed through at + `lib/statifier_ui/trace/normalizer.ex:275`. It goes in its round + bucket, on a dedicated `Round.budget_exhausted` field, because a round + that blew ADR-0019's budget is precisely the round a reader is looking + for, and burying it in a macrostep-level list would hide the reason + that macrostep never reached quiescence. The routing rule is therefore + "route by whether `round` is present", not "route by type prefix" - + which is also what makes open question 2's later propagation a pure + data change with no code change here. + +5. **`build/1` returns `{:ok, t()} | {:error, {:mixed_sessions, [id]}}`.** + Errors are values in this repo (`CLAUDE.md`, Conventions), and merging + two sessions' stamps onto one timeline is exactly what the Ordering + section forbids. Trade-off: a two-line call site instead of one. Cheap, + and it names the ADR-0050 case rather than corrupting the log. + +6. **Labels are derived from `session.start`, separately from `build/1`.** + The fold stays a pure regrouping with no interpretation; the resolver + is a second, independently testable module that degrades to bare + indexes when the stream was joined late and carries no manifest. This + also keeps phase 1 and phase 2 independent. + +## Phase 1: The log model + +### Overview + +Fold a message list into an ordered, grouped model. No labels, no +rendering, no `Kino`. + +### Changes Required: + +#### 1. Round struct + +**File**: `lib/statifier_ui/event_log/round.ex` +**Changes**: New public struct for one `(macrostep, round)` bucket. + +```elixir +defmodule StatifierUI.EventLog.Round do + @moduledoc """ + One `(macrostep, round)` bucket of the log ... + """ + + @type t :: %__MODULE__{ + macrostep: non_neg_integer(), + round: non_neg_integer(), + event: map() | nil, + from: String.t() | nil, + cause: map() | nil, + eventless?: boolean(), + t_indexes: [non_neg_integer()] | nil, + exited: [non_neg_integer()], + entered: [non_neg_integer()], + content: [%{owner: map(), c_indexes: [non_neg_integer()]}], + configuration: [non_neg_integer()] | nil, + done: map() | nil, + budget_exhausted: map() | nil, + messages: [Message.t()] + } + + @enforce_keys [:macrostep, :round] + defstruct [ + :macrostep, :round, :event, :from, :cause, :t_indexes, + :configuration, :done, :budget_exhausted, + eventless?: false, exited: [], entered: [], content: [], messages: [] + ] +end +``` + +Notes for the implementer: + +- `exited` and `entered` concatenate every `trace.exit_set`/ + `trace.entry_set` in the round in sort order, **preserving each + message's own order** (ADR-0011). Never `Enum.sort/1` these. +- `t_indexes` is `nil` when the round carried no + `trace.transitions_selected`, and `[]` when it carried one that selected + nothing - a real distinction the schema draws + (`docs/wire-format.md:438`). +- `eventless?` is `true` when a `trace.transitions_selected` in this round + omitted its `event` key; `false` otherwise. It is not the negation of + `event == nil`, because a round can have neither message. +- `cause` is `event["cause"]` when present, hoisted so a renderer does not + have to reach into the event object. +- `configuration` comes from `trace.macrostep_stable`; ADR-0044 allows at + most one per `(macrostep, round)`, and if a stream nonetheless carries + two, the last in sort order wins. +- `budget_exhausted` holds the payload of an `effect.budget_exhausted` + that landed in this round - the one core effect carrying a `round` + (`docs/wire-format.md:546-547`). A round with a non-`nil` + `budget_exhausted` and a `nil` `configuration` is the "blew the budget + before quiescence" case ADR-0019 describes, and the renderer says so. + +#### 2. Macrostep struct + +**File**: `lib/statifier_ui/event_log/macrostep.ex` +**Changes**: New public struct grouping rounds plus this macrostep's +`effect.*` messages. + +```elixir +@type t :: %__MODULE__{ + macrostep: non_neg_integer(), + rounds: [Round.t()], + effects: [Message.t()], + configuration: [non_neg_integer()] | nil, + event: map() | nil + } +``` + +`configuration` is the configuration of the **last** round that carried a +`trace.macrostep_stable` - ADR-0044's "last-arriving one is that +macrostep's quiescence". `event` is the first round's `event`, so a +collapsed macrostep can be summarized by what triggered it (`nil` for the +initialize burst). `effects` are this macrostep's round-less messages in +`{microstep, seq}` order - every `effect.*` except an +`effect.budget_exhausted`, which carries a `round` and goes to its round +instead (decision 4). + +#### 3. The fold + +**File**: `lib/statifier_ui/event_log.ex` +**Changes**: New module with the log struct and `build/1`. + +```elixir +@type t :: %__MODULE__{ + session: String.t() | nil, + macrosteps: [Macrostep.t()], + session_messages: [Message.t()], + truncated?: boolean() + } + +@spec build([Message.t()]) :: {:ok, t()} | {:error, {:mixed_sessions, [String.t()]}} +def build(messages) +``` + +Algorithm: + +1. Collect the distinct `session` values. More than one -> `{:error, + {:mixed_sessions, Enum.sort(ids)}}`. Empty list -> an empty log with + `session: nil`. +2. `truncated?` is `true` when the lowest `seq` in the list is greater + than `0` - the `Buffer` drop signal + (`lib/statifier_ui/trace/buffer.ex:12-16`). +3. Partition by `type` prefix: `"session."` into `session_messages` + (sorted by `seq`); everything else into the stamped set. +4. Group the stamped set by `macrostep`, and emit macrosteps in ascending + `macrostep` order. +5. Inside a macrostep, **route by whether `round` is present, not by type + prefix** (decision 4): a message with a non-`nil` `round` goes to that + round's bucket, a message with `round: nil` goes to `effects`. This + puts every `trace.*` and every `effect.budget_exhausted` in a round, + and the other eight `effect.*` types in `effects`. +6. Emit round buckets in ascending `round` order. Sort each bucket's + messages, and `effects`, by `{microstep, seq}`. +7. Reduce each round bucket into a `%Round{}` by message type. + +Every `defp` carries a `@spec` (repo convention, confirmed in +`lib/statifier_ui/diagram.ex`). + +Step 7 is the one place Credo `strict: true` could bite on cyclomatic +complexity. Write it as a multi-clause `defp put_message(%Round{}, %Message{type: "trace.event_dequeued"} = m)` +dispatch - one clause per `trace.*` type, one for +`"effect.budget_exhausted"`, plus a catch-all that only appends to +`messages` - rather than a `case` over `message.type`. That +keeps each clause trivial, makes an unhandled type a visible fall-through +instead of a silent one, and mirrors how +`lib/statifier_ui/trace/normalizer.ex:170-182` dispatches. + +#### 4. Tests + +**File**: `test/statifier_ui/event_log_test.exs` +**Changes**: New test file, `use ExUnit.Case, async: true`, describe +blocks named `build/1 - `. Messages are built from `%Message{}` +struct literals - the same "testable from a struct literal" discipline the +normalizer's moduledoc states. + +Cover: + +- The worked example's fifteen messages + (`docs/wire-format.md:775-791`) transcribed as literals: two macrosteps, + macrostep 1 with rounds 0 and 1 and no dequeued event, macrostep 2 with + round 0 carrying `go`/`t_indexes: [0]`/`exited: [1]`/`entered: [2]` and + round 1 carrying the quiescence `configuration: [0, 2]`. +- **Shuffle invariance**: `build(msgs) == build(Enum.shuffle(msgs))` over + that same list, asserted across several shuffles with a fixed seed. +- **Exit order is preserved**: a round whose `trace.exit_set` carries + `[3, 2, 1]` yields `exited: [3, 2, 1]`, not `[1, 2, 3]` (ADR-0011). +- **Rounds do not interleave across microsteps**: a macrostep whose round + 0 spans microsteps 0 and 1 while round 1 sits at microstep 1 yields + rounds in `[0, 1]` order with round 0 holding *both* of its microsteps' + messages. Add a second case a flat `{macrostep, microstep, round}` sort + would get wrong and two-level bucketing cannot: a round 1 message + stamped at a *lower* microstep than a round 0 message in the same + macrostep still lands in round 1's bucket. The engine may never emit + that shape, but the test pins that the model does not depend on it - + which is the whole reason decision 1 buckets rather than flat-sorts. +- `t_indexes` is `nil` with no selection message, `[]` with an empty one. +- `eventless?` is `true` for a `trace.transitions_selected` with no + `event` key. +- A `trace.event_dequeued` carrying an event with a `cause` hoists it to + `Round.cause`. +- Round-less `effect.*` (an `effect.log` and an `effect.send`) land in + `Macrostep.effects` in `{microstep, seq}` order and in no round's + `messages`. +- **`effect.budget_exhausted` lands in its round**, not in + `Macrostep.effects`: a message stamped `macrostep: 3, round: 12` with a + `budget`/`configuration`/`pending_internal_events` payload yields + `Round.budget_exhausted` on round 12 of macrostep 3, and that round's + `configuration` stays `nil` because no `trace.macrostep_stable` + followed. +- Two `trace.macrostep_stable` in one macrostep at different rounds: the + macrostep's `configuration` is the later round's. +- `session.*` land in `session_messages`; a `session.start` at `seq: 0` + gives `truncated?: false`, a list starting at `seq: 7` gives `true`. +- Mixed sessions return `{:error, {:mixed_sessions, ["a", "b"]}}`. +- The empty list returns an empty log. +- **Nothing is dropped**: the count of messages across + `session_messages`, every `Macrostep.effects`, and every + `Round.messages` equals the input count, asserted over the worked + example. + +### Success Criteria: + +#### Automated Verification: +- [x] `mix quality` exits zero with no `○` line other than the two + permanent ones (`:gettext not installed`, `:sobelow not installed`) +- [x] `mix test test/statifier_ui/event_log_test.exs` passes +- [x] `ls lib/statifier_ui/event_log.ex lib/statifier_ui/event_log/round.ex lib/statifier_ui/event_log/macrostep.ex` finds all three +- [x] `mix xref callers Kino` lists no `StatifierUI.EventLog*` module - + the compiler's own answer to "does this code touch the optional + dep", which a `grep` cannot give because it cannot tell a call from + a moduledoc mention (verified today: the command prints nothing on + this tree) +- [x] `mix quality --format json --report -` reports the doctor stage green (moduledoc coverage stays at 100%) + +#### Manual Verification: +- [ ] The round grouping reads correctly against a chart with a genuine + multi-round macrostep (an internal `` chain), not only the + two-state worked example +- [ ] `Macrostep.event` is a useful collapsed summary in practice, not a + confusing one for macrosteps that dequeue more than one event +- [ ] No regressions in the existing trace tests + +**Implementation Note**: Use `mix quality --profile loop` between edits; +run the full `mix quality` as the phase gate. In interactive execution, +pause here for the human to confirm the manual testing before moving to +the next phase. In looped (`--loop`) execution, this phase's Automated +Verification gates advancement automatically (via `/wurk:commit --auto`), +and Manual Verification items are deferred and surfaced once at the end +instead of blocking here. + +--- + +## Phase 2: Index labels from the manifest + +### Overview + +Turn the wire format's integers into strings a human can read, using only +the `session.start` tables already on the stream. Independently useful and +independently testable: nothing in phase 1 changes. + +### Changes Required: + +#### 1. The resolver + +**File**: `lib/statifier_ui/event_log/labels.ex` +**Changes**: New module. + +```elixir +@type t :: %__MODULE__{ + states: %{optional(non_neg_integer()) => map()}, + transitions: %{optional(non_neg_integer()) => map()}, + contents: %{optional(non_neg_integer()) => map()}, + data: %{optional(non_neg_integer()) => map()} + } + +@spec empty() :: t() +@spec from_manifest(Message.t()) :: t() +@spec from_log(EventLog.t()) :: t() + +@spec state(t(), non_neg_integer()) :: String.t() +@spec states(t(), [non_neg_integer()]) :: String.t() +@spec transition(t(), non_neg_integer()) :: String.t() +@spec content(t(), non_neg_integer()) :: String.t() +@spec data(t(), non_neg_integer()) :: String.t() +@spec origin(t(), map()) :: String.t() +@spec owner(t(), map()) :: String.t() +``` + +Resolution rules, all derived from +`lib/statifier_ui/trace/manifest.ex:120-211`: + +- `state/2`: the entry's `"id"` when present; `""` for the entry + whose `"kind"` is `"scxml"`; otherwise `"#"`. Unknown index -> + `"#"`. +- `transition/2`: `": -> "`, e.g. + `"go: a -> b"`. `"events"` is an *array of arrays of strings* - one + dot-split token list per whitespace-separated event descriptor + (`docs/wire-format.md:285`) - so `[["done","state","s1"], ["go"]]` + renders `"done.state.s1 go"`: inner lists join with `"."`, outer with + `" "`. An empty `"events"` list renders `"(eventless)"` in that + position. Targets join with `", "`; an empty target list renders + `"(targetless)"`. +- `content/2`: `"@:"`, e.g. `"raise@4:9"`, + from the entry's `"kind"` and `"location"`. +- `data/2`: the entry's `"id"`, otherwise `"#"`. Needed because the + `"data"` origin kind carries a `d_index` + (`docs/wire-format.md:636`), and `session.start` gained its `data` + table in `sui-o5c` + (`lib/statifier_ui/trace/manifest.ex:196-211`). +- `origin/2` and `owner/2` dispatch on the `"kind"` key over the eight + origin shapes (`docs/wire-format.md:625-645`) and the five owner shapes + (`docs/wire-format.md:646-660`), delegating to the four index resolvers + above. An unrecognized `"kind"` renders the kind string itself rather + than raising - a forward-compatible degradation, matching the + format's additive-field rule. +- `from_log/1` finds the first `session.start` in the log's + `session_messages` and delegates to `from_manifest/1`; with none, it + returns `empty/0`. + +`empty/0` is not a degenerate path to be tolerated - it is the late-attach +case (`StatifierUI.Trace.Subscriber` emits no manifest when it joins a +running session without one) and must render every label as a bare +`"#"`. + +#### 2. Tests + +**File**: `test/statifier_ui/event_log/labels_test.exs` +**Changes**: New test file. Build a real manifest with +`StatifierUI.Trace.Manifest.build/3` over the two-state heredoc chart from +`docs/wire-format.md:757-763` (compiled with +`StatifierUI.Test.Support.Trace.SessionCase.compile!/1`), so the tables +under test are the ones the producer actually emits, and assert on the +resolved strings. + +Cover: named state, the root `scxml` state, an anonymous state, an +out-of-range index, a transition with a multi-token dot-split descriptor +(`[["done","state","s1"]]` -> `"done.state.s1"`), an eventless +transition, a targetless transition, a content node's kind and line, a +`` element's id, each of the eight origin kinds +(`docs/wire-format.md:630-639`) and five owner kinds +(`docs/wire-format.md:653-659`), an unrecognized kind, and `empty/0` +returning `"#3"` for every index resolver. + +### Success Criteria: + +#### Automated Verification: +- [x] `mix quality` exits zero with no new `○` line +- [x] `mix test test/statifier_ui/event_log/labels_test.exs` passes +- [x] `mix quality --format json --report -` reports the doctor stage + green, holding 100% moduledoc coverage and the 75% doc/spec floors + in `.doctor.exs` +- [x] `mix xref callers Kino` lists no `StatifierUI.EventLog.Labels` + +#### Manual Verification: +- [ ] Transition labels read naturally for a transition with multiple + event descriptors and multiple targets +- [ ] Content labels point at the right source line when checked against + the chart by eye + +**Implementation Note**: Use `mix quality --profile loop` between edits; +run the full `mix quality` as the phase gate. In interactive execution, +pause here for the human to confirm the manual testing before moving to +the next phase. In looped (`--loop`) execution, this phase's Automated +Verification gates advancement automatically (via `/wurk:commit --auto`), +and Manual Verification items are deferred and surfaced once at the end +instead of blocking here. + +--- + +## Phase 3: Markdown rendering + +### Overview + +Render the model as Markdown a host hands to `Kino.Markdown.new/1`, with +one collapsible block per macrostep and one table row per round. Add the +changelog fragment for the whole bead. + +### Changes Required: + +#### 1. The renderer + +**File**: `lib/statifier_ui/event_log/markdown.ex` +**Changes**: New module. + +```elixir +@type opt :: + {:labels, Labels.t()} + | {:collapsible, boolean()} + | {:open, :last | :all | :none | [non_neg_integer()]} + +@spec render(EventLog.t(), [opt()]) :: String.t() +def render(%EventLog{} = log, opts \\ []) +``` + +Defaults: `:labels` is `Labels.from_log(log)`, `:collapsible` is `true`, +`:open` is `:last`. + +Output structure: + +- A header line naming the session, and a warning line when + `truncated?` is `true`, phrased in terms of the buffer's drop + semantics ("earliest messages dropped; log starts at seq N"). +- One block per macrostep. With `collapsible: true` the block is + `
` / ``, with `open` on the macrosteps the `:open` + option selects; with `collapsible: false` it is a + `### Macrostep ` heading and no HTML. The summary carries the + macrostep number, its triggering event name (or `initialize` for the + burst), its round count, and its quiescence configuration rendered + through `Labels.states/2`. +- Inside the block, a Markdown table with the columns + `round | event | selected | exited | entered`. An eventless round + renders `(eventless)` in the event column. The `selected` column + distinguishes the two absences edge case 4 names: `t_indexes: nil` + (no selection message ran) renders `-`, `t_indexes: []` (selection ran + and chose nothing) renders `none`. Exited and entered render through + `Labels.states/2` **in list order**, never re-sorted (ADR-0011). +- A raised-cause note under the table for each round whose `cause` is + present: `raised by at (macrostep M, round R)`. +- Executed content listed under the table as + `: , ...` for each entry. +- A budget note under the table for any round whose `budget_exhausted` is + set: `budget exhausted after rounds; internal events still + pending` - the ADR-0019 case, shown at the round it happened rather than + in the macrostep list. +- The macrostep's `effects` listed last, one line each, as + `` plus the payload keys that carry a human-visible value - + `label`/`value` for `effect.log`, `event`/`target` for `effect.send` + and `effect.send_delayed`, `invoke_id` for the invoke family, + `send_id` for `effect.cancel`. Under a heading that says these carry no + `round` and are therefore listed per macrostep, so a reader is not + left guessing why they are not in the table. `effect.budget_exhausted` + never appears here - it has a round and is rendered above. +- A footer rendering `session.halted` / `session.terminated` / + `session.unroutable` when present. + +The module references `Kino` in moduledoc prose only. + +#### 2. Changelog fragment + +**File**: `changelog.d/sui-t36.5.md` +**Changes**: New file, one `### Added` heading, matching +`changelog.d/README.md`'s rules: standard Keep-a-Changelog heading, one +line per change in present tense describing the effect on the user, no +nested bullets. + +```markdown +### Added + +- `StatifierUI.EventLog.build/1` folds a trace message stream into a log + grouped by `(macrostep, round)`, ordered by the producer's stamps rather + than arrival, and `StatifierUI.EventLog.Markdown.render/2` renders it as + collapsible Markdown for `Kino.Markdown`, with wire-format indexes + resolved to state and transition names by + `StatifierUI.EventLog.Labels`. +``` + +#### 3. Tests + +**File**: `test/statifier_ui/event_log/markdown_test.exs` +**Changes**: New test file. Assertion style, restated in full so it does +not depend on reading `diagram_test.exs`: split the rendered string into +trimmed lines, then assert with `assert "" in lines` and +with index comparisons for ordering. Never a whole-document golden string, +never a line count, and never a hardcoded state index - resolve indexes +through the machine and interpolate them, so the tests do not depend on +document-order numbering. + +Cover: + +- The worked-example log renders a table row for macrostep 2 round 0 + containing `go`, the resolved transition label, `a`, and `b`. +- Exit order survives rendering: a round with `exited: [3, 2, 1]` renders + those labels in that order in one cell. +- `collapsible: true` emits `
` and `
` per macrostep and + exactly one `open` attribute under the default `:open` of `:last`; + `open: :all` opens every one; `open: :none` opens none; + `open: [1]` opens only macrostep 1. +- `collapsible: false` emits no `
` anywhere and a + `### Macrostep 1` heading instead. +- Without a `session.start` the same log renders `#1`/`#2` rather than + crashing. +- A truncated log renders the drop warning; a complete one does not. +- A round with a cause renders the raised-by note with the cause's own + `(macrostep, round)`, not the dequeue's. +- `effect.log` appears under the macrostep with its label and value. +- The initialize burst renders `initialize` in its summary and no event + in its round rows. + +### Success Criteria: + +#### Automated Verification: +- [x] `mix quality` exits zero with no new `○` line, and the coverage + stage stays above the 80% floor in `coveralls.json` +- [x] `mix test test/statifier_ui/event_log/markdown_test.exs` passes +- [x] `ls changelog.d/sui-t36.5.md` finds the fragment +- [x] `mix xref callers Kino` lists no `StatifierUI.EventLog.Markdown` - + the renderer names `Kino.Markdown` in prose but must never call it +- [x] `mix test` passes the whole suite, including + `test/statifier_ui/trace/golden_trace_test.exs` unchanged + +#### Manual Verification: +- [ ] Pasted into a Livebook cell as + `Kino.Markdown.new(StatifierUI.EventLog.Markdown.render(log))`, the + `
` blocks actually collapse and expand. **If Livebook's + markdown sanitizer strips them, the fallback is + `collapsible: false`** and the finding is filed for `sui-t36.8` - + see open question 1 +- [ ] The log is genuinely readable for a chart with nested states and an + internal `` chain, not just legible +- [ ] The `effect.*` per-macrostep section does not read as a bug to + someone who does not know the `round` gap + +**Implementation Note**: Use `mix quality --profile loop` between edits; +run the full `mix quality` as the phase gate. In interactive execution, +pause here for the human to confirm the manual testing before finishing. +In looped (`--loop`) execution, this phase's Automated Verification gates +advancement automatically (via `/wurk:commit --auto`), and Manual +Verification items are deferred and surfaced once at the end instead of +blocking here. + +--- + +## Testing Strategy + +### Unit Tests: + +- `test/statifier_ui/event_log_test.exs` - the fold, from `%Message{}` + struct literals. The worked example + (`docs/wire-format.md:775-791`) is the primary fixture because it is + already the byte-exact reference the golden test pins + (`test/support/trace/two_state.jsonl`), so a change in producer behavior + surfaces in both places at once. +- `test/statifier_ui/event_log/labels_test.exs` - resolution, against a + real `StatifierUI.Trace.Manifest.build/3` output rather than a + hand-written table, so the test cannot drift from the manifest the + producer emits. +- `test/statifier_ui/event_log/markdown_test.exs` - rendering, asserted on + produced structure per `CLAUDE.md` ("Rendering is verified by what it + renders. Assert on the produced structure, not on a line count"). + +### Key edge cases, restated + +1. The initialize burst has no dequeued event + (`docs/wire-format.md:778-781`). +2. A round spans microsteps; rounds must not interleave + (`docs/wire-format.md:782-790`). +3. Exit order is descending and meaningful (ADR-0011). +4. `t_indexes: nil` (no message) is not `t_indexes: []` (selected + nothing). +5. More than one `trace.macrostep_stable` per macrostep is legal; the last + is quiescence (ADR-0044). +6. `effect.*` carry no `round` (`docs/wire-format.md:57-69`) - except + `effect.budget_exhausted`, which does (`:546-547`) and therefore + belongs to a round. +7. A late-attached subscriber's stream has no `session.start`, so labels + degrade to bare indexes. +8. A `Buffer` at capacity dropped the head, so `seq` does not start at 0. +9. An ADR-0050 invoke tree puts more than one session on one mailbox. + +### Manual Testing Steps: + +1. `iex -S mix`, compile a nested chart with an internal `` chain, + start a session through + `StatifierUI.Test.Support.Trace.SessionCase.start_early!/3`, drive one + external event, then + `{:ok, log} = sub |> StatifierUI.Trace.Subscriber.messages() |> StatifierUI.EventLog.build()`. +2. `IO.puts(StatifierUI.EventLog.Markdown.render(log))` and read the + result: are the rounds in the order the run actually took, are the + state names right, is the raised-cause note pointing at the raiser? +3. Repeat with `collapsible: false` and confirm the plain-heading form is + equally readable. +4. Open a Livebook, add `statifier_ui` as a path dependency, and render + the same log through `Kino.Markdown.new/1`. Confirm the `
` + blocks collapse (open question 1). +5. Start a subscriber with a small `capacity`, drive enough events to drop + messages, and confirm the truncation warning appears. +6. Attach late (`SessionCase.start_late!/3`) and confirm the log renders + with bare `#n` indexes rather than crashing. + +## Open Questions + +Each carries the call this plan made, so nothing here blocks +implementation. + +1. **Does Livebook render `
`/`` inside + `Kino.Markdown`?** `Kino.Markdown` is a plain string carrier + (`deps/kino/lib/kino/markdown.ex`), so the answer lives in Livebook's + own markdown sanitizer, which is not vendored here and could not be + checked without running a notebook. Gates nothing in this plan. + **Call made**: assume yes, and hedge - `:collapsible` defaults to + `true` but `collapsible: false` produces an equally complete + plain-heading rendering, so a sanitizer that strips the HTML costs one + option flag at the `sui-t36.8` call site rather than a rewrite. Phase + 3's first manual-verification item is where this gets settled. + +2. **Who owns propagating the engine's `round` onto `effect.*` + (statifier ADR-0046; cited upstream as `st-xb2b` in the bpb research + and as `st-nbmj` at `docs/wire-format.md:854-856` - reconcile the two + when the bead is filed)?** `docs/wire-format.md:63` and `:856` both + name `sui-t36.5`, and + `docs/research/260819-sui-bpb-statifier-and-predicator-9-refresh-surface.md:438` + maps the change onto this bead - but its landing sites are + `lib/statifier_ui/trace/normalizer.ex`, its tests, and the `effect.*` + schemas, all plumbing-layer files, and this bead's own description says + nothing about them. The operator's scope constraint for this plan + forbids extending the wire format. Gates nothing. + **Call made**: out of scope here. The work needs its own bead against + the trace layer, and `docs/wire-format.md:63` and `:856` should be + repointed at it so the spec does not carry a forward reference to a + closed pane bead. Decision 4 keeps the model forward-compatible: when + `round` arrives on `effect.*`, **no code here changes at all**: step 5 + of the fold routes on whether `round` is present, so those messages + move from `Macrostep.effects` into their round bucket the moment the + producer stamps them, exactly as `effect.budget_exhausted` already + does today. + +3. **Should `build/1` split a multi-session list instead of refusing + it?** ADR-0050 makes a mixed list reachable, and a pane that showed an + invoke tree's child sessions inline would be genuinely useful. Gates + nothing. + **Call made**: refuse with `{:error, {:mixed_sessions, ids}}` + (decision 5). Splitting produces a map of logs, which is a composition + shape, and composition is `sui-t36.8`'s contract to define. Reversible: + a later `build_by_session/1` can wrap `build/1` without changing it. + +4. **Should `effect.log` be rendered inline in the round table rather + than in a per-macrostep section?** A user's own `` output is + arguably the most useful line in a debugging log, and burying it below + the table costs it prominence. Gates nothing. + **Call made**: per-macrostep section, because placing it in a round + would require inventing the round it belongs to (open question 2). + The section is labelled with why, so the placement reads as a + consequence of the format rather than an oversight. Revisit once + question 2's work lands. + +## Deferred Manual Verification + +Every manual item above, collected for a looped run to surface at the end: + +- [ ] Phase 1: round grouping reads correctly against a chart with a + genuine multi-round macrostep (internal `` chain) +- [ ] Phase 1: `Macrostep.event` is a useful collapsed summary for a + macrostep that dequeues more than one event +- [ ] Phase 1: no regressions in the existing trace tests +- [ ] Phase 2: transition labels read naturally with multiple event + descriptors and multiple targets +- [ ] Phase 2: content labels point at the right source line +- [ ] Phase 3: `
` blocks collapse in a real Livebook cell + (open question 1); if not, `collapsible: false` and file for + `sui-t36.8` +- [ ] Phase 3: the log is readable for a nested chart with an internal + `` chain +- [ ] Phase 3: the `effect.*` per-macrostep section does not read as a bug + +## References + +- Bead: `sui-t36.5` (parent `sui-t36`; blocked-by `sui-t36.3` and + `sui-bpb`, both closed; blocks `sui-t36.8`) +- Wire format: `docs/wire-format.md` - the envelope (`:37-80`), ordering + (`:82-127`), the nine `trace.*` schemas (`:394-505`), origins + (`:625-645`), owners (`:646-660`), the `session.*` types (`:661-742`), + the worked example (`:743-807`) +- ADR-0011 (`docs/adr/0011-exit-and-entry-sets-are-sequences.md`) - exit + and entry sets are sequences; the record names this pane as the reason +- ADR-0005 (`docs/adr/0005-language-neutral-trace-wire-format.md`) - the + format this pane consumes without extending +- ADR-0004 (`docs/adr/0004-one-package-with-optional-integrations.md`) - + optional deps enforced at compile time +- statifier ADR-0044 (`st-r6l9`) - monotone delivery, terminal halt, one + `trace.macrostep_stable` per `(macrostep, round)` +- statifier ADR-0046 (`st-xb2b` per the bpb research, `st-nbmj` per + `docs/wire-format.md:854`) - `round` on core effects; not consumed + here, see open question 2 +- Source modules: `lib/statifier_ui/trace/message.ex`, + `lib/statifier_ui/trace/normalizer.ex`, + `lib/statifier_ui/trace/manifest.ex`, + `lib/statifier_ui/trace/buffer.ex:12-16`, + `lib/statifier_ui/trace/subscriber.ex:194-195` +- Convention (**not in this worktree** - on branch + `sui-t36.4-config-renderer`, PR #28; every rule borrowed from them is + restated inline in this plan): `lib/statifier_ui/diagram.ex` - a pure + pane module at the top of `lib/statifier_ui/`, `@spec` on every private + function, `Kino` in moduledoc prose only; + `test/statifier_ui/diagram_test.exs` for the membership-assertion test + style; `changelog.d/sui-t36.4.md` for the fragment shape +- statifier ADR-0019 - the round budget behind + `effect.budget_exhausted` +- Prior plan: `docs/plans/260817-sui-t36.3-session-subscriber-and-trace-normalizer.md` + - the producer this pane reads, and this document's section conventions +- Test support: `test/support/trace/session_case.ex`, + `test/support/trace/two_state.jsonl` diff --git a/lib/statifier_ui/event_log.ex b/lib/statifier_ui/event_log.ex new file mode 100644 index 0000000..ffe0756 --- /dev/null +++ b/lib/statifier_ui/event_log.ex @@ -0,0 +1,200 @@ +defmodule StatifierUI.EventLog do + @moduledoc """ + Folds a `[StatifierUI.Trace.Message.t()]` (in any order) into a readable + log keyed by `(macrostep, round)`, per the sui-t36.5 plan. + + `build/1` is a pure regrouping, not an interpretation: it decides where + each message goes, never what it means. Ordering comes entirely from the + stamps the producer put on each message, never from arrival order or + `seq` alone - `docs/wire-format.md:91-94` names `(macrostep, round)` as + the format's own timeline key. Feeding `build/1` the same messages in a + different order returns an identical struct. + + ## Bucketing, not sorting + + Buckets are ordered by `(macrostep, round)`, and only *within* a bucket + do messages get a further order, by `{microstep, seq}`. This is two + levels, not one flat `{macrostep, round, microstep, seq}` sort key: a + round is a bucket a message either belongs to or does not, so a message + stamped at a lower `microstep` than an earlier round's messages still + lands in its own round's bucket rather than sorting ahead of them. `seq` + is itself a producer stamp (`docs/wire-format.md:84-89`), so using it as + the final tiebreak is still ordering from the stamps. + + ## Routing `effect.*` messages + + Every `trace.*` message carries a `round`; eight of the nine `effect.*` + types do not and are routed to their macrostep's `effects` instead. The + ninth, `effect.budget_exhausted`, is "the one core effect that does carry + `round`" (`docs/wire-format.md:546-547`), so it is routed to its round + bucket instead of `effects`. The routing rule is therefore "does this + message carry a `round`", never a check against the `"effect."` prefix - + which is also what makes a future propagation of `round` onto the + remaining `effect.*` types (tracked separately, not part of this plan) a + pure data change that needs no change here. + + ## Errors + + `docs/wire-format.md:96-102` forbids merging two sessions' stamps onto + one timeline (statifier ADR-0050 lets one mailbox carry an invoke tree of + related sessions), so `build/1` refuses a message list naming more than + one session id rather than corrupting the log. + """ + + alias StatifierUI.EventLog.Macrostep + alias StatifierUI.EventLog.Round + alias StatifierUI.Trace.Message + + @type t :: %__MODULE__{ + session: String.t() | nil, + macrosteps: [Macrostep.t()], + session_messages: [Message.t()], + truncated?: boolean() + } + + defstruct session: nil, macrosteps: [], session_messages: [], truncated?: false + + @doc """ + Folds `messages` into an `t()`. + + Returns `{:error, {:mixed_sessions, sorted_ids}}` when `messages` names + more than one distinct session. An empty list returns an empty, default + log with `session: nil`. + """ + @spec build([Message.t()]) :: + {:ok, t()} | {:error, {:mixed_sessions, [String.t()]}} + def build([]), do: {:ok, %__MODULE__{}} + + def build(messages) do + with {:ok, session} <- resolve_session(messages) do + {session_messages, stamped} = Enum.split_with(messages, &session_message?/1) + + log = %__MODULE__{ + session: session, + session_messages: Enum.sort_by(session_messages, & &1.seq), + truncated?: truncated?(messages), + macrosteps: build_macrosteps(stamped) + } + + {:ok, log} + end + end + + # -- Session resolution ----------------------------------------------------- + + @spec resolve_session([Message.t()]) :: + {:ok, String.t() | nil} | {:error, {:mixed_sessions, [String.t()]}} + defp resolve_session(messages) do + case messages |> Enum.map(& &1.session) |> Enum.uniq() |> Enum.sort() do + [] -> {:ok, nil} + [session] -> {:ok, session} + ids -> {:error, {:mixed_sessions, ids}} + end + end + + @spec truncated?([Message.t()]) :: boolean() + defp truncated?(messages) do + messages + |> Enum.map(& &1.seq) + |> Enum.min() + |> Kernel.>(0) + end + + @spec session_message?(Message.t()) :: boolean() + defp session_message?(%Message{type: "session." <> _rest}), do: true + defp session_message?(%Message{}), do: false + + # -- Macrostep grouping ------------------------------------------------- + + @spec build_macrosteps([Message.t()]) :: [Macrostep.t()] + defp build_macrosteps(stamped) do + stamped + |> Enum.group_by(& &1.macrostep) + |> Enum.sort_by(fn {macrostep, _msgs} -> macrostep end) + |> Enum.map(fn {macrostep, msgs} -> build_macrostep(macrostep, msgs) end) + end + + @spec build_macrostep(non_neg_integer(), [Message.t()]) :: Macrostep.t() + defp build_macrostep(macrostep, msgs) do + {round_msgs, effect_msgs} = Enum.split_with(msgs, &(&1.round != nil)) + + rounds = build_rounds(macrostep, round_msgs) + effects = Enum.sort_by(effect_msgs, &{&1.microstep, &1.seq}) + + Macrostep.new(macrostep, rounds, effects) + end + + # -- Round grouping ------------------------------------------------------- + + @spec build_rounds(non_neg_integer(), [Message.t()]) :: [Round.t()] + defp build_rounds(macrostep, round_msgs) do + round_msgs + |> Enum.group_by(& &1.round) + |> Enum.sort_by(fn {round, _msgs} -> round end) + |> Enum.map(fn {round, msgs} -> build_round(macrostep, round, msgs) end) + end + + @spec build_round(non_neg_integer(), non_neg_integer(), [Message.t()]) :: Round.t() + defp build_round(macrostep, round, msgs) do + msgs + |> Enum.sort_by(&{&1.microstep, &1.seq}) + |> Enum.reduce(%Round{macrostep: macrostep, round: round}, &put_message(&2, &1)) + end + + # -- Reducing a round's messages into its Round fields ----------------- + + @spec put_message(Round.t(), Message.t()) :: Round.t() + defp put_message(round, %Message{type: "trace.event_dequeued", payload: payload} = m) do + event = payload["event"] + + %{ + round + | event: event, + from: payload["from"], + cause: cause_of(event), + messages: round.messages ++ [m] + } + end + + defp put_message(round, %Message{type: "trace.transitions_selected", payload: payload} = m) do + %{ + round + | t_indexes: payload["t_indexes"], + eventless?: not Map.has_key?(payload, "event"), + messages: round.messages ++ [m] + } + end + + defp put_message(round, %Message{type: "trace.exit_set", payload: payload} = m) do + %{round | exited: round.exited ++ payload["indexes"], messages: round.messages ++ [m]} + end + + defp put_message(round, %Message{type: "trace.entry_set", payload: payload} = m) do + %{round | entered: round.entered ++ payload["indexes"], messages: round.messages ++ [m]} + end + + defp put_message(round, %Message{type: "trace.content_executed", payload: payload} = m) do + item = %{owner: payload["owner"], c_indexes: payload["c_indexes"]} + %{round | content: round.content ++ [item], messages: round.messages ++ [m]} + end + + defp put_message(round, %Message{type: "trace.macrostep_stable", payload: payload} = m) do + %{round | configuration: payload["configuration"], messages: round.messages ++ [m]} + end + + defp put_message(round, %Message{type: "trace.done", payload: payload} = m) do + %{round | done: payload, messages: round.messages ++ [m]} + end + + defp put_message(round, %Message{type: "effect.budget_exhausted", payload: payload} = m) do + %{round | budget_exhausted: payload, messages: round.messages ++ [m]} + end + + defp put_message(round, m) do + %{round | messages: round.messages ++ [m]} + end + + @spec cause_of(map() | nil) :: map() | nil + defp cause_of(nil), do: nil + defp cause_of(event), do: event["cause"] +end diff --git a/lib/statifier_ui/event_log/labels.ex b/lib/statifier_ui/event_log/labels.ex new file mode 100644 index 0000000..8cbc4c2 --- /dev/null +++ b/lib/statifier_ui/event_log/labels.ex @@ -0,0 +1,239 @@ +defmodule StatifierUI.EventLog.Labels do + @moduledoc """ + Turns the wire format's integer indexes into strings a human can read, + using only the `session.start` tables already on the stream + (`lib/statifier_ui/trace/manifest.ex:120-211`). + + A `t()` is a flat lookup built once, from `from_manifest/1` or + `from_log/1`, and then consulted many times as an `EventLog` renders. + Every resolver takes the `t()` first so a renderer can partially apply + it (`&Labels.state(labels, &1)`) across a list of indexes. + + ## The late-attach degradation + + `empty/0` is not a stub - it is the late-attach case + (`StatifierUI.Trace.Subscriber` emits no `session.start` when it joins a + running session that already has one). With no manifest tables at all, + every resolver falls back to its bare `"#"` form, which is why + every resolver's "not found" branch and `empty/0`'s "always not found" + case are the same code path rather than two. + + ## Origins and owners + + `origin/2` and `owner/2` dispatch on the `"kind"` field of an origin or + owner object (`docs/wire-format.md:625-660`) and delegate to the four + index resolvers above. Both are total over the documented shapes: an + unrecognized `"kind"` renders the kind string itself rather than + raising, matching the format's additive-field rule - a client should + degrade gracefully in front of a newer producer, not crash. + """ + + alias StatifierUI.EventLog + alias StatifierUI.Trace.Message + + @type t :: %__MODULE__{ + states: %{optional(non_neg_integer()) => map()}, + transitions: %{optional(non_neg_integer()) => map()}, + contents: %{optional(non_neg_integer()) => map()}, + data: %{optional(non_neg_integer()) => map()} + } + + defstruct states: %{}, transitions: %{}, contents: %{}, data: %{} + + @doc "The late-attach case: no tables at all, so every index resolves to its bare `\"#\"` form." + @spec empty() :: t() + def empty, do: %__MODULE__{} + + @doc """ + Builds a `t()` from a `session.start` message's payload, keying each + table by its own index field (`"index"`, `"t_index"`, `"c_index"`, + `"d_index"`). + """ + @spec from_manifest(Message.t()) :: t() + def from_manifest(%Message{type: "session.start", payload: payload}) do + %__MODULE__{ + states: index_by(payload["states"], "index"), + transitions: index_by(payload["transitions"], "t_index"), + contents: index_by(payload["contents"], "c_index"), + data: index_by(payload["data"], "d_index") + } + end + + @doc """ + Finds the first `session.start` in `log`'s `session_messages` and + delegates to `from_manifest/1`. Returns `empty/0` when the log carries + none - the late-attach case. + """ + @spec from_log(EventLog.t()) :: t() + def from_log(%EventLog{session_messages: session_messages}) do + case Enum.find(session_messages, &(&1.type == "session.start")) do + nil -> empty() + message -> from_manifest(message) + end + end + + @doc """ + Resolves a state `index` to its `"id"` when present, `""` for + the synthesized root, or a bare `"#"` for an anonymous state or + an index this `t()` does not know about. + """ + @spec state(t(), non_neg_integer()) :: String.t() + def state(%__MODULE__{states: states}, index) do + case Map.get(states, index) do + %{"id" => id} -> id + %{"kind" => "scxml"} -> "" + _ -> bare_index(index) + end + end + + @doc "Resolves each of `indexes` through `state/2`, in list order, joined with `\", \"`." + @spec states(t(), [non_neg_integer()]) :: String.t() + def states(%__MODULE__{} = labels, indexes) do + Enum.map_join(indexes, ", ", &state(labels, &1)) + end + + @doc """ + Resolves a transition `index` to `": -> "`. + + `events` is an array of arrays of strings - one dot-split token list + per whitespace-separated event descriptor - so inner lists join with + `"."` and the outer list joins with `" "`; an empty `events` list + renders `"(eventless)"`. Targets join with `", "`; an empty target + list renders `"(targetless)"`. An unknown index renders `"#"`. + """ + @spec transition(t(), non_neg_integer()) :: String.t() + def transition(%__MODULE__{transitions: transitions} = labels, index) do + case Map.get(transitions, index) do + nil -> bare_index(index) + entry -> render_transition(labels, entry) + end + end + + @doc """ + Resolves a content `index` to `"@:"`. + An unknown index renders `"#"`. + """ + @spec content(t(), non_neg_integer()) :: String.t() + def content(%__MODULE__{contents: contents}, index) do + case Map.get(contents, index) do + nil -> bare_index(index) + entry -> render_content(entry) + end + end + + @doc ~S'Resolves a `` `index` to its `"id"`, otherwise `"#"`.' + @spec data(t(), non_neg_integer()) :: String.t() + def data(%__MODULE__{data: data}, index) do + case Map.get(data, index) do + %{"id" => id} -> id + _ -> bare_index(index) + end + end + + @doc """ + Resolves an origin object (`docs/wire-format.md:625-645`) by dispatching + on its `"kind"`. An unrecognized `"kind"` renders the kind string + itself. + """ + @spec origin(t(), map()) :: String.t() + def origin(%__MODULE__{} = labels, %{"kind" => "content"} = origin) do + "#{owner(labels, origin["owner"])}: #{content(labels, origin["c_index"])}" + end + + def origin(%__MODULE__{} = labels, %{"kind" => "state"} = origin) do + state(labels, origin["state_index"]) + end + + def origin(%__MODULE__{} = labels, %{"kind" => "transition"} = origin) do + transition(labels, origin["t_index"]) + end + + def origin(%__MODULE__{} = labels, %{"kind" => "data"} = origin) do + data(labels, origin["d_index"]) + end + + def origin(%__MODULE__{} = labels, %{"kind" => "donedata_param"} = origin) do + "#{state(labels, origin["state_index"])} donedata param #{origin["param_index"]}" + end + + def origin(%__MODULE__{} = labels, %{"kind" => "global_script"} = origin) do + content(labels, origin["index"]) + end + + def origin(%__MODULE__{} = labels, %{"kind" => "invoke"} = origin) do + "#{state(labels, origin["state_index"])} invoke##{origin["invoke_index"]}" + end + + def origin(%__MODULE__{} = labels, %{"kind" => "finalize"} = origin) do + "#{state(labels, origin["state_index"])} invoke##{origin["invoke_index"]} finalize" + end + + def origin(%__MODULE__{}, %{"kind" => kind}), do: kind + + @doc """ + Resolves an owner object (`docs/wire-format.md:646-660`) by dispatching + on its `"kind"`. An unrecognized `"kind"` renders the kind string + itself. + """ + @spec owner(t(), map()) :: String.t() + def owner(%__MODULE__{} = labels, %{"kind" => "onentry"} = owner) do + "#{state(labels, owner["state_index"])} onentry##{owner["ordinal"]}" + end + + def owner(%__MODULE__{} = labels, %{"kind" => "onexit"} = owner) do + "#{state(labels, owner["state_index"])} onexit##{owner["ordinal"]}" + end + + def owner(%__MODULE__{} = labels, %{"kind" => "transition"} = owner) do + transition(labels, owner["t_index"]) + end + + def owner(%__MODULE__{} = labels, %{"kind" => "finalize"} = owner) do + "#{state(labels, owner["state_index"])} invoke##{owner["invoke_index"]} finalize" + end + + def owner(%__MODULE__{} = labels, %{"kind" => "global_script"} = owner) do + content(labels, owner["index"]) + end + + def owner(%__MODULE__{}, %{"kind" => kind}), do: kind + + # -- table building ----------------------------------------------------- + + @spec index_by([map()], String.t()) :: %{optional(non_neg_integer()) => map()} + defp index_by(entries, key) do + Map.new(entries, &{&1[key], &1}) + end + + # -- rendering helpers ---------------------------------------------------- + + @spec bare_index(non_neg_integer()) :: String.t() + defp bare_index(index), do: "##{index}" + + @spec render_transition(t(), map()) :: String.t() + defp render_transition(labels, entry) do + events = render_events(entry["events"]) + source = state(labels, entry["source"]) + targets = render_targets(labels, entry["targets"]) + "#{events}: #{source} -> #{targets}" + end + + @spec render_events([[String.t()]]) :: String.t() + defp render_events([]), do: "(eventless)" + + defp render_events(events) do + Enum.map_join(events, " ", &Enum.join(&1, ".")) + end + + @spec render_targets(t(), [non_neg_integer()]) :: String.t() + defp render_targets(_labels, []), do: "(targetless)" + + defp render_targets(labels, targets) do + Enum.map_join(targets, ", ", &state(labels, &1)) + end + + @spec render_content(map()) :: String.t() + defp render_content(%{"kind" => kind, "location" => location}) do + "#{kind}@#{location["start_line"]}:#{location["start_column"]}" + end +end diff --git a/lib/statifier_ui/event_log/macrostep.ex b/lib/statifier_ui/event_log/macrostep.ex new file mode 100644 index 0000000..e69a4e7 --- /dev/null +++ b/lib/statifier_ui/event_log/macrostep.ex @@ -0,0 +1,56 @@ +defmodule StatifierUI.EventLog.Macrostep do + @moduledoc """ + One macrostep's worth of an `StatifierUI.EventLog.t()`: its rounds, its + round-less `effect.*` messages, and two summaries derived from its rounds + rather than carried on any single message. + + `configuration` is the configuration of the *last* round (by round order) + that carried a `trace.macrostep_stable` - statifier ADR-0044's rule that + the last-arriving `trace.macrostep_stable` in a macrostep is that + macrostep's quiescence, since a stream may carry more than one. `event` + is the first round's dequeued event, `nil` for the initialize burst, + which lets a collapsed macrostep be summarized by what triggered it + without inspecting every round. + """ + + alias StatifierUI.EventLog.Round + alias StatifierUI.Trace.Message + + @type t :: %__MODULE__{ + macrostep: non_neg_integer(), + rounds: [Round.t()], + effects: [Message.t()], + configuration: [non_neg_integer()] | nil, + event: map() | nil + } + + @enforce_keys [:macrostep] + defstruct [:macrostep, :configuration, :event, rounds: [], effects: []] + + @doc """ + Builds a macrostep from its already-ordered rounds and its already-sorted + round-less `effects`, deriving `configuration` and `event` from `rounds`. + """ + @spec new(non_neg_integer(), [Round.t()], [Message.t()]) :: t() + def new(macrostep, rounds, effects) do + %__MODULE__{ + macrostep: macrostep, + rounds: rounds, + effects: effects, + configuration: last_configuration(rounds), + event: first_event(rounds) + } + end + + @spec last_configuration([Round.t()]) :: [non_neg_integer()] | nil + defp last_configuration(rounds) do + rounds + |> Enum.map(& &1.configuration) + |> Enum.reject(&is_nil/1) + |> List.last() + end + + @spec first_event([Round.t()]) :: map() | nil + defp first_event([]), do: nil + defp first_event([%Round{event: event} | _rest]), do: event +end diff --git a/lib/statifier_ui/event_log/markdown.ex b/lib/statifier_ui/event_log/markdown.ex new file mode 100644 index 0000000..3198ac5 --- /dev/null +++ b/lib/statifier_ui/event_log/markdown.ex @@ -0,0 +1,312 @@ +defmodule StatifierUI.EventLog.Markdown do + @moduledoc """ + Renders a `StatifierUI.EventLog.t()` as Markdown a host hands to + `Kino.Markdown.new/1`, per the sui-t36.5 plan's Phase 3. + + `render/2` is a pure function over the log and the `StatifierUI.EventLog.Labels.t()` + that resolves its indexes - it names `Kino` only in this moduledoc and + calls nothing under `Kino.*`. `sui-t36.8` owns wrapping the returned string + in an actual `Kino.Markdown` widget. + + ## Structure + + A header line naming the session (and a drop warning when the log's + `truncated?` is `true`), then one block per macrostep - a collapsible + `
`/`` by default, or a plain `### Macrostep ` heading + with `collapsible: false` - each holding a Markdown table with one row per + round, that round's notes (a raised-cause line, its executed content, a + budget-exhausted line), and the macrostep's round-less `effect.*` messages + listed last under their own heading. A footer names `session.halted`/ + `session.terminated`/`session.unroutable` when the log carries one. + + `exited` and `entered` render through `Labels.states/2` **in list order** + (ADR-0011): this module never re-sorts either list. + """ + + alias StatifierUI.EventLog + alias StatifierUI.EventLog.Labels + alias StatifierUI.EventLog.Macrostep + alias StatifierUI.EventLog.Round + alias StatifierUI.Trace.Message + + @type open_selector :: :last | :all | :none | [non_neg_integer()] + @type opt :: + {:labels, Labels.t()} + | {:collapsible, boolean()} + | {:open, open_selector()} + + @doc """ + Renders `log` as a Markdown string. + + Options: + + * `:labels` - a `Labels.t()` to resolve indexes with. Defaults to + `Labels.from_log(log)`. + * `:collapsible` - wraps each macrostep in `
`/`` when + `true` (the default), or renders a plain `### Macrostep ` heading + with no HTML when `false`. + * `:open` - which macrosteps start open when `:collapsible` is `true`: + `:last` (the default, opens the highest-numbered macrostep), `:all`, + `:none`, or a list of macrostep numbers. + """ + @spec render(EventLog.t(), [opt()]) :: String.t() + def render(%EventLog{} = log, opts \\ []) do + labels = Keyword.get(opts, :labels, Labels.from_log(log)) + collapsible? = Keyword.get(opts, :collapsible, true) + open = Keyword.get(opts, :open, :last) + + ([header(log)] ++ + macrostep_blocks(log.macrosteps, labels, collapsible?, open) ++ + footer_blocks(log)) + |> Enum.reject(&(&1 == "")) + |> Enum.join("\n\n") + end + + # -- Header and footer --------------------------------------------------- + + @spec header(EventLog.t()) :: String.t() + defp header(%EventLog{session: session} = log) do + lines = ["# Event log: #{session || "(no session)"}"] + + if log.truncated? do + Enum.join(lines ++ [truncation_line(log)], "\n") + else + Enum.join(lines, "\n") + end + end + + @spec truncation_line(EventLog.t()) :: String.t() + defp truncation_line(log) do + "Earliest messages dropped; log starts at seq #{earliest_seq(log)}." + end + + @spec earliest_seq(EventLog.t()) :: non_neg_integer() + defp earliest_seq(log) do + log + |> all_messages() + |> Enum.map(& &1.seq) + |> Enum.min(fn -> 0 end) + end + + @spec all_messages(EventLog.t()) :: [Message.t()] + defp all_messages(%EventLog{session_messages: session_messages, macrosteps: macrosteps}) do + session_messages ++ + Enum.flat_map(macrosteps, fn %Macrostep{effects: effects, rounds: rounds} -> + effects ++ Enum.flat_map(rounds, & &1.messages) + end) + end + + @spec footer_blocks(EventLog.t()) :: [String.t()] + defp footer_blocks(%EventLog{session_messages: session_messages}) do + session_messages + |> Enum.filter(&(&1.type in ["session.halted", "session.terminated", "session.unroutable"])) + |> Enum.map(&footer_line/1) + |> case do + [] -> [] + lines -> [Enum.join(lines, "\n")] + end + end + + @spec footer_line(Message.t()) :: String.t() + defp footer_line(%Message{type: "session.halted", payload: %{"reason" => reason}}) do + "Session halted: #{reason}" + end + + defp footer_line(%Message{type: "session.terminated", payload: %{"reason" => reason}}) do + "Session terminated: #{reason}" + end + + defp footer_line(%Message{type: "session.unroutable", payload: %{"effect" => effect}}) do + "Session unroutable: #{inspect(effect)}" + end + + # -- Macrostep blocks ----------------------------------------------------- + + @spec macrostep_blocks([Macrostep.t()], Labels.t(), boolean(), open_selector()) :: [ + String.t() + ] + defp macrostep_blocks(macrosteps, labels, collapsible?, open) do + Enum.map(macrosteps, fn macrostep -> + open? = macrostep_open?(macrosteps, open, macrostep) + macrostep_block(macrostep, labels, collapsible?, open?) + end) + end + + @spec macrostep_open?([Macrostep.t()], open_selector(), Macrostep.t()) :: boolean() + defp macrostep_open?(_macrosteps, :all, _macrostep), do: true + defp macrostep_open?(_macrosteps, :none, _macrostep), do: false + + defp macrostep_open?(macrosteps, :last, macrostep) do + case List.last(macrosteps) do + %Macrostep{macrostep: number} -> number == macrostep.macrostep + nil -> false + end + end + + defp macrostep_open?(_macrosteps, indexes, macrostep) when is_list(indexes) do + macrostep.macrostep in indexes + end + + @spec macrostep_block(Macrostep.t(), Labels.t(), boolean(), boolean()) :: String.t() + defp macrostep_block(%Macrostep{} = macrostep, labels, true, open?) do + open_attr = if open?, do: " open", else: "" + summary = summary_text(macrostep, labels) + + lines = + ["", "#{summary}", ""] ++ + macrostep_body(macrostep, labels) ++ + ["
"] + + Enum.join(lines, "\n") + end + + defp macrostep_block(%Macrostep{} = macrostep, labels, false, _open?) do + summary = summary_text(macrostep, labels) + + lines = + ["### Macrostep #{macrostep.macrostep}", "", summary, ""] ++ + macrostep_body(macrostep, labels) + + Enum.join(lines, "\n") + end + + @spec macrostep_body(Macrostep.t(), Labels.t()) :: [String.t()] + defp macrostep_body(%Macrostep{rounds: rounds, effects: effects}, labels) do + round_table_lines(rounds, labels) ++ + round_note_lines(rounds, labels) ++ effects_lines(effects) + end + + @spec summary_text(Macrostep.t(), Labels.t()) :: String.t() + defp summary_text(%Macrostep{} = macrostep, labels) do + round_count = length(macrostep.rounds) + + "Macrostep #{macrostep.macrostep}: #{event_summary(macrostep.event)}, " <> + "#{round_count} #{pluralize(round_count, "round")}, " <> + quiescence_summary(macrostep.configuration, labels) + end + + @spec event_summary(map() | nil) :: String.t() + defp event_summary(nil), do: "initialize" + defp event_summary(%{"name" => name}), do: name + + @spec quiescence_summary([non_neg_integer()] | nil, Labels.t()) :: String.t() + defp quiescence_summary(nil, _labels), do: "not yet quiescent" + + defp quiescence_summary(configuration, labels) do + "quiescent at #{Labels.states(labels, configuration)}" + end + + @spec pluralize(non_neg_integer(), String.t()) :: String.t() + defp pluralize(1, word), do: word + defp pluralize(_count, word), do: word <> "s" + + # -- The round table ------------------------------------------------------ + + @spec round_table_lines([Round.t()], Labels.t()) :: [String.t()] + defp round_table_lines(rounds, labels) do + header = [ + "| round | event | selected | exited | entered |", + "| --- | --- | --- | --- | --- |" + ] + + header ++ Enum.map(rounds, &round_row(&1, labels)) + end + + @spec round_row(Round.t(), Labels.t()) :: String.t() + defp round_row(%Round{} = round, labels) do + "| #{round.round} | #{event_cell(round)} | #{selected_cell(round, labels)} | " <> + "#{Labels.states(labels, round.exited)} | #{Labels.states(labels, round.entered)} |" + end + + @spec event_cell(Round.t()) :: String.t() + defp event_cell(%Round{event: nil, eventless?: true}), do: "(eventless)" + defp event_cell(%Round{event: nil, eventless?: false}), do: "-" + defp event_cell(%Round{event: %{"name" => name}}), do: name + + @spec selected_cell(Round.t(), Labels.t()) :: String.t() + defp selected_cell(%Round{t_indexes: nil}, _labels), do: "-" + defp selected_cell(%Round{t_indexes: []}, _labels), do: "none" + + defp selected_cell(%Round{t_indexes: t_indexes}, labels) do + Enum.map_join(t_indexes, ", ", &Labels.transition(labels, &1)) + end + + # -- Per-round notes: raised cause, executed content, budget exhaustion -- + + @spec round_note_lines([Round.t()], Labels.t()) :: [String.t()] + defp round_note_lines(rounds, labels) do + Enum.flat_map(rounds, fn round -> + cause_lines(round, labels) ++ content_lines(round, labels) ++ budget_lines(round) + end) + end + + @spec cause_lines(Round.t(), Labels.t()) :: [String.t()] + defp cause_lines(%Round{cause: nil}, _labels), do: [] + + defp cause_lines(%Round{cause: cause}, labels) do + origin = Labels.origin(labels, cause["origin"]) + ["- raised by #{origin} at (macrostep #{cause["macrostep"]}, round #{cause["round"]})"] + end + + @spec content_lines(Round.t(), Labels.t()) :: [String.t()] + defp content_lines(%Round{content: []}, _labels), do: [] + + defp content_lines(%Round{content: content}, labels) do + Enum.map(content, &content_line(&1, labels)) + end + + @spec content_line(%{owner: map(), c_indexes: [non_neg_integer()]}, Labels.t()) :: String.t() + defp content_line(%{owner: owner, c_indexes: c_indexes}, labels) do + contents = Enum.map_join(c_indexes, ", ", &Labels.content(labels, &1)) + "- #{Labels.owner(labels, owner)}: #{contents}" + end + + @spec budget_lines(Round.t()) :: [String.t()] + defp budget_lines(%Round{budget_exhausted: nil}), do: [] + + defp budget_lines(%Round{budget_exhausted: payload}) do + pending = length(payload["pending_internal_events"]) + + [ + "- budget exhausted after #{payload["budget"]} rounds; #{pending} internal events still pending" + ] + end + + # -- The macrostep's round-less effects ----------------------------------- + + @spec effects_lines([Message.t()]) :: [String.t()] + defp effects_lines([]), do: [] + + defp effects_lines(effects) do + ["", "Effects with no round (listed per macrostep):"] ++ Enum.map(effects, &effect_line/1) + end + + @spec effect_line(Message.t()) :: String.t() + defp effect_line(%Message{type: "effect.log", payload: payload}) do + "- effect.log#{payload_suffix(payload, ~w(label value))}" + end + + defp effect_line(%Message{type: type, payload: payload}) + when type in ["effect.send", "effect.send_delayed"] do + "- #{type}#{payload_suffix(payload, ~w(event target))}" + end + + defp effect_line(%Message{type: type, payload: payload}) + when type in ["effect.invoke", "effect.cancel_invoke", "effect.autoforward"] do + "- #{type}#{payload_suffix(payload, ["invoke_id"])}" + end + + defp effect_line(%Message{type: "effect.cancel", payload: payload}) do + "- effect.cancel#{payload_suffix(payload, ["send_id"])}" + end + + defp effect_line(%Message{type: type}), do: "- #{type}" + + @spec payload_suffix(map(), [String.t()]) :: String.t() + defp payload_suffix(payload, keys) do + case Enum.filter(keys, &Map.has_key?(payload, &1)) do + [] -> "" + present -> ": " <> Enum.map_join(present, ", ", &"#{&1}=#{inspect(Map.get(payload, &1))}") + end + end +end diff --git a/lib/statifier_ui/event_log/round.ex b/lib/statifier_ui/event_log/round.ex new file mode 100644 index 0000000..9fa2fc3 --- /dev/null +++ b/lib/statifier_ui/event_log/round.ex @@ -0,0 +1,53 @@ +defmodule StatifierUI.EventLog.Round do + @moduledoc """ + One `(macrostep, round)` bucket of an `StatifierUI.EventLog.t()`. + + A round is the timeline key the wire format names + (`docs/wire-format.md:91-94`): the messages an event's dequeue (or, for + the initialize burst and any eventless probe, the absence of one) sets in + motion, up to and including the round's own `trace.macrostep_stable` when + it reached quiescence. + + Every field below is populated by folding this round's messages in + `{microstep, seq}` order (`StatifierUI.EventLog.build/1`); nothing here is + computed by inspecting the round's messages a second time, so the struct + and `messages` never disagree about what happened. + """ + + alias StatifierUI.Trace.Message + + @type t :: %__MODULE__{ + macrostep: non_neg_integer(), + round: non_neg_integer(), + event: map() | nil, + from: String.t() | nil, + cause: map() | nil, + eventless?: boolean(), + t_indexes: [non_neg_integer()] | nil, + exited: [non_neg_integer()], + entered: [non_neg_integer()], + content: [%{owner: map(), c_indexes: [non_neg_integer()]}], + configuration: [non_neg_integer()] | nil, + done: map() | nil, + budget_exhausted: map() | nil, + messages: [Message.t()] + } + + @enforce_keys [:macrostep, :round] + defstruct [ + :macrostep, + :round, + :event, + :from, + :cause, + :t_indexes, + :configuration, + :done, + :budget_exhausted, + eventless?: false, + exited: [], + entered: [], + content: [], + messages: [] + ] +end diff --git a/test/statifier_ui/event_log/labels_test.exs b/test/statifier_ui/event_log/labels_test.exs new file mode 100644 index 0000000..8180468 --- /dev/null +++ b/test/statifier_ui/event_log/labels_test.exs @@ -0,0 +1,277 @@ +defmodule StatifierUI.EventLog.LabelsTest do + use ExUnit.Case, async: true + + alias StatifierUI.EventLog + alias StatifierUI.EventLog.Labels + alias StatifierUI.Test.Support.Trace.SessionCase + alias StatifierUI.Trace.Manifest + + # A superset of the two-state chart at `docs/wire-format.md:757-763`: kept + # to one ``/`` pair but adds the shapes this + # module's own resolvers need that the worked example does not exercise - + # a `` element, a content node, an anonymous state, and transitions + # covering the multi-token, eventless, and targetless cases. + @chart """ + + + + + + + + + + + + + + + + """ + + setup do + machine = SessionCase.compile!(@chart) + {:ok, message} = Manifest.build(machine, "sess_1") + labels = Labels.from_manifest(message) + + payload = message.payload + state_a = Enum.find(payload["states"], &(&1["id"] == "a")) + state_b = Enum.find(payload["states"], &(&1["id"] == "b")) + anonymous = Enum.find(payload["states"], &(&1["kind"] == "state" and is_nil(&1["id"]))) + data_x = Enum.find(payload["data"], &(&1["id"] == "x")) + raise_content = Enum.find(payload["contents"], &(&1["kind"] == "raise")) + + multi_token = + Enum.find( + payload["transitions"], + &(&1["source"] == state_a["index"] and &1["events"] != []) + ) + + eventless = + Enum.find( + payload["transitions"], + &(&1["source"] == state_a["index"] and &1["events"] == []) + ) + + targetless = + Enum.find(payload["transitions"], &(&1["events"] == [["stay"]])) + + %{ + message: message, + labels: labels, + state_a: state_a, + state_b: state_b, + anonymous: anonymous, + data_x: data_x, + raise_content: raise_content, + multi_token: multi_token, + eventless: eventless, + targetless: targetless + } + end + + describe "state/2" do + test "resolves a named state to its id", %{labels: labels, state_a: state_a} do + assert Labels.state(labels, state_a["index"]) == "a" + end + + test "resolves the root scxml state to ", %{labels: labels} do + assert Labels.state(labels, 0) == "" + end + + test "resolves an anonymous state to a bare index", %{labels: labels, anonymous: anonymous} do + assert Labels.state(labels, anonymous["index"]) == "##{anonymous["index"]}" + end + + test "resolves an out-of-range index to a bare index", %{labels: labels} do + assert Labels.state(labels, 999) == "#999" + end + end + + describe "states/2" do + test "resolves each index in list order, joined with a comma", %{ + labels: labels, + state_a: state_a, + state_b: state_b + } do + assert Labels.states(labels, [state_b["index"], state_a["index"]]) == "b, a" + end + end + + describe "transition/2" do + test "renders a multi-token dot-split descriptor alongside a single-token one", %{ + labels: labels, + multi_token: multi_token + } do + assert multi_token["events"] == [["done", "state", "s1"], ["go"]] + assert Labels.transition(labels, multi_token["t_index"]) == "done.state.s1 go: a -> b" + end + + test "renders an eventless transition", %{labels: labels, eventless: eventless} do + assert Labels.transition(labels, eventless["t_index"]) == "(eventless): a -> b" + end + + test "renders a targetless transition", %{labels: labels, targetless: targetless} do + assert Labels.transition(labels, targetless["t_index"]) == "stay: a -> (targetless)" + end + + test "resolves an out-of-range index to a bare index", %{labels: labels} do + assert Labels.transition(labels, 999) == "#999" + end + end + + describe "content/2" do + test "renders the content node's kind and start line", %{ + labels: labels, + raise_content: raise_content + } do + expected = + "raise@#{raise_content["location"]["start_line"]}:#{raise_content["location"]["start_column"]}" + + assert Labels.content(labels, raise_content["c_index"]) == expected + end + + test "resolves an out-of-range index to a bare index", %{labels: labels} do + assert Labels.content(labels, 999) == "#999" + end + end + + describe "data/2" do + test "resolves a element to its id", %{labels: labels, data_x: data_x} do + assert Labels.data(labels, data_x["d_index"]) == "x" + end + + test "resolves an out-of-range index to a bare index", %{labels: labels} do + assert Labels.data(labels, 999) == "#999" + end + end + + describe "origin/2 - the eight kinds" do + test "content delegates to owner and content", %{ + labels: labels, + raise_content: raise_content, + state_a: state_a + } do + origin = %{ + "kind" => "content", + "c_index" => raise_content["c_index"], + "owner" => %{"kind" => "onentry", "state_index" => state_a["index"], "ordinal" => 0} + } + + expected = "a onentry#0: #{Labels.content(labels, raise_content["c_index"])}" + assert Labels.origin(labels, origin) == expected + end + + test "state delegates to state/2", %{labels: labels, state_b: state_b} do + origin = %{"kind" => "state", "state_index" => state_b["index"]} + assert Labels.origin(labels, origin) == "b" + end + + test "transition delegates to transition/2", %{labels: labels, multi_token: multi_token} do + origin = %{"kind" => "transition", "t_index" => multi_token["t_index"]} + assert Labels.origin(labels, origin) == "done.state.s1 go: a -> b" + end + + test "data delegates to data/2", %{labels: labels, data_x: data_x} do + origin = %{"kind" => "data", "d_index" => data_x["d_index"]} + assert Labels.origin(labels, origin) == "x" + end + + test "donedata_param names the state and the param index", %{labels: labels, state_b: state_b} do + origin = %{ + "kind" => "donedata_param", + "state_index" => state_b["index"], + "param_index" => 0 + } + + assert Labels.origin(labels, origin) == "b donedata param 0" + end + + test "global_script delegates to content/2", %{labels: labels, raise_content: raise_content} do + origin = %{"kind" => "global_script", "index" => raise_content["c_index"]} + assert Labels.origin(labels, origin) == Labels.content(labels, raise_content["c_index"]) + end + + test "invoke names the state and the invoke index", %{labels: labels, state_a: state_a} do + origin = %{"kind" => "invoke", "state_index" => state_a["index"], "invoke_index" => 0} + assert Labels.origin(labels, origin) == "a invoke#0" + end + + test "finalize names the state and the invoke index", %{labels: labels, state_a: state_a} do + origin = %{"kind" => "finalize", "state_index" => state_a["index"], "invoke_index" => 0} + assert Labels.origin(labels, origin) == "a invoke#0 finalize" + end + + test "an unrecognized kind renders the kind string itself", %{labels: labels} do + origin = %{"kind" => "mystery_kind"} + assert Labels.origin(labels, origin) == "mystery_kind" + end + end + + describe "owner/2 - the five kinds" do + test "onentry names the state and the ordinal", %{labels: labels, state_a: state_a} do + owner = %{"kind" => "onentry", "state_index" => state_a["index"], "ordinal" => 0} + assert Labels.owner(labels, owner) == "a onentry#0" + end + + test "onexit names the state and the ordinal", %{labels: labels, state_a: state_a} do + owner = %{"kind" => "onexit", "state_index" => state_a["index"], "ordinal" => 1} + assert Labels.owner(labels, owner) == "a onexit#1" + end + + test "transition delegates to transition/2", %{labels: labels, multi_token: multi_token} do + owner = %{"kind" => "transition", "t_index" => multi_token["t_index"]} + assert Labels.owner(labels, owner) == "done.state.s1 go: a -> b" + end + + test "finalize names the state and the invoke index", %{labels: labels, state_a: state_a} do + owner = %{"kind" => "finalize", "state_index" => state_a["index"], "invoke_index" => 2} + assert Labels.owner(labels, owner) == "a invoke#2 finalize" + end + + test "global_script delegates to content/2", %{labels: labels, raise_content: raise_content} do + owner = %{"kind" => "global_script", "index" => raise_content["c_index"]} + assert Labels.owner(labels, owner) == Labels.content(labels, raise_content["c_index"]) + end + + test "an unrecognized kind renders the kind string itself", %{labels: labels} do + owner = %{"kind" => "mystery_kind"} + assert Labels.owner(labels, owner) == "mystery_kind" + end + end + + describe "from_manifest/1 and from_log/1" do + test "from_manifest/1 builds a t() a real manifest resolves through", %{ + message: message, + state_a: state_a + } do + labels = Labels.from_manifest(message) + assert Labels.state(labels, state_a["index"]) == "a" + end + + test "from_log/1 finds the first session.start and delegates to from_manifest/1", %{ + message: message, + state_a: state_a + } do + {:ok, log} = EventLog.build([message]) + labels = Labels.from_log(log) + assert Labels.state(labels, state_a["index"]) == "a" + end + + test "from_log/1 returns empty/0 when the log carries no session.start" do + labels = Labels.from_log(%EventLog{}) + assert labels == Labels.empty() + end + end + + describe "empty/0" do + test "every index resolver renders a bare index with no manifest at all" do + labels = Labels.empty() + + assert Labels.state(labels, 3) == "#3" + assert Labels.transition(labels, 3) == "#3" + assert Labels.content(labels, 3) == "#3" + assert Labels.data(labels, 3) == "#3" + end + end +end diff --git a/test/statifier_ui/event_log/markdown_test.exs b/test/statifier_ui/event_log/markdown_test.exs new file mode 100644 index 0000000..6d3d5ea --- /dev/null +++ b/test/statifier_ui/event_log/markdown_test.exs @@ -0,0 +1,366 @@ +defmodule StatifierUI.EventLog.MarkdownTest do + use ExUnit.Case, async: true + + alias StatifierUI.EventLog + alias StatifierUI.EventLog.Labels + alias StatifierUI.EventLog.Markdown + alias StatifierUI.Test.Support.Trace.SessionCase + alias StatifierUI.Trace.Manifest + alias StatifierUI.Trace.Message + + # The worked example's chart (`docs/wire-format.md:755-761`), driven the + # same way: one external "go" from `a` to `b`, after the initialize burst. + @chart """ + + + + + + + """ + + setup do + machine = SessionCase.compile!(@chart) + {:ok, start_message} = Manifest.build(machine, "sess_golden") + labels = Labels.from_manifest(start_message) + + states = start_message.payload["states"] + state_a = Enum.find(states, &(&1["id"] == "a")) + state_b = Enum.find(states, &(&1["id"] == "b")) + root = Enum.find(states, &(&1["kind"] == "scxml")) + + transition = + Enum.find(start_message.payload["transitions"], &(&1["source"] == state_a["index"])) + + messages = [ + start_message, + %Message{type: "session.datamodel", session: "sess_golden", seq: 1, payload: %{}}, + %Message{ + type: "trace.entry_set", + session: "sess_golden", + seq: 2, + macrostep: 1, + microstep: 1, + round: 0, + payload: %{"indexes" => [root["index"], state_a["index"]]} + }, + %Message{ + type: "trace.transitions_selected", + session: "sess_golden", + seq: 3, + macrostep: 1, + microstep: 1, + round: 1, + payload: %{"t_indexes" => []} + }, + %Message{ + type: "trace.macrostep_stable", + session: "sess_golden", + seq: 4, + macrostep: 1, + microstep: 1, + round: 1, + payload: %{"configuration" => [root["index"], state_a["index"]]} + }, + %Message{ + type: "trace.event_dequeued", + session: "sess_golden", + seq: 5, + macrostep: 2, + microstep: 0, + round: 0, + payload: %{"event" => %{"name" => "go", "type" => "external"}, "from" => "external"} + }, + %Message{ + type: "trace.transitions_selected", + session: "sess_golden", + seq: 6, + macrostep: 2, + microstep: 0, + round: 0, + payload: %{ + "event" => %{"name" => "go", "type" => "external"}, + "t_indexes" => [transition["t_index"]] + } + }, + %Message{ + type: "trace.exit_set", + session: "sess_golden", + seq: 7, + macrostep: 2, + microstep: 1, + round: 0, + payload: %{"indexes" => [state_a["index"]]} + }, + %Message{ + type: "trace.entry_set", + session: "sess_golden", + seq: 8, + macrostep: 2, + microstep: 1, + round: 0, + payload: %{"indexes" => [state_b["index"]]} + }, + %Message{ + type: "trace.transitions_selected", + session: "sess_golden", + seq: 9, + macrostep: 2, + microstep: 1, + round: 1, + payload: %{"t_indexes" => []} + }, + %Message{ + type: "trace.macrostep_stable", + session: "sess_golden", + seq: 10, + macrostep: 2, + microstep: 1, + round: 1, + payload: %{"configuration" => [root["index"], state_b["index"]]} + } + ] + + {:ok, log} = EventLog.build(messages) + + %{ + log: log, + labels: labels, + state_a: state_a, + state_b: state_b, + transition: transition + } + end + + @spec rendered_lines(EventLog.t(), keyword()) :: [String.t()] + defp rendered_lines(log, opts \\ []) do + log + |> Markdown.render(opts) + |> String.split("\n") + |> Enum.map(&String.trim/1) + end + + describe "render/2 - the worked-example table row" do + test "macrostep 2 round 0 carries go, the resolved transition label, and the exit/entry", %{ + log: log, + labels: labels, + state_a: state_a, + state_b: state_b, + transition: transition + } do + lines = rendered_lines(log) + + expected_row = + "| 0 | go | #{Labels.transition(labels, transition["t_index"])} | " <> + "#{state_a["id"]} | #{state_b["id"]} |" + + assert expected_row in lines + end + end + + describe "render/2 - exit order (ADR-0011)" do + test "exit indexes render in list order, not sorted" do + messages = [ + %Message{ + type: "trace.exit_set", + session: "s", + seq: 0, + macrostep: 1, + microstep: 0, + round: 0, + payload: %{"indexes" => [3, 2, 1]} + }, + %Message{ + type: "trace.entry_set", + session: "s", + seq: 1, + macrostep: 1, + microstep: 0, + round: 0, + payload: %{"indexes" => [9]} + } + ] + + {:ok, log} = EventLog.build(messages) + lines = rendered_lines(log, labels: Labels.empty()) + + assert "| 0 | - | - | #3, #2, #1 | #9 |" in lines + end + end + + describe "render/2 - collapsible: true" do + test "emits
/
once per macrostep, and only the last is open", %{log: log} do + lines = rendered_lines(log) + + assert Enum.count(lines, &(&1 == "
")) == 2 + assert "
" in lines + assert "
" in lines + assert Enum.count(lines, &(&1 == "
")) == 1 + end + + test "open: :all opens every macrostep", %{log: log} do + lines = rendered_lines(log, open: :all) + + assert Enum.count(lines, &(&1 == "
")) == 2 + refute "
" in lines + end + + test "open: :none opens none", %{log: log} do + lines = rendered_lines(log, open: :none) + + refute "
" in lines + assert Enum.count(lines, &(&1 == "
")) == 2 + end + + test "open: [1] opens only macrostep 1", %{log: log} do + lines = rendered_lines(log, open: [1]) + + summary_index = Enum.find_index(lines, &(&1 == "
")) + next_summary = Enum.at(lines, summary_index + 1) + + assert Enum.count(lines, &(&1 == "
")) == 1 + assert next_summary =~ "Macrostep 1:" + end + end + + describe "render/2 - collapsible: false" do + test "emits no
anywhere, and a plain heading instead", %{log: log} do + lines = rendered_lines(log, collapsible: false) + + refute Enum.any?(lines, &String.starts_with?(&1, "")) + assert "### Macrostep 1" in lines + assert "### Macrostep 2" in lines + end + end + + describe "render/2 - no manifest" do + test "degrades to bare indexes rather than crashing" do + messages = [ + %Message{ + type: "trace.exit_set", + session: "s", + seq: 0, + macrostep: 1, + microstep: 0, + round: 0, + payload: %{"indexes" => [1]} + }, + %Message{ + type: "trace.entry_set", + session: "s", + seq: 1, + macrostep: 1, + microstep: 0, + round: 0, + payload: %{"indexes" => [2]} + } + ] + + {:ok, log} = EventLog.build(messages) + lines = rendered_lines(log) + + assert "| 0 | - | - | #1 | #2 |" in lines + end + end + + describe "render/2 - truncation" do + test "a truncated log renders the drop warning" do + messages = [ + %Message{type: "session.datamodel", session: "s", seq: 7, payload: %{}} + ] + + {:ok, log} = EventLog.build(messages) + lines = rendered_lines(log) + + assert log.truncated? == true + assert "Earliest messages dropped; log starts at seq 7." in lines + end + + test "a complete log renders no drop warning" do + messages = [ + %Message{type: "session.datamodel", session: "s", seq: 0, payload: %{}} + ] + + {:ok, log} = EventLog.build(messages) + lines = rendered_lines(log) + + assert log.truncated? == false + refute Enum.any?(lines, &String.contains?(&1, "dropped")) + end + end + + describe "render/2 - the raised-cause note" do + test "renders the cause's own (macrostep, round), not the dequeue's", %{labels: labels} do + cause = %{ + "origin" => %{"kind" => "state", "state_index" => 0}, + "macrostep" => 1, + "microstep" => 0, + "round" => 0 + } + + messages = [ + %Message{ + type: "trace.event_dequeued", + session: "s", + seq: 0, + macrostep: 5, + microstep: 0, + round: 3, + payload: %{ + "event" => %{"name" => "internal", "type" => "internal", "cause" => cause}, + "from" => "internal" + } + } + ] + + {:ok, log} = EventLog.build(messages) + lines = rendered_lines(log, labels: labels) + + expected = + "- raised by #{Labels.origin(labels, cause["origin"])} at (macrostep 1, round 0)" + + assert expected in lines + + refute Enum.any?( + lines, + &(&1 == + "- raised by #{Labels.origin(labels, cause["origin"])} at (macrostep 5, round 3)") + ) + end + end + + describe "render/2 - effect.log" do + test "appears under the macrostep with its label and value" do + messages = [ + %Message{ + type: "effect.log", + session: "s", + seq: 0, + macrostep: 1, + microstep: 0, + payload: %{"label" => "checkpoint", "value" => 42} + } + ] + + {:ok, log} = EventLog.build(messages) + lines = rendered_lines(log) + + assert "- effect.log: label=\"checkpoint\", value=42" in lines + assert "Effects with no round (listed per macrostep):" in lines + end + end + + describe "render/2 - the initialize burst" do + test "renders initialize in its summary and no event in its round rows", %{log: log} do + lines = rendered_lines(log) + + # Macrostep 1 is the initialize burst: its summary names it "initialize" + # rather than any dequeued event, and neither of its round rows (round + # 0, entry only; round 1, the eventless quiescence probe) names one. + assert Enum.any?(lines, &String.contains?(&1, "Macrostep 1: initialize,")) + assert "| 0 | - | - | " <> _rest = Enum.find(lines, &String.starts_with?(&1, "| 0 | -")) + assert "| 1 | (eventless) | none | | |" in lines + end + end +end diff --git a/test/statifier_ui/event_log_test.exs b/test/statifier_ui/event_log_test.exs new file mode 100644 index 0000000..3c713da --- /dev/null +++ b/test/statifier_ui/event_log_test.exs @@ -0,0 +1,580 @@ +defmodule StatifierUI.EventLogTest do + use ExUnit.Case, async: true + + alias StatifierUI.EventLog + alias StatifierUI.EventLog.Macrostep + alias StatifierUI.EventLog.Round + alias StatifierUI.Trace.Message + + # The worked example, `docs/wire-format.md:775-791` - the two-state chart + # driven by one external "go" event, transcribed as struct literals. Kept + # as a module attribute so every describe block below exercises the same + # fifteen messages. + @worked_example [ + %Message{type: "session.start", session: "sess_golden", seq: 0, payload: %{"version" => 1}}, + %Message{type: "session.datamodel", session: "sess_golden", seq: 1, payload: %{}}, + %Message{ + type: "trace.entry_set", + session: "sess_golden", + seq: 2, + macrostep: 1, + microstep: 1, + round: 0, + payload: %{"indexes" => [0, 1]} + }, + %Message{ + type: "trace.transitions_selected", + session: "sess_golden", + seq: 3, + macrostep: 1, + microstep: 1, + round: 1, + payload: %{"t_indexes" => []} + }, + %Message{ + type: "trace.invoke_pass", + session: "sess_golden", + seq: 4, + macrostep: 1, + microstep: 1, + round: 1, + payload: %{"state_indexes" => [0, 1], "invoke_ids" => []} + }, + %Message{ + type: "trace.macrostep_stable", + session: "sess_golden", + seq: 5, + macrostep: 1, + microstep: 1, + round: 1, + payload: %{"configuration" => [0, 1]} + }, + %Message{ + type: "trace.event_dequeued", + session: "sess_golden", + seq: 6, + macrostep: 2, + microstep: 0, + round: 0, + payload: %{"event" => %{"name" => "go", "type" => "external"}, "from" => "external"} + }, + %Message{ + type: "trace.finalize_autoforward", + session: "sess_golden", + seq: 7, + macrostep: 2, + microstep: 0, + round: 0, + payload: %{ + "event" => %{"name" => "go", "type" => "external"}, + "finalized" => [], + "forwarded" => [] + } + }, + %Message{ + type: "trace.transitions_selected", + session: "sess_golden", + seq: 8, + macrostep: 2, + microstep: 0, + round: 0, + payload: %{"event" => %{"name" => "go", "type" => "external"}, "t_indexes" => [0]} + }, + %Message{ + type: "trace.exit_set", + session: "sess_golden", + seq: 9, + macrostep: 2, + microstep: 1, + round: 0, + payload: %{"indexes" => [1]} + }, + %Message{ + type: "trace.content_executed", + session: "sess_golden", + seq: 10, + macrostep: 2, + microstep: 1, + round: 0, + payload: %{"owner" => %{"kind" => "transition", "t_index" => 0}, "c_indexes" => []} + }, + %Message{ + type: "trace.entry_set", + session: "sess_golden", + seq: 11, + macrostep: 2, + microstep: 1, + round: 0, + payload: %{"indexes" => [2]} + }, + %Message{ + type: "trace.transitions_selected", + session: "sess_golden", + seq: 12, + macrostep: 2, + microstep: 1, + round: 1, + payload: %{"t_indexes" => []} + }, + %Message{ + type: "trace.invoke_pass", + session: "sess_golden", + seq: 13, + macrostep: 2, + microstep: 1, + round: 1, + payload: %{"state_indexes" => [2], "invoke_ids" => []} + }, + %Message{ + type: "trace.macrostep_stable", + session: "sess_golden", + seq: 14, + macrostep: 2, + microstep: 1, + round: 1, + payload: %{"configuration" => [0, 2]} + } + ] + + describe "build/1 - the worked example" do + setup do + {:ok, log} = EventLog.build(@worked_example) + %{log: log} + end + + test "groups into two macrosteps", %{log: log} do + assert [%Macrostep{macrostep: 1}, %Macrostep{macrostep: 2}] = log.macrosteps + end + + test "macrostep 1 has rounds 0 and 1 and no dequeued event", %{log: log} do + [macrostep_1, _macrostep_2] = log.macrosteps + + assert macrostep_1.event == nil + assert [%Round{round: 0}, %Round{round: 1}] = macrostep_1.rounds + end + + test "macrostep 2 round 0 carries the dequeue and its selection/exit/entry", %{log: log} do + [_macrostep_1, macrostep_2] = log.macrosteps + [round_0, _round_1] = macrostep_2.rounds + + assert round_0.event == %{"name" => "go", "type" => "external"} + assert round_0.from == "external" + assert round_0.t_indexes == [0] + assert round_0.exited == [1] + assert round_0.entered == [2] + end + + test "macrostep 2 round 1 carries the quiescent configuration", %{log: log} do + [_macrostep_1, macrostep_2] = log.macrosteps + [_round_0, round_1] = macrostep_2.rounds + + assert round_1.configuration == [0, 2] + end + + test "nothing is dropped", %{log: log} do + counted = + length(log.session_messages) + + Enum.sum(Enum.map(log.macrosteps, &length(&1.effects))) + + Enum.sum( + Enum.map(log.macrosteps, fn macrostep -> + Enum.sum(Enum.map(macrostep.rounds, &length(&1.messages))) + end) + ) + + assert counted == length(@worked_example) + end + end + + describe "build/1 - shuffle invariance" do + test "the same messages in any order fold to the same log" do + {:ok, expected} = EventLog.build(@worked_example) + + for seed <- 1..5 do + :rand.seed(:exsss, {seed, seed, seed}) + shuffled = Enum.shuffle(@worked_example) + assert {:ok, ^expected} = EventLog.build(shuffled) + end + end + end + + describe "build/1 - exit and entry order" do + test "exit order is preserved, not sorted (ADR-0011)" do + messages = [ + %Message{ + type: "trace.exit_set", + session: "s", + seq: 0, + macrostep: 1, + microstep: 0, + round: 0, + payload: %{"indexes" => [3, 2, 1]} + } + ] + + {:ok, log} = EventLog.build(messages) + [macrostep] = log.macrosteps + [round] = macrostep.rounds + + assert round.exited == [3, 2, 1] + end + + test "entry order is preserved, not sorted (ADR-0011)" do + messages = [ + %Message{ + type: "trace.entry_set", + session: "s", + seq: 0, + macrostep: 1, + microstep: 0, + round: 0, + payload: %{"indexes" => [5, 4, 6]} + } + ] + + {:ok, log} = EventLog.build(messages) + [macrostep] = log.macrosteps + [round] = macrostep.rounds + + assert round.entered == [5, 4, 6] + end + end + + describe "build/1 - rounds do not interleave across microsteps" do + test "a round spanning two microsteps keeps both, in round order" do + messages = [ + %Message{ + type: "trace.event_dequeued", + session: "s", + seq: 0, + macrostep: 1, + microstep: 0, + round: 0, + payload: %{"event" => %{"name" => "go", "type" => "external"}, "from" => "external"} + }, + %Message{ + type: "trace.exit_set", + session: "s", + seq: 1, + macrostep: 1, + microstep: 1, + round: 0, + payload: %{"indexes" => [1]} + }, + %Message{ + type: "trace.transitions_selected", + session: "s", + seq: 2, + macrostep: 1, + microstep: 1, + round: 1, + payload: %{"t_indexes" => []} + } + ] + + {:ok, log} = EventLog.build(messages) + [macrostep] = log.macrosteps + + assert [%Round{round: 0} = round_0, %Round{round: 1}] = macrostep.rounds + assert length(round_0.messages) == 2 + end + + test "a round-1 message stamped at a lower microstep than round 0 still lands in round 1" do + # The engine may never emit this shape - see decision 1 in the plan - + # but the model does not depend on microstep never decreasing as + # round increases, because bucketing is by round, not a flat sort. + messages = [ + %Message{ + type: "trace.exit_set", + session: "s", + seq: 0, + macrostep: 1, + microstep: 5, + round: 0, + payload: %{"indexes" => [1]} + }, + %Message{ + type: "trace.entry_set", + session: "s", + seq: 1, + macrostep: 1, + microstep: 0, + round: 1, + payload: %{"indexes" => [2]} + } + ] + + {:ok, log} = EventLog.build(messages) + [macrostep] = log.macrosteps + + assert [%Round{round: 0, exited: [1], entered: []}, %Round{round: 1, entered: [2]}] = + macrostep.rounds + end + end + + describe "build/1 - t_indexes" do + test "is nil when the round carries no trace.transitions_selected" do + messages = [ + %Message{ + type: "trace.entry_set", + session: "s", + seq: 0, + macrostep: 1, + microstep: 0, + round: 0, + payload: %{"indexes" => [1]} + } + ] + + {:ok, log} = EventLog.build(messages) + [macrostep] = log.macrosteps + [round] = macrostep.rounds + + assert round.t_indexes == nil + end + + test "is an empty list when the message selected nothing" do + messages = [ + %Message{ + type: "trace.transitions_selected", + session: "s", + seq: 0, + macrostep: 1, + microstep: 0, + round: 0, + payload: %{"t_indexes" => []} + } + ] + + {:ok, log} = EventLog.build(messages) + [macrostep] = log.macrosteps + [round] = macrostep.rounds + + assert round.t_indexes == [] + end + end + + describe "build/1 - eventless?" do + test "is true when trace.transitions_selected omits its event key" do + messages = [ + %Message{ + type: "trace.transitions_selected", + session: "s", + seq: 0, + macrostep: 1, + microstep: 0, + round: 0, + payload: %{"t_indexes" => []} + } + ] + + {:ok, log} = EventLog.build(messages) + [macrostep] = log.macrosteps + [round] = macrostep.rounds + + assert round.eventless? == true + end + + test "is false when trace.transitions_selected carries an event" do + messages = [ + %Message{ + type: "trace.transitions_selected", + session: "s", + seq: 0, + macrostep: 1, + microstep: 0, + round: 0, + payload: %{"event" => %{"name" => "go", "type" => "external"}, "t_indexes" => [0]} + } + ] + + {:ok, log} = EventLog.build(messages) + [macrostep] = log.macrosteps + [round] = macrostep.rounds + + assert round.eventless? == false + end + end + + describe "build/1 - cause hoisting" do + test "an internal event's cause is hoisted to Round.cause" do + cause = %{ + "origin" => %{"kind" => "transition", "t_index" => 0}, + "macrostep" => 1, + "microstep" => 0, + "round" => 0 + } + + messages = [ + %Message{ + type: "trace.event_dequeued", + session: "s", + seq: 0, + macrostep: 2, + microstep: 0, + round: 0, + payload: %{ + "event" => %{"name" => "internal", "type" => "internal", "cause" => cause}, + "from" => "internal" + } + } + ] + + {:ok, log} = EventLog.build(messages) + [macrostep] = log.macrosteps + [round] = macrostep.rounds + + assert round.cause == cause + end + + test "an external event with no cause hoists nil" do + messages = [ + %Message{ + type: "trace.event_dequeued", + session: "s", + seq: 0, + macrostep: 2, + microstep: 0, + round: 0, + payload: %{"event" => %{"name" => "go", "type" => "external"}, "from" => "external"} + } + ] + + {:ok, log} = EventLog.build(messages) + [macrostep] = log.macrosteps + [round] = macrostep.rounds + + assert round.cause == nil + end + end + + describe "build/1 - effect placement" do + test "round-less effects land in Macrostep.effects in {microstep, seq} order" do + messages = [ + %Message{ + type: "effect.send", + session: "s", + seq: 1, + macrostep: 1, + microstep: 1, + payload: %{} + }, + %Message{ + type: "effect.log", + session: "s", + seq: 0, + macrostep: 1, + microstep: 0, + payload: %{} + } + ] + + {:ok, log} = EventLog.build(messages) + [macrostep] = log.macrosteps + + assert [%Message{type: "effect.log"}, %Message{type: "effect.send"}] = macrostep.effects + assert macrostep.rounds == [] + end + + test "effect.budget_exhausted lands in its round, not in Macrostep.effects" do + payload = %{ + "budget" => 10, + "configuration" => [0, 1], + "pending_internal_events" => [] + } + + messages = [ + %Message{ + type: "effect.budget_exhausted", + session: "s", + seq: 0, + macrostep: 3, + microstep: 4, + round: 12, + payload: payload + } + ] + + {:ok, log} = EventLog.build(messages) + [macrostep] = log.macrosteps + [round] = macrostep.rounds + + assert macrostep.effects == [] + assert round.round == 12 + assert round.budget_exhausted == payload + assert round.configuration == nil + end + end + + describe "build/1 - multiple trace.macrostep_stable in one macrostep" do + test "the macrostep's configuration is the later round's" do + messages = [ + %Message{ + type: "trace.macrostep_stable", + session: "s", + seq: 0, + macrostep: 1, + microstep: 0, + round: 0, + payload: %{"configuration" => [0, 1]} + }, + %Message{ + type: "trace.macrostep_stable", + session: "s", + seq: 1, + macrostep: 1, + microstep: 1, + round: 1, + payload: %{"configuration" => [0, 2]} + } + ] + + {:ok, log} = EventLog.build(messages) + [macrostep] = log.macrosteps + + assert macrostep.configuration == [0, 2] + end + end + + describe "build/1 - session_messages and truncated?" do + test "session.* land in session_messages and truncated? is false at seq 0" do + messages = [ + %Message{type: "session.start", session: "s", seq: 0, payload: %{}}, + %Message{type: "session.datamodel", session: "s", seq: 1, payload: %{}} + ] + + {:ok, log} = EventLog.build(messages) + + assert length(log.session_messages) == 2 + assert log.macrosteps == [] + assert log.truncated? == false + end + + test "a list starting above seq 0 is truncated" do + messages = [ + %Message{type: "session.datamodel", session: "s", seq: 7, payload: %{}} + ] + + {:ok, log} = EventLog.build(messages) + + assert log.truncated? == true + end + end + + describe "build/1 - mixed sessions" do + test "returns a sorted mixed_sessions error" do + messages = [ + %Message{type: "session.start", session: "b", seq: 0, payload: %{}}, + %Message{type: "session.start", session: "a", seq: 0, payload: %{}} + ] + + assert EventLog.build(messages) == {:error, {:mixed_sessions, ["a", "b"]}} + end + end + + describe "build/1 - empty input" do + test "returns an empty log" do + assert {:ok, %EventLog{session: nil, macrosteps: [], session_messages: []}} = + EventLog.build([]) + end + end +end