Raxol.Harness.RecencyPolicy (Raxol v2.6.1)

View Source

Turn recency -> per-block prominence: the policy that decides WHICH prominence (0.0..1.0) a transcript block gets, based purely on how many turns behind the current one its own turn is. This module is pure turn-identity arithmetic -- no processes, no clocks, no config reads, no rendering. It feeds context[:prominence] into Raxol.UI.Components.Harness.Block.render/2 (Raxol.UI.Harness.Prominence.resolve/3 underneath), but never calls either.

The ladder

turns_behind steps down a fixed four-tier ladder, floored at floor/0 (0.4):

turns_behind:   0     1     2     3+
prominence:    1.0   0.8   0.6   0.4

nil or negative turns_behind -- an unknown or (defensively) future-relative position -- always resolves to 1.0, never something darker: an ungraded block is never demoted below full prominence by this policy's own uncertainty.

Why these values (the ladder's provenance)

The four tiers are not new: they are the shipped salience ladder already pinned by Raxol.UI.Harness.Prominence -- 0.6 is the ordinary-context tier (Prominence.needs_input_floor/0 floors awaiting-input content exactly there), the 1.0/0.6 pair is pinned by regression to survive 256-color quantization as distinct palette indices, and 0.4 is that ladder's own floor. This policy was ratified with an explicit "no new tiers" fence: it maps recency onto the EXISTING ladder, one uniform 0.2 step per turn behind, and any retuning of the tier values themselves belongs to the solver-side ladder (and its pending human-eye ratification pass, see the Prominence moduledoc), never to this mapping.

Seal-time grading (the substrate law)

Prominence for a block is decided exactly once, at the moment the block is painted/sealed, and is never re-graded afterward -- the print-once history substrate this framework paints through cannot repaint scrollback (see Raxol.Harness.Surface's own "seal / seal-once" glossary entry). A block sealed during its own turn seals at 1.0 and does NOT fade when the next turn starts; it is not re-visited once painted.

The fade ladder is therefore visible wherever MULTIPLE turns are painted in one pass -- a full re-render, a reattach rebuild, the live region -- while incrementally sealed inline history keeps its seal-time grade forever, unchanged by anything that happens afterward. This is the honest, substrate-constrained reading of the ratified "scoped by policy" clause: the policy grades a block once, when it is painted; there is no retroactive re-grading, and no mechanism in this module (or its one caller, Raxol.Harness.Surface.render_block_lines/3) ever asks "what would this block's prominence be now?" for an already-sealed block.

Composition with the needs-input floor

This policy only produces the RECENCY prominence -- it has no opinion on, and never special-cases, content that is awaiting user input. That promotion lives one layer below, in Raxol.UI.Components.Harness.Block and Raxol.UI.Harness.Prominence: a live :approval block auto-passes needs_input: true into its render context, and Prominence.resolve/3 floors the EFFECTIVE prominence at Prominence.needs_input_floor/0 (0.6) before the fade runs. So a pending approval this policy grades at, say, 0.4 (three-plus turns behind) still renders no dimmer than the 0.6 ordinary-context tier -- an approval outranks its own ladder tier automatically, with no needs-input branch anywhere in this module.

Scope note: on today's sole wiring point (the fixture-replay surface's seal path) this composition is dormant -- the block builder constructs every block already :sealed, and the seal frontier holds a live approval back from painting at all, so a live approval never reaches the graded path there; it lives in the repaintable footer instead. The floor composition is real and tested at the Prominence layer, and engages the moment a live-rendering surface grades live blocks.

Coverage today (an honest scope note)

The grade is threaded through Raxol.UI.Components.Harness.BlockBody's render context for EVERY block, but only Block.render/2 -- the folded path, and every fallback -- resolves context[:prominence] into a fade today. The expanded rich body components mounted via Raxol.UI.Components.Harness.BodyProvider do not yet thread the key (a pre-existing gap in those components, not in this policy or its wiring); when they grow that support, the grade is already there in their context, unchanged.

Turn identity is opaque

turn_ids/turn_id are opaque terms (atoms in tests, strings on the wire) compared only with == -- this module has no notion of a turn's internal shape, ordering by VALUE, or timestamps. "Recency" here means purely POSITION in first-seen order among the turns actually present, never wall-clock or ts distance.

Summary

Types

A resolved prominence value, 0.0..1.0.

An opaque turn identifier -- compared only with ==.

Functions

The floor of the recency fade ladder (0.4) -- the prominence a block three-or-more turns behind the current one renders at.

Grades turn_ids (the per-block list of turn identifiers, in transcript order) against current_turn, returning a same-length list of ladder prominences.

Convenience for the render path: grades block (matched loosely as %{event_refs: refs} so both Raxol.UI.Components.Harness.Block.t() and a plain map with that key work) against events -- typically projection.source_events, the retained durable events a Raxol.Harness.Projection was built from.

Batch form of grade_block/2: grades EVERY block in blocks against events with a SINGLE walk over the event list -- O(events + total_refs) for the whole batch, where the per-block form re-derives the (identical) turn order and current turn from the full event list on every call.

Maps turns_behind (a non-negative integer distance from the current turn) onto its ladder prominence: 0 -> 1.0, 1 -> 0.8, 2 -> 0.6, 3 or more -> 0.4 (the floor). nil or a negative integer -- an unknown or defensively-clamped position -- always resolves to 1.0: this policy's own uncertainty never reads as "darker."

Types

prominence()

@type prominence() :: float()

A resolved prominence value, 0.0..1.0.

turn_id()

@type turn_id() :: term()

An opaque turn identifier -- compared only with ==.

Functions

floor()

@spec floor() :: prominence()

The floor of the recency fade ladder (0.4) -- the prominence a block three-or-more turns behind the current one renders at.

grade(turn_ids, current_turn)

@spec grade([turn_id() | nil], turn_id() | nil) :: [prominence()]

Grades turn_ids (the per-block list of turn identifiers, in transcript order) against current_turn, returning a same-length list of ladder prominences.

Turn ORDER is the distinct non-nil turn ids in turn_ids, in first-seen order (never wall-clock, never a separate turn list -- exactly the turns actually present in this transcript). A block's turns_behind is position(current_turn) - position(block_turn) in that order.

Caller contract (load-bearing): turn_ids must be in transcript order. Positional recency is meaningless on a reordered list -- a turn that appears first IS oldest to this function, whatever its id looks like. This is a documented precondition, not something this pure function can validate (turn ids are opaque; there is no order-independent notion of "older" to check against).

Rules (each a documented guarantee, never darker than honest uncertainty warrants):

  • current_turn is nil -- no honest way to grade -- every block grades 1.0.
  • current_turn absent from the order (a brand-new turn with no blocks yet in turn_ids) is treated as one position NEWER than the newest listed turn -- every existing block is graded one tier further behind than it would be if that new turn already had an entry.
  • A block's own turn id nil grades that block 1.0 (nothing to grade against).
  • A block's turn NEWER than current_turn (shouldn't happen in a well-formed transcript; handled defensively) clamps turns_behind to 0 -> 1.0, rather than extrapolating a "prominence above 1.0" that has no meaning.

grade_block(arg1, events)

@spec grade_block(map(), [map()]) :: prominence()

Convenience for the render path: grades block (matched loosely as %{event_refs: refs} so both Raxol.UI.Components.Harness.Block.t() and a plain map with that key work) against events -- typically projection.source_events, the retained durable events a Raxol.Harness.Projection was built from.

Derivation (defensive throughout -- a non-map entry in events is skipped, never raised on; the whole derivation is a SINGLE pass over events, so grading one block costs one walk, never three):

  • the block's own turn is the :turn_id of the FIRST event in events whose :id is in block.event_refs (nil if event_refs is empty or nothing matches);
  • the current turn is the :turn_id of the LAST event in events that carries a non-nil :turn_id (nil if none do);
  • the turn order is the distinct non-nil :turn_ids across events, in first-seen order.

Empty events, an unresolvable block turn, or a nil current turn all fall out of the same grade/2 core as 1.0 -- see that function's own rule list.

Input contract (load-bearing, guaranteed upstream for the intended feed): events must be journal/ingest-ordered and complete for the blocks being graded. Both hold for projection.source_events by construction: Raxol.Harness.Projection.Recovery.filter_ids/1 drops duplicate/out-of-order ids BEFORE retention (so the retained list is id-monotonic), and source_events retains EVERY durable event for the session un-windowed (only :ephemeral tier -- item_delta traffic, which never enters a block's event_refs -- is excluded), so every block built by the projection resolves its turn here. A caller feeding a reordered or windowed list violates the contract; each violation degrades toward 1.0 (never darker), per the same uncertainty rule as everything else in this module.

grade_blocks(blocks, events)

@spec grade_blocks([map()], [map()]) :: [prominence()]

Batch form of grade_block/2: grades EVERY block in blocks against events with a SINGLE walk over the event list -- O(events + total_refs) for the whole batch, where the per-block form re-derives the (identical) turn order and current turn from the full event list on every call.

Equivalence law (tested): grade_blocks(blocks, events) is exactly Enum.map(blocks, &grade_block(&1, events)) -- same input contract, same defensive fallbacks, only the cost differs. This is the entry point a bulk paint should use: a full re-render, a reattach rebuild, or any pass that grades a whole projection at once. The incremental seal path (Raxol.Harness.Surface.render_block_lines/3) keeps the per-block form because its hold-back-one design seals a bounded number of blocks per advance.

prominence(turns_behind)

@spec prominence(integer() | nil) :: prominence()

Maps turns_behind (a non-negative integer distance from the current turn) onto its ladder prominence: 0 -> 1.0, 1 -> 0.8, 2 -> 0.6, 3 or more -> 0.4 (the floor). nil or a negative integer -- an unknown or defensively-clamped position -- always resolves to 1.0: this policy's own uncertainty never reads as "darker."