Raxol.Harness.Projection (Raxol v2.6.1)

View Source

The journal-fold projection: durable events -> an ordered block list; ephemeral item_delta -> a live-tail state, never a durable block.

Roadmap unit T7 (journal-fold projection). Test design covers P-DET/ P-TIER/P-FOLD properties, N-ADV/N-DORM/N-SEAL/N-FWD negatives, and the recovery policy table documented inline across this module, Raxol.Harness.Projection.Recovery, and Raxol.Harness.Projection.BlockBuilder.

Pipeline

project/2 accepts either a Raxol.Harness.Fixture.Session (loaded from a .jsonl fixture) or a plain list of event-shaped maps (the property-test generators build these directly, matching the fixture wire shape -- string-keyed payloads, atom top-level fields):

  1. Raxol.Harness.Projection.Recovery.filter_ids/1 -- global id-monotonic recovery: duplicate/out-of-order (N-ADV-02/03) are dropped; a forward gap (a dense-id journal missing one or more ids, e.g. 1, 2, 5 with 3 and 4 never arriving) is accepted -- soft-render, survivor blocks are never withheld -- but sets damaged?: true (hard-mark) on the returned tuple, threaded onto the %__MODULE__{} struct's damaged field. Mirrors Raxol.Agent.Journal's own status/1 :: :damaged contract: the journal fails closed on interior corruption upstream, so a gap reaching this filter means events were lost somewhere between journal and here -- diagnose and mark, don't withhold, so a downstream consumer (T18) can refuse a gapped tail as canonical.
  2. Recovery.partition_families/1 -- :loop vs :meta vs untyped/unrecognised (N-DORM-04, including a non-atom :type on an otherwise-loop record); only :loop events ever become blocks.
  3. Recovery.bucket_by_turn/1 -- groups by turn_id in first-seen order (N-ADV-05), independent of raw arrival order.
  4. Raxol.Harness.Projection.BlockBuilder.build_turn/3 per turn -- folds items into blocks, drops late deltas (N-SEAL), caps the live-tail delta buffer for an item that never completes, flags orphans/unknown kinds recovered (N-ADV-04, N-FWD-01), merges well-formed tool_use+tool_result pairs into one :tool_call block and fixes its duration span (the STATE-note bug: Block's own duration_from_timestamps/1 only sees the first started/completed pair in a merged list). Also attaches completion evidence to a closed turn's last block (see BlockBuilder's moduledoc, "Completion evidence"), resolving refs against the SESSION-wide id -> event index built here in step 0 below -- a ref is a session-scoped journal offset, not a turn-scoped one (the frozen offset law), so it may legitimately point at an earlier turn's event.

Step 0 (implicit, ahead of the numbered list): this function builds one id -> event map over the id-recovered stream (id_ok, the output of step 1, before family partition or turn bucketing) and threads it into every turn's BlockBuilder.build_turn/3 call -- completion-evidence resolution is the only consumer today, but the index covers the whole durable+ephemeral id-recovered set, not a per-turn slice, so any ref reachable by id resolves regardless of which turn built the block it names.

Every recovered condition -- duplicate, out-of-order, forward gap, orphan, late delta, capped delta buffer, missing turn_started, untyped record, unknown item_type -- emits [:raxol, :harness, :projection, :recovered] telemetry via Recovery.emit/2. Recovery is never silent; the resulting durable_block_list is always deterministic (same input, same output, no ambient state).

The damaged field

true iff Recovery.filter_ids/1 detected at least one forward id gap in the input. This is projection-level METADATA, not transcript content: it is not part of either transcript_identity/1 or identity/1 (both operate on blocks/fold_defaults only, never on this field), so a gap detection can never perturb either identity key -- exactly like content.recovered/content.recovered_reasons are excluded from transcript_identity/1. Hard mark (the struct flag), soft render (the survivor blocks are still returned in full).

Retained raw events (STATE note)

source_events holds every event that survived the id-recovery pass and is NOT :ephemeral tier -- both families, but durable-only. D-PA policy (B) soft-owned-history re-emission and retroactive opaque-kind recognition both need to re-fold from the original DURABLE events, not just the block structs (P-TIER-03: ephemeral deltas never affect identity); refold/2 re-runs the pipeline over that retained list with no fixture re-read. One consequence: refolding a projection whose session had an item still accumulating deltas at capture time yields an EMPTY tail post-refold (the ephemeral chunks that fed it are gone) -- acceptable, since refold/2 rebuilds durable history, not the live stream, and identity/1/transcript_identity/1 never look at tail.

Two identity keys — pick the one that matches the question

There are two distinct "is this the same?" questions, and conflating them churns downstream diffs. Both key blocks by event_refs and exclude a block's mutable fold field (a UI-local toggle never perturbs either key — leak-guard #1).

transcript_identity/1 — the reattach-consistency key (T18)

"Is this the same conversation?" The block list, each block reduced to {kind, raw_kind, event_refs, seal, outcome, content} with content stripped of :recovered / :recovered_reasons, and fold_defaults excluded entirely. Rationale:

  • fold_defaults is a per-surface display preference — two surfaces reattaching the same journal needn't agree on folding, so it is not part of "same transcript".
  • :recovered / :recovered_reasons are recovery metadata, not visible transcript content — a recovery re-annotation must not make "same transcript?" answer false.

This is the key T18's restoration-diff-on-reattach uses: without the strips it would churn on every fold toggle or recovery re-annotation.

content[:completion] is NOT stripped here. Unlike :recovered/ :recovered_reasons (recovery metadata about how THIS projection survived an anomaly), a block's :completion -- set by Raxol.Harness.Projection.BlockBuilder.build_turn/3 on a turn's last block when that turn closed with a final turn_completed -- is genuine transcript content: whether a "done" claim carried evidence (or explicitly carried none, see that module's moduledoc) IS part of what was said, not projection-recovery bookkeeping. It is therefore included, verbatim, in BOTH transcript_identity/1 and identity/1; a completion appearing, disappearing, or gaining/losing a ref is a real "is this the same conversation?" change, same as any other content field.

identity/1 — the T13a regression FREEZE key

identity(fixture) = {transcript_identity_blocks, fold_defaults}

The transcript block shape plus fold_defaults. Here a fold_default change is a wanted diff (leak-guard #2) — it's the reviewed tripwire the <name>.t7blocks.json snapshot freezes. Use this for the golden-snapshot regression gate, transcript_identity/1 for "did the conversation change".

Explicitly NOT in either key: UI-local fold toggles, scroll position, the live tail buffer, ephemeral deltas, salience/prominence (D-PA scoped, tested elsewhere), meta-family events.

The reattach convergence invariant (load-bearing for T18)

Two surfaces attaching at different journal offsets converge to the same transcript_identity/1 only when replay is in journal-offset order — ids strictly monotonic, physical offset canonical. Replaying from a peer's live tail (whose transient, mid-stream out-of-order drops and late-delta discards differ from a clean journal walk) is NOT guaranteed to converge. The journal is the source of truth; the tail is a throwaway preview. T18's restoration diff must always rebuild from the durable journal in offset order, never from another surface's tail.

Summary

Functions

The T13a regression FREEZE key: {transcript_identity_blocks, fold_defaults}. Unlike transcript_identity/1, a fold_default change here is a wanted diff (leak-guard #2) — this is what the <name>.t7blocks.json snapshot freezes. See the moduledoc for both keys' contracts.

Projects a fixture Session (or a plain list of event-shaped maps) into a t(). Pure: identical input + options always produce an identical result (P-DET-01).

Re-runs the projection over its own retained source_events -- the re-fold hook D-PA policy (B) and retroactive opaque-kind recognition need (STATE note). No fixture re-read; opts can supply a different :fold_defaults for the re-fold.

The last durable block -- the tip a resumed/rebuilt view must land on. Since blocks never contains a meta or untyped record (they're excluded before block-building ever runs), this is always the last legitimately conversational block, never a non-conversational record (N-DORM / FI-12 mirrored).

The reattach-consistency key (T18): the block list, each block keyed by event_refs and reduced to {kind, raw_kind, event_refs, seal, outcome, content} with content stripped of :recovered / :recovered_reasons. Excludes fold_defaults (a per-surface display preference) and the mutable fold field. This is the "is this the same conversation?" key — see the moduledoc's convergence invariant.

Types

t()

@type t() :: %Raxol.Harness.Projection{
  blocks: [Raxol.UI.Components.Harness.Block.t()],
  damaged: boolean(),
  diagnostics: [Raxol.Harness.Projection.Recovery.diagnostic()],
  fold_defaults: %{
    optional(Raxol.UI.Components.Harness.Block.kind()) =>
      Raxol.UI.Components.Harness.Block.fold_state()
  },
  source_events: [map()],
  tail: %{optional({turn_id :: term(), item_id :: term()}) => tail_entry()}
}

tail_entry()

@type tail_entry() :: %{item_type: term(), turn_id: term(), chunks: [String.t()]}

Functions

identity(projection)

@spec identity(t()) :: {[map()], map()}

The T13a regression FREEZE key: {transcript_identity_blocks, fold_defaults}. Unlike transcript_identity/1, a fold_default change here is a wanted diff (leak-guard #2) — this is what the <name>.t7blocks.json snapshot freezes. See the moduledoc for both keys' contracts.

project(session_or_events, opts \\ [])

@spec project(
  Raxol.Harness.Fixture.Session.t() | [map()],
  keyword()
) :: t()

Projects a fixture Session (or a plain list of event-shaped maps) into a t(). Pure: identical input + options always produce an identical result (P-DET-01).

Options:

  • :fold_defaults -- a %{Block.kind() => Block.fold_state()} map overriding Block.default_fold/1 per kind. Part of identity/1 -- see the moduledoc.

refold(projection, opts \\ [])

@spec refold(
  t(),
  keyword()
) :: t()

Re-runs the projection over its own retained source_events -- the re-fold hook D-PA policy (B) and retroactive opaque-kind recognition need (STATE note). No fixture re-read; opts can supply a different :fold_defaults for the re-fold.

tip(projection)

@spec tip(t()) :: Raxol.UI.Components.Harness.Block.t() | nil

The last durable block -- the tip a resumed/rebuilt view must land on. Since blocks never contains a meta or untyped record (they're excluded before block-building ever runs), this is always the last legitimately conversational block, never a non-conversational record (N-DORM / FI-12 mirrored).

transcript_identity(projection)

@spec transcript_identity(t()) :: [map()]

The reattach-consistency key (T18): the block list, each block keyed by event_refs and reduced to {kind, raw_kind, event_refs, seal, outcome, content} with content stripped of :recovered / :recovered_reasons. Excludes fold_defaults (a per-surface display preference) and the mutable fold field. This is the "is this the same conversation?" key — see the moduledoc's convergence invariant.