feat(panel): the authoritative cell-mapping key (G1-G4)

The key itself, its domain, and the witnesses for both. No gating and no
replay yet.

**DERIVED FROM A FINGERPRINT, NOT BUMPED AT MUTATION SITES.**
`panel_mapping_fingerprint` hashes what actually decides which byte a
cell means --- buffer identity, grid rows and columns, `view_top`,
`view_left`, wrap mode, content columns, fold POLICY and fold CONTENT,
and the buffer's content revision --- and the generation advances
whenever that changes. This makes the changing/stable split
STRUCTURAL: an input that is hashed moves the key by construction, and
one that is not cannot. Bumping by hand at each mutation site would
have made "advances after any mapping mutation" a promise about
someone remembering.

Folds are hashed at their SOURCE, the registry's ranges, rather than
through the derived `VisibleLineMap`, whose only public summary is
`is_identity()` --- too coarse, since a fold edit that leaves the map
non-identity still changes which source line a row shows.

**ONE SEAM, READ BY BOTH SIDES.** `panel_mapping_generation` advances
if the fingerprint changed and returns the current value; projection
will stamp with it and inbound validation will compare against it, so
"what the frontend was shown" and "what the daemon checks" cannot
drift. Computed ON DEMAND, deliberately: a mutation not yet painted has
still changed the inverse, and a gesture arriving in that gap must be
refused. Deriving from the last emitted frame recreates the hole.

**Nondecreasing, and never cleared.** `Absent` yields no key to stamp
--- which is not a key of zero --- but the high-water mark survives, so
a frame delayed across a hide cannot roll authority backward. First
establishment takes 1; zero is the wire's invalid value.

**MUTATION TESTING FOUND MY WITNESSES UNDER-SPECIFIED, TWICE.**

With only the content-edit row present, dropping `view_left` from the
key stayed GREEN, and so did collapsing the grid to `rows * cols`. That
is exactly what the closure predicted --- "a key that ignores
`view_left` passes every row that only scrolls vertically" --- and it
is why G2 is enumerated per input rather than asserted in aggregate.
Six legs now, one per input, each touching only its own.

Mutations that bite: omit the content revision (3 rows), omit
`view_left`, omit wrap, omit fold policy, and include the CURSOR --- a
stable input, caught by G3.

**One mutation does NOT bite, and the row says so rather than
pretending.** Collapsing rows and columns to a product stays green,
because `last_content_cols` co-varies with a column change and the row
count co-varies with a resize: the key moves by another input either
way. Only a transposition (2x6 -> 6x2, identical product) would isolate
it, and no production path reaches one --- rows come from the band's
height, columns from the frame declaration, and nothing swaps them. The
key hashes them separately anyway; hashing a product because no test
can currently tell the difference would be choosing the weaker
construction for the suite's convenience.

G4b --- that an in-flight drag survives a selection repaint through
real replay --- stays owed by the rebased replay lane, which is the only
branch where replay exists.

Verified: focused suite 34/34, `cargo test --lib` green, clippy clean.
Per the standing procedure the eleven-stage `--protocol` gate is
reserved for the next coherent checkpoint.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
This commit is contained in:
Levi Neuwirth 2026-08-15 13:36:24 +02:00
parent 758b985c35
commit 063db52555
No known key found for this signature in database
3 changed files with 439 additions and 0 deletions

View File

@ -2452,6 +2452,80 @@ impl EditorState {
true true
} }
/// Fingerprint of the panel's **inverse mapping** for one frontend
/// (§5b, Q#BP-R3).
///
/// This is the whole of "what decides which byte a cell means". A
/// generation is derived from it by advancing whenever it changes,
/// which makes the changing/stable split **structural** rather than
/// a list of bump sites someone must remember to touch: an input
/// that is hashed moves the key by construction, and one that is not
/// cannot.
///
/// **Deliberately EXCLUDED**, and each exclusion is a contract:
/// focus, styling and theme, the cursor, and the selection. None of
/// them changes which byte a cell denotes, and a drag provokes
/// selection repaints on every motion — a key that moved with them
/// would cancel the gesture it is meant to protect after one step.
///
/// Returns `None` when there is no presentable panel, which is not
/// the same as a zero key: absence of a mapping is not a mapping.
pub fn panel_mapping_fingerprint(&self, frontend_id: FrontendId) -> Option<u64> {
use std::hash::{Hash, Hasher};
let core = self.core.borrow();
let size = core.panel_grid_size(frontend_id)?;
let side = core.side_window_for(frontend_id)?;
let window = core.windows.get(&side)?;
let mut hasher = std::collections::hash_map::DefaultHasher::new();
// Identity first: a different buffer is a different mapping even
// if every geometry input coincides.
window.buffer_id.hash(&mut hasher);
// Grid ROWS and COLUMNS hashed separately, not as an area — a
// 2x6 and a 6x2 panel invert differently.
size.rows.hash(&mut hasher);
size.cols.hash(&mut hasher);
// Viewport, both axes. `view_left` matters from GUI arc 1b
// onward, and is hashed now so the key does not need revisiting
// when horizontal scrolling starts moving it.
window.view_top.hash(&mut hasher);
window.view_left.hash(&mut hasher);
// Wrap mode and the content width that the gutter reservation
// has already been subtracted from: together these decide how a
// source line is broken into display rows and where column zero
// sits.
window.last_wrap.hash(&mut hasher);
window.last_content_cols.hash(&mut hasher);
// Fold POLICY and fold CONTENT are separate inputs. The policy
// belongs to the owning frontend's view; the content belongs to
// the window. Either alone can change which source line a grid
// row shows.
core.views
.get(&frontend_id)
.is_some_and(|view| view.fold_projection)
.hash(&mut hasher);
// Hashed at their SOURCE — the registry's ranges — rather than
// through the derived `VisibleLineMap`, whose only public
// summary is `is_identity()`. That would be too coarse: a fold
// edit that leaves the map non-identity would not move the key
// while plainly changing which source line a row shows.
for range in core.fold_registry.folds(window.buffer_id) {
range.start.hash(&mut hasher);
range.end.hash(&mut hasher);
}
// Content. A foreign edit moves the mapping with every geometry
// input untouched, and is the case the epoch ladder cannot see.
let registry = core.registry.clone();
let revision = registry
.borrow()
.get(window.buffer_id)
.ok()
.map(crate::buffer::Buffer::revision);
revision.hash(&mut hasher);
Some(hasher.finish())
}
/// Paint one semantic frontend's side window into a panel-sized grid /// Paint one semantic frontend's side window into a panel-sized grid
/// (Q#BP8, Q#BP15, Q#BP15a, Q#BP17). /// (Q#BP8, Q#BP15, Q#BP15a, Q#BP17).
/// ///

View File

@ -377,6 +377,26 @@ pub struct SemanticRenderState {
/// retains its last valid frame and silence would leave a stale band /// retains its last valid frame and silence would leave a stale band
/// on screen indefinitely (Q#BP15). /// on screen indefinitely (Q#BP15).
last_panel_payload: Option<PanelFramePayload>, last_panel_payload: Option<PanelFramePayload>,
/// §5b — the authoritative **cell-mapping key** for this frontend.
///
/// `(fingerprint, generation)`. The generation advances whenever the
/// fingerprint changes, and **both projection and inbound
/// validation read it through the same accessor**, so "what the
/// frontend was shown" and "what the daemon checks" cannot drift.
///
/// It is deliberately **not** recomputed from the last emitted
/// frame: a mapping mutation that has not yet been painted has still
/// changed the inverse, and a gesture arriving in that gap must be
/// refused. Advancing on demand at both seams is what makes
/// "advances before the next inbound pointer, whether or not
/// anything rendered" true rather than aspirational.
///
/// **Nondecreasing, and never cleared** — not even by `Absent`. A
/// delayed lower frame must not roll the producer's authority
/// backward, so this is a high-water mark for the session.
/// `generation` starts at 0 meaning "never established"; the first
/// real mapping takes 1, because zero is invalid on the wire.
panel_mapping: Option<(u64, u64)>,
/// Highest presentation epoch allocated for this session; `0` means /// Highest presentation epoch allocated for this session; `0` means
/// none has been. Advanced only when a frame is actually shipped, so /// none has been. Advanced only when a frame is actually shipped, so
/// a frame that fails validation does not burn an identity the peer /// a frame that fails validation does not burn an identity the peer
@ -575,6 +595,7 @@ impl SemanticRenderState {
// peer already holds. Seeding the baseline keeps the first // peer already holds. Seeding the baseline keeps the first
// frame from shipping a redundant authoritative `Absent`. // frame from shipping a redundant authoritative `Absent`.
last_panel_payload: Some(PanelFramePayload::Absent), last_panel_payload: Some(PanelFramePayload::Absent),
panel_mapping: None,
panel_epoch_used: 0, panel_epoch_used: 0,
panel_presentation: None, panel_presentation: None,
panel_error_latched: false, panel_error_latched: false,
@ -599,6 +620,37 @@ impl SemanticRenderState {
} }
} }
/// Advance-if-changed, then read: the authoritative mapping key.
///
/// **The single seam §5b requires.** Projection stamps the frame
/// with what this returns, and inbound validation compares against
/// what this returns; there is no second derivation to disagree
/// with.
///
/// `fingerprint` is `None` when no panel is presentable. That does
/// **not** reset the key — the high-water mark survives `Absent`,
/// so a frame delayed across a hide cannot come back with a lower
/// generation and be believed.
pub fn panel_mapping_generation(&mut self, fingerprint: Option<u64>) -> Option<u64> {
let fingerprint = fingerprint?;
let next = match self.panel_mapping {
Some((seen, generation)) if seen == fingerprint => generation,
Some((_, generation)) => generation.saturating_add(1),
// First establishment takes 1, never 0: zero is the wire's
// "uninitialised" value and is refused on sight.
None => 1,
};
self.panel_mapping = Some((fingerprint, next));
Some(next)
}
/// The current key without advancing it, for assertions and for
/// callers that must not have a side effect.
#[must_use]
pub fn panel_mapping_generation_peek(&self) -> Option<u64> {
self.panel_mapping.map(|(_, generation)| generation)
}
/// Whether the last shipped declaration is a `Present` whose epochs /// Whether the last shipped declaration is a `Present` whose epochs
/// both match an inbound panel event **and** which still describes /// both match an inbound panel event **and** which still describes
/// the side window that is live now (Q#BP16 steps 2–4). /// the side window that is live now (Q#BP16 steps 2–4).

View File

@ -1362,3 +1362,316 @@ fn sweep_a_panel_wider_than_the_terminal_cap_still_presents_its_terminal() {
// write into their real data root. // write into their real data root.
#[path = "common/iso.rs"] #[path = "common/iso.rs"]
mod iso; mod iso;
// ---------------------------------------------------------------------------
// §5b G1–G4 — the authoritative cell-mapping key
//
// The key is derived from a FINGERPRINT of the inverse mapping's inputs,
// so the changing/stable split is structural: an input that is hashed
// moves the key by construction, and one that is not cannot. These rows
// pin each input individually, because a single "it changed" row cannot
// show WHICH input moved it — and a key that silently ignored, say,
// `view_left` would pass every row that only scrolls vertically.
// ---------------------------------------------------------------------------
/// Edit the panel's buffer from OUTSIDE any gesture — the "foreign
/// edit" the ladder cannot see. Done in Rust rather than Lua because it
/// must be a plain content mutation with no view, cursor or command
/// state attached to it.
fn foreign_edit(session: &Session, text: &str) {
let core = session.state.core.borrow();
let side = core.side_window_for(FID).expect("a side window");
let buffer_id = core.windows[&side].buffer_id;
let registry = core.registry.clone();
let mut reg = registry.borrow_mut();
let buffer = reg.get_mut(buffer_id).expect("the panel's buffer");
buffer
.set_generated_contents(text.as_bytes())
.expect("a generated-contents write is a plain content change");
}
/// The key as the daemon would compute it for `FID`, advancing on change.
fn mapping_generation(session: &mut Session) -> Option<u64> {
let fingerprint = session.state.panel_mapping_fingerprint(FID);
session.render.panel_mapping_generation(fingerprint)
}
/// §5b G1 — a **foreign** edit before the next render moves the key.
///
/// This is the case the whole slice exists for: the epoch ladder cannot
/// see it. No buffer is replaced, no panel reopens, no geometry is
/// re-declared — every epoch holds — and yet the byte under a cell has
/// changed. It is also why the key must be derived on demand rather than
/// from the last emitted frame: nothing has rendered here.
#[test]
fn g1_a_foreign_edit_moves_the_mapping_key_before_anything_renders() {
let mut session = Session::new();
open_panel(&session, "g1", 4);
session.declare(1, 24, 80);
let _ = session.present();
let before = mapping_generation(&mut session).expect("a presentable panel has a key");
assert!(
before >= 1,
"a live key is never zero — zero is the wire's invalid value"
);
// An edit from somewhere other than the gesture's frontend, with no
// render in between.
foreign_edit(&session, "foreign edit\n");
let after = mapping_generation(&mut session).expect("still presentable");
assert!(
after > before,
"a foreign edit changes which byte a cell means, and no epoch \
moves with it — this is the hole the ladder cannot close"
);
}
/// §5b G3 — the **stable** inputs, one row each.
///
/// Every entry here is something that repaints a panel without changing
/// which byte a cell denotes. A drag provokes selection repaints on every
/// motion, so a key that moved with them would cancel the gesture it
/// exists to protect after a single step.
#[test]
fn g3_repaints_that_cannot_move_a_byte_leave_the_key_alone() {
let mut session = Session::new();
open_panel(&session, "g3", 4);
session.declare(1, 24, 80);
let _ = session.present();
let baseline = mapping_generation(&mut session).expect("a key");
// Re-reading with nothing changed at all.
assert_eq!(
mapping_generation(&mut session),
Some(baseline),
"an idle re-read must not advance the key, or every frame would \
cancel every gesture"
);
// Cursor motion the follow rules absorb: the caret moves inside the
// viewport, so no origin moves with it. Set directly, so nothing but
// the cursor changes.
{
let mut core = session.state.core.borrow_mut();
let side = core.side_window_for(FID).expect("a side window");
let window = core.windows.get_mut(&side).expect("the side window");
window.cursor = 0;
}
assert_eq!(
mapping_generation(&mut session),
Some(baseline),
"cursor motion that moves no origin is not a mapping change"
);
}
/// §5b G4a — a **selection-only** repaint preserves the key.
///
/// Split from G3 because it is the one the lifecycle depends on: G4b —
/// that an in-flight drag then continues through real replay — is owed by
/// the rebased replay lane, which is the only branch where replay exists.
#[test]
fn g4a_a_selection_only_repaint_preserves_the_mapping_key() {
let mut session = Session::new();
open_panel(&session, "g4a", 4);
session.declare(1, 24, 80);
let _ = session.present();
let baseline = mapping_generation(&mut session).expect("a key");
foreign_edit(&session, "alpha beta\n");
let after_edit = mapping_generation(&mut session).expect("a key");
assert!(after_edit > baseline, "the edit itself is a mapping change");
// Now a selection, with no content or viewport change.
{
let mut core = session.state.core.borrow_mut();
let side = core.side_window_for(FID).expect("a side window");
let window = core.windows.get_mut(&side).expect("the side window");
window.selection = Some(pmacs::window::Selection { anchor: 0 });
window.cursor = 5;
}
assert_eq!(
mapping_generation(&mut session),
Some(after_edit),
"a selection changes what is HIGHLIGHTED, never what a cell \
denotes — and a drag repaints the selection on every motion"
);
}
/// §5b — the key is a **high-water mark** and survives `Absent`.
///
/// Hiding the band clears input authority, but it must not reset the
/// generation: a frame delayed across the hide would otherwise return
/// with a lower value and be believed.
#[test]
fn the_mapping_key_never_moves_backward_across_a_hidden_panel() {
let mut session = Session::new();
open_panel(&session, "hw", 4);
session.declare(1, 24, 80);
let _ = session.present();
let before = mapping_generation(&mut session).expect("a key");
foreign_edit(&session, "one\n");
let peak = mapping_generation(&mut session).expect("a key");
assert!(peak > before);
// Hide it: no fingerprint, so no advance — and no reset either.
{
let mut core = session.state.core.borrow_mut();
core.views
.get_mut(&FID)
.expect("the frontend's view")
.panel_hidden = true;
}
assert_eq!(
mapping_generation(&mut session),
None,
"no presentable panel means no key to stamp, which is not the \
same as a key of zero"
);
assert_eq!(
session.render.panel_mapping_generation_peek(),
Some(peak),
"the high-water mark SURVIVES the hide — clearing it would let a \
delayed frame roll the producer's authority backward"
);
}
/// §5b G2 — **every changing input, one leg each.**
///
/// Enumerated rather than asserted in aggregate, and mutation testing is
/// what forced it: with only the content-edit row present, dropping
/// `view_left` from the key and collapsing the grid to `rows * cols`
/// both stayed GREEN. A single "the key moved" row cannot show *which*
/// input moved it, and a key that ignores horizontal scrolling passes
/// every row that only scrolls vertically.
#[test]
fn g2_each_input_of_the_inverse_mapping_moves_the_key_on_its_own() {
// Each leg names one input and touches only that input.
type Leg = (&'static str, fn(&Session));
let legs: &[Leg] = &[
("view_top", |session| {
let mut core = session.state.core.borrow_mut();
let side = core.side_window_for(FID).expect("side");
core.windows.get_mut(&side).expect("win").view_top += 1;
}),
("view_left — GUI arc 1b makes this real", |session| {
let mut core = session.state.core.borrow_mut();
let side = core.side_window_for(FID).expect("side");
core.windows.get_mut(&side).expect("win").view_left += 1;
}),
("wrap mode", |session| {
let mut core = session.state.core.borrow_mut();
let side = core.side_window_for(FID).expect("side");
let window = core.windows.get_mut(&side).expect("win");
window.last_wrap = match window.last_wrap {
pmacs::view::WrapMode::Wrap => pmacs::view::WrapMode::Truncate,
pmacs::view::WrapMode::Truncate => pmacs::view::WrapMode::Wrap,
};
}),
(
"content columns — the gutter is subtracted here",
|session| {
let mut core = session.state.core.borrow_mut();
let side = core.side_window_for(FID).expect("side");
core.windows.get_mut(&side).expect("win").last_content_cols += 1;
},
),
("fold PROJECTION POLICY, owned by the view", |session| {
let mut core = session.state.core.borrow_mut();
let view = core.views.get_mut(&FID).expect("view");
view.fold_projection = !view.fold_projection;
}),
("fold CONTENT, owned by the buffer", |session| {
let core = session.state.core.borrow();
let side = core.side_window_for(FID).expect("side");
let buffer_id = core.windows[&side].buffer_id;
let registry = core.registry.clone();
let mut reg = registry.borrow_mut();
let buffer = reg.get_mut(buffer_id).expect("buffer");
let store = core.fold_registry.store_or_attach(buffer);
store
.lock()
.expect("fold store mutex")
.insert(pmacs_protocol::ByteRange { start: 0, end: 1 });
}),
];
for (name, mutate) in legs {
let mut session = Session::new();
open_panel(&session, "g2", 4);
session.declare(1, 24, 80);
let _ = session.present();
let before = mapping_generation(&mut session).expect("a key");
mutate(&session);
let after = mapping_generation(&mut session).expect("a key");
assert!(
after > before,
"changing {name} changes which byte a cell means, so the key \
must move; it did not ({before} → {after})"
);
}
}
/// §5b G2 — grid **rows** and **columns** each move the key.
///
/// **Honest limit, recorded because mutation testing found it:** these
/// two legs do NOT discriminate `rows`/`cols` from their product.
/// Collapsing the key to `rows * cols` leaves both GREEN, because
/// `last_content_cols` co-varies with a column change and the panel's
/// row count co-varies with a resize — the key still moves, by another
/// input. Only a **transposition** (2×6 → 6×2, identical product) would
/// isolate it, and no production path reaches one: rows come from the
/// band's height and columns from the frame declaration, and nothing
/// swaps them.
///
/// The key hashes them separately anyway. That is cheap and correct,
/// and the alternative — hashing a product because no test can currently
/// tell the difference — would be choosing the weaker construction for
/// the convenience of the test suite. What these legs *do* pin is that
/// each dimension moves the key at all, which is what the rest of the
/// slice depends on.
#[test]
fn g2_grid_rows_and_columns_are_independent_inputs() {
// ROWS come from the band's own height, not the frame's total rows —
// declaring a shorter frame leaves a 4-row panel a 4-row panel. The
// resize path is the one that actually changes them.
{
let mut session = Session::new();
open_panel(&session, "g2rows", 4);
session.declare(1, 24, 80);
let _ = session.present();
let before = mapping_generation(&mut session).expect("a key");
assert!(
session.state.apply_panel_resize_rows(FID, 6),
"the resize must be accepted, or this leg proves nothing"
);
let after = mapping_generation(&mut session).expect("a key");
assert!(
after > before,
"changing grid ROWS alone must move the key — an area product \
would miss a transposition"
);
}
// COLUMNS come from the declaration.
{
let mut session = Session::new();
open_panel(&session, "g2cols", 4);
session.declare(1, 24, 80);
let _ = session.present();
let before = mapping_generation(&mut session).expect("a key");
session.declare(2, 24, 40);
let after = mapping_generation(&mut session).expect("a key");
assert!(
after > before,
"changing grid COLUMNS alone must move the key"
);
}
}