StatifierUI.Trace.Normalizer (StatifierUI v0.9.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.

The retired third answer

normalize/2 used to have a third answer, :skip, for an engine trace effect the wire format deliberately did not carry - closed, named, and distinct from an unknown effect. It had exactly one member, Statifier.Effect.Trace.CondsEvaluated, skipped by sui-9fs because v1 had no guard-evaluation message to map it onto. ADR-0018 added that message, sui-e41 moved the effect onto the mapping clause below, and the answer retired with its last member: nothing in this module returns :skip, and normalize/2 no longer offers it.

It was retired rather than kept empty because the empty form is not writable here, in either half. Keep the clause with an empty list and module in [] expands to false, so the pattern's own binding is never read and the compiler answers variable "module" is unused; drop the clause and keep the constant instead and it answers module attribute @skipped_trace_effects was set but never used. Both are fatal under warnings_as_errors, and a contract offering a return no clause produces makes every caller's third arm dead code the analyzer refuses. The next trace effect ruled out of the vocabulary reintroduces the answer with the clause that produces it, which is the only form that stays honest.

The fallthrough that answers {:error, {:unknown_effect, _}} is untouched, and deliberately so - a trace effect nobody has considered still refuses. That refusal is the mechanism the skip was always distinct from, and it is the half that has members.

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. 25 entries: 10 trace.* (the nine Appendix D phase boundaries plus trace.conds_evaluated, the guard seam inside selection, from Statifier.Effect.Trace.CondsEvaluated), 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 25, 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()} | {: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.

There is no third answer. normalize/2 returned :skip while one engine trace effect had no message to map onto; ADR-0018 gave it one, so every effect this module accepts now produces a message or an error. See this module's "The retired third answer" section.

types()

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

The closed, sorted list of every type string this format defines - the vocabulary's single definition site in code. 25 entries: 10 trace.* (the nine Appendix D phase boundaries plus trace.conds_evaluated, the guard seam inside selection, from Statifier.Effect.Trace.CondsEvaluated), 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 25, and test/statifier_ui/trace/wire_format_spec_test.exs asserts the two sets are equal.