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):
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 setsdamaged?: true(hard-mark) on the returned tuple, threaded onto the%__MODULE__{}struct'sdamagedfield. MirrorsRaxol.Agent.Journal's ownstatus/1 :: :damagedcontract: 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.Recovery.partition_families/1--:loopvs:metavs untyped/unrecognised (N-DORM-04, including a non-atom:typeon an otherwise-loop record); only:loopevents ever become blocks.Recovery.bucket_by_turn/1-- groups byturn_idin first-seen order (N-ADV-05), independent of raw arrival order.Raxol.Harness.Projection.BlockBuilder.build_turn/3per 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-formedtool_use+tool_resultpairs into one:tool_callblock and fixes its duration span (the STATE-note bug: Block's ownduration_from_timestamps/1only sees the first started/completed pair in a merged list). Also attaches completion evidence to a closed turn's last block (seeBlockBuilder's moduledoc, "Completion evidence"), resolving refs against the SESSION-wideid -> eventindex 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_defaultsis 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_reasonsare 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
@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()} }
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.
@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 overridingBlock.default_fold/1per kind. Part ofidentity/1-- see the moduledoc.
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.
@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).
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.