Rename-shuffle fix: label-only channel + three hygiene items

The live-rename interlude routed label changes through a fresh
snapshot object, which flowed setData -> geometry rebuild - and the
rebuild treats the last-fetched position ledger as authoritative and
cancels any pending drag commit, so a rename visibly rewound the
whole graph whenever display had drifted (recent drag, mid-anneal,
physics re-anneal).

- Renderer gains setClusterLabels (a setPhysicsTuning sibling): pure
  applyClusterLabels patches the data-side cluster records and the
  built region meta (re-wrapping the changed entries with the same
  measure the build uses), markDirty only. Never touches nodes, the
  sim, positions, or the pending commit. The hull-silence guard
  travels inside it: a null-label cluster is a gone record - no
  rename can reach it.
- The lh:chat-renamed listener stops building snapshot objects:
  renameSessionsPatch writes the sessions entry in place (pure
  display state - the detail card's provenance line), the cluster
  label goes through the new channel, and a tick bump re-renders the
  view. Identity preserved everywhere -> the [snapshot]-keyed
  setData feed cannot fire.
- Hygiene (a): a fresh sim starts at alpha 1 and never decays while
  stopped - the first post-rebuild drag re-annealed the whole graph
  at full strength. buildSim now initializes alpha at the settle
  floor, so first-drag matches steady-state drag. setPhysicsTuning
  still rehearses alpha(1) when a full re-anneal is the point.
- Hygiene (b): nodes missing from the position ledger used to dump
  at (0,0) via the ?? 0 fallback. seedMissingNodePositions seeds
  them on a deterministic ring around the known positions' centroid
  (FNV-1a hashed angle, golden-angle spiral offset).
- Hygiene (c) is store-band (writePositionMap's writeFileSync ->
  tmp+rename): flagged for Platform, not crossed.

Tests: applyClusterLabels suite (patch + re-wrap, hull silence,
no-op, explicit null, unknown ids), renameSessionsPatch identity
pins (sessions map and clusters array identity preserved - the
contract that keeps setData from firing), seedMissingNodePositions
determinism + ring math. Live-verified: mid-anneal rename shows a
4-world-unit max disc jump (no teleport), the commit PUT survives
the rename, rest renames leave the disc build byte-identical, and a
held first drag displaces discs ~14 units instead of re-forming the
graph.
This commit is contained in:
amosblomqvist
2026-09-23 20:20:36 +03:00
parent 976be50a81
commit e826088cc8
4 changed files with 362 additions and 19 deletions
+84 -1
View File
@@ -218,6 +218,44 @@ export function wrapRegionLabel(
lines.push(`${tail}…`);
return lines;
}
/** Label-only cluster patch (rename-shuffle fix, 2026-09-23): apply a set
* of live label updates to the data-side cluster records AND the built
* region meta (re-wrapping the changed entries with the provided
* measure — the same wrap a geometry build would produce). The
* hull-silence guard travels here: a cluster whose CURRENT label is
* null is a gone record — no rename can reach it. Pure, in-place on the
* provided arrays; returns whether anything changed (the caller only
* redraws when it did). Exported for tests. */
export function applyClusterLabels(
clusters: { id: string; label: string | null }[],
regionMeta: { id: string; label: string | null; lines: string[] | null }[],
labels: ReadonlyMap<string, string | null>,
maxWidth: number,
maxLines: number,
measure: (s: string) => number,
): boolean {
let changed = false;
for (const cluster of clusters) {
if (!labels.has(cluster.id)) continue;
if (cluster.label == null) continue; // hull silence: gone records stay silent
const label = labels.get(cluster.id) ?? null;
if (label === cluster.label) continue;
cluster.label = label;
changed = true;
}
if (!changed) return false;
for (const rm of regionMeta) {
if (!labels.has(rm.id)) continue;
if (rm.label == null) continue;
const label = labels.get(rm.id) ?? null;
rm.label = label;
rm.lines = label
? wrapRegionLabel(label, maxWidth, maxLines, measure)
: null;
}
return true;
}
const LABEL_MAX_WIDTH = 170; // wrap width at the reference label size, world units
const LABEL_GAP = 2.9; // world units between node edge and first label line (the 2px-at-fit reference ÷ 0.69)
const LABEL_LINE_HEIGHT = 1.3;
@@ -590,7 +628,7 @@ export class GraphCanvasRenderer {
/** World rect the bitmap covers (drawImage target coords). */
private regionRect: { minX: number; minY: number; w: number; h: number } | null = null;
/** Region labels: cluster centroid + hue, computed at splat build. */
private regionMeta: { label: string | null; lines: string[] | null; cx: number; cy: number; hue: number }[] = [];
private regionMeta: { id: string; label: string | null; lines: string[] | null; cx: number; cy: number; hue: number }[] = [];
private regionTuning: RegionTuning = DEFAULT_REGION_TUNING;
private physicsTuning: PhysicsTuning = DEFAULT_PHYSICS_TUNING;
/** Gravity center for the live sim (graph bbox centroid at setData). */
@@ -771,6 +809,42 @@ export class GraphCanvasRenderer {
this.markDirty();
}
/** Label-only channel (rename-shuffle fix, 2026-09-23): live
* cluster-label updates with NO setData. The rename path used to
* build a fresh snapshot object, which flowed setData → geometry
* rebuild — and the rebuild treats the last-fetched ledger as
* authoritative and cancels any pending drag commit, so a rename
* visibly rewound the whole graph whenever display had drifted
* (recent drag, mid-anneal, physics re-anneal). This channel touches
* ONLY label text: the region meta the draw pass reads plus the
* data-side cluster record (so the next geometry rebuild —
* visibility/settings/size — keeps the fresh label). Nodes, the
* sim, positions, and the pending commit are never touched. */
setClusterLabels(labels: ReadonlyMap<string, string | null>): void {
const data = this.data;
if (!data) return;
const settings = this.settings;
// No settings yet → nothing is drawn; patch the data side only and
// let the next build derive the wrap (same guard the build uses).
if (!settings) {
applyClusterLabels(data.clusters, [], labels, REGION_LABEL_MAX_WIDTH, REGION_LABEL_MAX_LINES, () => 0);
return;
}
const ctx = this.ctx;
const prevFont = ctx.font;
ctx.font = `${LABEL_WEIGHT} ${settings.clusterLabelSize}px ${data.fontFamily}`;
const changed = applyClusterLabels(
data.clusters,
this.regionMeta,
labels,
REGION_LABEL_MAX_WIDTH,
REGION_LABEL_MAX_LINES,
(s) => ctx.measureText(s).width,
);
ctx.font = prevFont;
if (changed) this.markDirty();
}
setVisible(visible: ReadonlySet<string> | null): void {
this.visible = visible;
this.geoVersion++; // member visibility changes hulls + paths
@@ -1229,6 +1303,14 @@ export class GraphCanvasRenderer {
},
});
this.simById = new Map(data.nodes.map((n) => [n.id, n as LayoutNode]));
// Hygiene (2026-09-23 fix wave): a fresh sim starts at alpha 1 and
// never decays while stopped — so the first post-rebuild drag would
// re-anneal the whole graph at full strength (alphaTarget(0.3)
// glides DOWN from 1 instead of gently up from rest). Initialize at
// the settle floor so first-drag behavior matches steady-state
// drags. setPhysicsTuning still rehearses its explicit alpha(1)
// when a full re-anneal IS the point.
this.sim.alpha(SIM_SETTLE_ALPHA);
}
/** Rasterize cluster regions into the low-res world-space bitmap.
@@ -1296,6 +1378,7 @@ export class GraphCanvasRenderer {
}
if (members > 0) {
this.regionMeta.push({
id: cluster.id,
label: cluster.label || null,
cx: sumX / members,
cy: sumY / members,
+79 -18
View File
@@ -129,6 +129,64 @@ export function isNodeVisible(node: Pick<GraphNode, "name" | "statement">, query
return node.name.toLowerCase().includes(q) || node.statement.toLowerCase().includes(q);
}
/** In-place sessions patch for a live rename (rename-shuffle fix,
* 2026-09-23). The OLD path built a fresh snapshot object, which
* flowed through the [snapshot]-keyed setData effect → geometry
* rebuild → last-fetched ledger became authoritative + pending drag
* commit cancelled → the graph visibly rewound whenever display had
* drifted. This seam NEVER changes object identity: same snapshot
* reference, same clusters array — so the data feed cannot re-fire.
* The display refresh rides a separate tick state, and the cluster
* label itself goes through the renderer's label-only channel
* (setClusterLabels). Exported for tests; pure display state only —
* the server derives the same join on its next read. */
export function renameSessionsPatch(snapshot: Pick<GraphSnapshot, "sessions">, id: string, title: string): void {
snapshot.sessions[id] = title;
}
/** Seed positions for nodes the ledger doesn't know yet (hygiene,
* 2026-09-23 fix wave): they used to fall through to a ?? 0 and dump
* at the origin. Place them on a deterministic ring around the known
* positions' centroid — angle hashed from the id (FNV-1a), golden-angle
* spiral offset so even hash collisions spread. Stable across
* remounts, far enough off the mass to see, and they relax with the
* pack on the next drag. In-place on the provided map; pure otherwise. */
export function seedMissingNodePositions(
nodes: readonly { id: string }[],
positions: Map<string, { x: number; y: number }>,
): Map<string, { x: number; y: number }> {
if (positions.size === 0 || nodes.every((n) => positions.has(n.id))) return positions;
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const p of positions.values()) {
if (p.x < minX) minX = p.x;
if (p.y < minY) minY = p.y;
if (p.x > maxX) maxX = p.x;
if (p.y > maxY) maxY = p.y;
}
const cx = (minX + maxX) / 2;
const cy = (minY + maxY) / 2;
const radius = Math.max(maxX - minX, maxY - minY, 40) * 0.7 + 20;
const missing = nodes.filter((n) => !positions.has(n.id));
missing.forEach((n, i) => {
// FNV-1a hash → [0,1) → angle; the golden-angle spiral offset
// both spreads collisions and staggers long paths deterministically.
let h = 0x811c9dc5;
for (let c = 0; c < n.id.length; c++) {
h ^= n.id.charCodeAt(c);
h = (h * 0x01000193) >>> 0;
}
const angle = ((h / 0xffffffff) * 2 * Math.PI + i * 2.39996323) % (2 * Math.PI);
positions.set(n.id, {
x: cx + Math.cos(angle) * radius,
y: cy + Math.sin(angle) * radius,
});
});
return positions;
}
// ---- Color helpers (canvas wants hex, not oklch) ---------------------------
/** Resolve a CSS variable (oklch in this app) to #rrggbb via the canvas. */
@@ -921,27 +979,27 @@ export function GraphView() {
return () => window.removeEventListener("lh:graph-revision", onRevision);
}, [refreshCosts]);
/* Live rename labels (wave 7 interlude): a session rename anywhere
(sidebar inline row, chat header) announces itself as lh:chat-renamed
{id, title} — and the birth-session partition means that title IS
the cluster label. Patch the snapshot in place: the sessions map
(the detail card's provenance line) and the one matching cluster.
No refetch — the server derives the same join on its next read, this
just keeps the two views from disagreeing until then. Unlabeled
clusters keep their hull-only silence (a null label is a GONE
record — a rename can't reach one, and the guard holds the line). */
/* Live rename labels (rename-shuffle fix, 2026-09-23): a session
rename anywhere (sidebar inline row, chat header) announces itself
as lh:chat-renamed {id, title} — and the birth-session partition
means that title IS the cluster label. The label update goes
through the renderer's label-only channel (setClusterLabels): NO
new snapshot object, NO setData, NO geometry rebuild — so the
position ledger can't assert itself and a pending drag commit
survives. The sessions map (the detail card's provenance line)
patches in place; a tick bump re-renders the view so an open
detail card reads the fresh title. Unlabeled clusters keep their
hull-only silence — the null-label guard lives in the channel
(applyClusterLabels): a rename can't reach a gone record. */
const [, setLabelTick] = useState(0);
useEffect(() => {
const onRenamed = (e: Event) => {
const { id, title } = (e as CustomEvent<{ id?: string; title?: string }>).detail ?? {};
if (typeof id !== "string" || typeof title !== "string" || id === "" || title === "") return;
setSnapshot((snap) => {
if (snap == null) return snap;
const sessions = { ...snap.sessions, [id]: title };
const clusters = snap.clusters.map((c) =>
c.id === id && c.label != null ? { ...c, label: title } : c,
);
return { ...snap, sessions, clusters };
});
const snap = snapshotRef.current;
if (snap != null) renameSessionsPatch(snap, id, title);
rendererRef.current?.setClusterLabels(new Map([[id, title]]));
setLabelTick((t) => t + 1);
};
window.addEventListener("lh:chat-renamed", onRenamed);
return () => window.removeEventListener("lh:chat-renamed", onRenamed);
@@ -995,7 +1053,10 @@ export function GraphView() {
if (p) positions.set(node.id, { x: p.x, y: p.y });
}
if (positions.size > 0) {
posRef.current = positions;
// Hygiene (2026-09-23 fix wave): nodes the ledger doesn't know
// yet (raced mid-settle) get seeded ring positions instead of
// the old ?? 0 origin dump.
posRef.current = seedMissingNodePositions(snapshot.nodes, positions);
return () => {
posRef.current = null;
};