StatifierUI.Trace.Normalizer (StatifierUI v0.6.1)

Copy Markdown View Source

The pure mapping from Statifier.Effect.t() (and the session's own lifecycle messages) to StatifierUI.Trace.Message.t(), matching docs/wire-format.md field for field. No process, no session, no %Statifier.Machine{} - the whole vocabulary is testable from a struct literal.

Value handling

Anything from the engine that occupies a value position - Event.data, Log.value, Trace.Done.donedata, Effect.Done.donedata, Send.data, SendDelayed.data, Invoke.params, Invoke.content, DatamodelChange.new_value, DatamodelChange.prior_value - goes through StatifierUI.Value.encode/1; its {:error, _} is propagated, never rescued. Everything structural is reduced first (decision 6): MapSets to sorted lists (configuration/1), atoms to "kind"-tagged objects (origin/1, owner/1). This ordering matters - StatifierUI.Value.encode/1 rejects any term outside predicator's closed value domain with {:error, {:unsupported_value, term}}, so handing it a raw tuple or a bare atom would produce a wrong error (:unsupported_value instead of a normalizer-specific one) rather than the structural, purpose-built handling this module gives those shapes. A %Statifier.Evaluator.Error{} in Event.data gets the same structural treatment, one step further: it never reaches StatifierUI.Value.encode/1 at all, because it is not a predicator value - it is reduced by StatifierUI.Trace.Diagnostic.object/4 into the wire error object and lands on the event's "error" key instead of "data". The two keys are alternatives, never siblings. An error.execution or error.communication event's data gets the same treatment for the same reason (ADR-0014): the engine's raise site puts an unconstrained reason term there, StatifierUI.Value.encode/1 rejects the tagged tuples it is in practice, and the whole message used to be dropped rather than rendered. It is reduced by StatifierUI.Trace.Diagnostic.reason_object/4 onto the same "error" key instead.

Absence

_event.data keeps ADR-0005's three-way rule unchanged: :undefined omits the key, nil is present as JSON null, %{} is present as {} (put_defined/3). DatamodelChange.new_value and DatamodelChange.prior_value follow the same three-way rule, because they share _event.data's property: the engine spells "unbound" as :undefined there (ADR-0037), so nil genuinely means a stored null. Every other nullable field in this vocabulary has no engine-side way to distinguish "no value" from "a genuinely null value" - Statifier.Effect.Log.value and the two donedata fields default to plain nil for "nothing here", never :undefined - so put_present/3 and put_value/3 generalize the rule (decision 5) by treating nil and :undefined alike as absence for those fields, applied uniformly instead of remembered clause by clause.

Skipped trace effects

normalize/2 has a third answer, :skip, for an engine trace effect the v1 wire format deliberately does not carry. It is not a failure and it is not an unknown effect: the set is closed, named in @skipped_trace_effects, and every member has been looked at and ruled out of v1 on purpose. Today the set is Statifier.Effect.Trace.CondsEvaluated alone - statifier 2.5.0's guard seam, emitted once per selection round that evaluated a written cond. The vocabulary has no guard-evaluation message to map it onto, and inventing one is a wire-format change with an ADR in front of it (sui-e41 will add trace.conds_evaluated), so until then the producer passes over the effect rather than refusing the stream it appears in. Refusing it is what this skip replaces: a chart with a single guarded transition used to fail the whole offline replay on its first branch.

The fallthrough that answers {:error, {:unknown_effect, _}} is untouched, and deliberately so - a trace effect nobody has considered still refuses.

Summary

Types

What normalize/2 needs beyond the effect itself: the emitting session and the seq to stamp, always required. :machine and :source are optional - present only when the caller (StatifierUI.Trace.Subscriber) can supply the compiled machine and the chart text, which is what lets StatifierUI.Trace.Diagnostic.object/4 resolve an absolute location for an %Evaluator.Error{} event. Without them the error object still carries kind/expression/span; only location/location_kind are omitted. Optional rather than required on purpose: every existing test in this module constructs ctx as a two-key struct literal, and the moduledoc's "no process, no session, no %Statifier.Machine{}" claim stays true for the rest of the vocabulary.

Everything normalize/2 accepts: a bare effect, the {:effect, _} wrapper the session sends its subscribers, and the two lifecycle messages. The outer {:statifier, session_id, _} envelope is never accepted here - unwrapping it is the subscriber's job, and keeping it out is what makes this module testable from a struct literal.

Functions

Normalizes one input() into a %Message{} stamped with ctx's session and seq.

The closed, sorted list of every type string this format defines - the vocabulary's single definition site in code. 24 entries: 9 trace.*, 10 effect.* (the nine core effects plus effect.datamodel_change, from Statifier.Effect.DatamodelChange), and 5 session.* (the four lifecycle types plus session.datamodel, emitted once per session from Statifier.Effect.DatamodelInit). docs/wire-format.md's type index table is the same 24, and test/statifier_ui/trace/wire_format_spec_test.exs asserts the two sets are equal.

Types

ctx()

@type ctx() :: %{
  :session => String.t(),
  :seq => non_neg_integer(),
  optional(:machine) => Statifier.Machine.t(),
  optional(:source) => String.t()
}

What normalize/2 needs beyond the effect itself: the emitting session and the seq to stamp, always required. :machine and :source are optional - present only when the caller (StatifierUI.Trace.Subscriber) can supply the compiled machine and the chart text, which is what lets StatifierUI.Trace.Diagnostic.object/4 resolve an absolute location for an %Evaluator.Error{} event. Without them the error object still carries kind/expression/span; only location/location_kind are omitted. Optional rather than required on purpose: every existing test in this module constructs ctx as a two-key struct literal, and the moduledoc's "no process, no session, no %Statifier.Machine{}" claim stays true for the rest of the vocabulary.

input()

@type input() ::
  Statifier.Effect.t()
  | {:effect, Statifier.Effect.t()}
  | {:halted, :done | :cancelled | :budget_exhausted}
  | {:unroutable, Statifier.Effect.t()}

Everything normalize/2 accepts: a bare effect, the {:effect, _} wrapper the session sends its subscribers, and the two lifecycle messages. The outer {:statifier, session_id, _} envelope is never accepted here - unwrapping it is the subscriber's job, and keeping it out is what makes this module testable from a struct literal.

Functions

normalize(effect, ctx)

@spec normalize(input(), ctx()) ::
  {:ok, StatifierUI.Trace.Message.t()} | :skip | {:error, term()}

Normalizes one input() into a %Message{} stamped with ctx's session and seq.

Returns {:error, {:unknown_effect, tag}} for a tag this module does not know, rather than skipping it - a new engine effect appearing silently is exactly what ADR-0005's Consequences warn a drift test must catch. It is the caller's job to decide what to do with the error.

Returns :skip for an engine trace effect the wire format deliberately does not carry - the closed @skipped_trace_effects set, today Statifier.Effect.Trace.CondsEvaluated alone. :skip is not a failure: the effect produced no message because v1 has no message for it, so a caller consumes no seq for it, records no error, and carries on. See this module's "Skipped trace effects" section. A caller matching only {:ok, _} and {:error, _} needs a third clause.

types()

@spec types() :: [String.t()]

The closed, sorted list of every type string this format defines - the vocabulary's single definition site in code. 24 entries: 9 trace.*, 10 effect.* (the nine core effects plus effect.datamodel_change, from Statifier.Effect.DatamodelChange), and 5 session.* (the four lifecycle types plus session.datamodel, emitted once per session from Statifier.Effect.DatamodelInit). docs/wire-format.md's type index table is the same 24, and test/statifier_ui/trace/wire_format_spec_test.exs asserts the two sets are equal.