perf(themes): PR #120 round 5 -- O(1) frozen counts via store totals

The round-4 freeze read counted the retained for_uri vector inside
status_facts_msg -- correct, but StatusFacts runs at frame cadence
for every semantic session under the shared store mutex, so a long
stale interval cost O(frames x diagnostics x sessions).
DiagnosticStore now maintains per-URI severity totals alongside
by_uri: set replaces them (an empty publication removes them with
the vector), clear removes them, and mark_stale deliberately
preserves both -- entries exist exactly when by_uri entries do, and
set/clear are the store's only by_uri mutators. status_facts_msg
reads the tuple in O(1), and the all-URI severity_totals sum reuses
the cached tuples.

Store unit (acceptance item 34) pins the invariant: all four totals
replace correctly, survive staleness, and clear with the diagnostic
vector; empty_set_clears_uri asserts the totals drop too. The
rounds 3-4 freeze acceptance passes unchanged -- behavior parity,
so the unit pin is the evidence (no runtime bite exists for a
behavior-preserving refactor). Round-5 implementation
user-authored; this commit folds it with framing revision 9, the
protocol doc's O(1) note, and the acceptance manifest pointer to
the item-34 unit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VoiEyuPjoBhvwACf8HAnLB
This commit is contained in:
Levi Neuwirth 2026-07-15 11:19:03 +01:00
parent 3d6336a912
commit b75d45b1d4
5 changed files with 124 additions and 40 deletions

View File

@ -132,7 +132,9 @@ instance's stale-store diagnostic-count freeze is store knowledge,
not session state — the re-sent `StatusFacts` after a snapshot
carries the frozen counts, never zeros, including for a session
whose first frame lands during staleness (a late joiner attaching
mid-edit).
mid-edit). The store maintains those per-URI severity totals with
the retained vector, so frame-time producers read them in O(1)
rather than rescanning diagnostics for every attached session.
## Capability and version mechanics

View File

@ -1,7 +1,18 @@
# Theme faces — framing (Arc 4 stage 1, themes)
**Revision 8 — 2026-07-15. Status: implemented on branch
`theme-faces` (PR #120); revision 8 folds PR round 4.**
**Revision 9 — 2026-07-15. Status: implemented on branch
`theme-faces` (PR #120); revision 9 folds PR round 5.**
Revision 9 (PR #120 round 5, one finding): the store-sourced freeze
no longer rescans the retained diagnostic vector at frame cadence.
`DiagnosticStore` maintains per-URI severity totals alongside
`by_uri`, replacing or removing both in `set` / `clear` while
`mark_stale` deliberately preserves both. `status_facts_msg` reads
that tuple in O(1), so the round-4 late-join correctness does not
turn a long stale interval into O(frames x diagnostics x semantic
sessions) work under the shared store mutex. Acceptance item 34 is
the store unit pin: all four totals replace correctly, survive
staleness, and clear with the diagnostic vector.
Revision 8 (PR #120 round 4, one finding): the diagnostic-count
freeze is sourced from the diag store itself, superseding round 3's
@ -671,10 +682,13 @@ pub struct ThemeFace {
other buffers' baselines (each buffer's own snapshot precedes its
revisit). The diagnostic-count freeze needs no protection here
(rounds 34): it is sourced from the diag store's retained vector
`mark_stale` keeps the last published diagnostics, whose counts
merely lag — never from session state, so the reset cannot zero
mid-edit counts and the freeze holds even for a session attaching
during staleness. Resetting on a failed write is harmless — the
and its cached per-URI severity tuple — `mark_stale` keeps the last
published diagnostics and counts, which merely lag — never from
session state, so the reset cannot zero mid-edit counts and the
freeze holds even for a session attaching during staleness. The
producer reads the tuple in O(1), rather than scanning the retained
vector for every semantic session at frame cadence (round 5).
Resetting on a failed write is harmless — the
failure mode is one redundant re-send, never staleness. The GPU's `BufferSnapshot` arm mirrors the contract
(round 3 finding 1): it clears its buffer-scoped facts —
search/menu popups (which gate key and pointer interception) and
@ -1016,3 +1030,9 @@ Keybinding-driven tests dispatch keys, never `pmacs.command.invoke`.
`SemanticRenderState` — the session's first frame reports the
store's preserved counts, because the freeze is the retained
diagnostic vector itself, not a per-session cache.
34. **Frozen counts stay O(1) at frame cadence** (PR round 5, Rust
unit): `DiagnosticStore` caches all four per-URI severity totals;
replacement updates them, `mark_stale` preserves them, and
`clear` / an empty replacement remove them with the diagnostic
vector. `StatusFacts` reads this tuple rather than rescanning
`for_uri` for every semantic session on every frame.

View File

@ -196,6 +196,10 @@ impl Diagnostic {
#[derive(Default)]
pub struct DiagnosticStore {
by_uri: HashMap<String, Vec<Diagnostic>>,
/// Per-URI severity totals, maintained alongside `by_uri` so
/// frame-time consumers do not rescan every diagnostic at render
/// cadence. Entries exist exactly when `by_uri` entries do.
severity_counts: HashMap<String, (u32, u32, u32, u32)>,
/// URIs whose stored diagnostics are known to be out of date
/// because a `textDocument/didChange` was issued after the last
/// `publishDiagnostics` was absorbed. `Self::set` clears entries
@ -210,6 +214,20 @@ pub struct DiagnosticStore {
epochs: HashMap<String, u64>,
}
fn count_severities(diags: &[Diagnostic]) -> (u32, u32, u32, u32) {
let mut counts = (0u32, 0u32, 0u32, 0u32);
for diagnostic in diags {
let slot = match diagnostic.severity {
DiagnosticSeverity::Error => &mut counts.0,
DiagnosticSeverity::Warning => &mut counts.1,
DiagnosticSeverity::Information => &mut counts.2,
DiagnosticSeverity::Hint => &mut counts.3,
};
*slot = slot.saturating_add(1);
}
counts
}
impl DiagnosticStore {
/// Empty store.
#[must_use]
@ -227,11 +245,14 @@ impl DiagnosticStore {
pub fn set(&mut self, uri: impl Into<String>, mut diags: Vec<Diagnostic>) {
diags.sort_by(Diagnostic::compare_by_position);
let uri = uri.into();
let counts = count_severities(&diags);
self.stale_uris.remove(&uri);
*self.epochs.entry(uri.clone()).or_insert(0) += 1;
if diags.is_empty() {
self.by_uri.remove(&uri);
self.severity_counts.remove(&uri);
} else {
self.severity_counts.insert(uri.clone(), counts);
self.by_uri.insert(uri, diags);
}
}
@ -240,6 +261,7 @@ impl DiagnosticStore {
/// no entry to be stale about.
pub fn clear(&mut self, uri: &str) {
self.by_uri.remove(uri);
self.severity_counts.remove(uri);
self.stale_uris.remove(uri);
*self.epochs.entry(uri.to_owned()).or_insert(0) += 1;
}
@ -285,19 +307,26 @@ impl DiagnosticStore {
let mut w = 0;
let mut i = 0;
let mut h = 0;
for diags in self.by_uri.values() {
for d in diags {
match d.severity {
DiagnosticSeverity::Error => e += 1,
DiagnosticSeverity::Warning => w += 1,
DiagnosticSeverity::Information => i += 1,
DiagnosticSeverity::Hint => h += 1,
}
}
for &(errors, warnings, information, hints) in self.severity_counts.values() {
e += errors as usize;
w += warnings as usize;
i += information as usize;
h += hints as usize;
}
(e, w, i, h)
}
/// Per-URI totals `(error, warning, information, hint)`.
///
/// The tuple is computed once by [`Self::set`] and deliberately
/// survives [`Self::mark_stale`]: staleness invalidates byte
/// positions, while the last published counts remain valid as a
/// frozen status summary until the next publication.
#[must_use]
pub fn severity_counts_for(&self, uri: &str) -> (u32, u32, u32, u32) {
self.severity_counts.get(uri).copied().unwrap_or_default()
}
/// Per-URI count.
#[must_use]
pub fn count_for(&self, uri: &str) -> usize {
@ -860,12 +889,47 @@ mod tests {
assert_eq!(totals, (2, 1, 1, 1));
}
#[test]
fn cached_severity_counts_replace_clear_and_survive_staleness() {
let mut store = DiagnosticStore::new();
assert_eq!(store.severity_counts_for("file:///a"), (0, 0, 0, 0));
store.set(
"file:///a",
vec![
diag(0, DiagnosticSeverity::Error, "1"),
diag(1, DiagnosticSeverity::Error, "2"),
diag(2, DiagnosticSeverity::Warning, "3"),
diag(3, DiagnosticSeverity::Information, "4"),
diag(4, DiagnosticSeverity::Hint, "5"),
],
);
assert_eq!(store.severity_counts_for("file:///a"), (2, 1, 1, 1));
store.mark_stale("file:///a");
assert_eq!(
store.severity_counts_for("file:///a"),
(2, 1, 1, 1),
"staleness freezes the last published counts"
);
store.set(
"file:///a",
vec![diag(0, DiagnosticSeverity::Warning, "replacement")],
);
assert_eq!(store.severity_counts_for("file:///a"), (0, 1, 0, 0));
store.clear("file:///a");
assert_eq!(store.severity_counts_for("file:///a"), (0, 0, 0, 0));
}
#[test]
fn empty_set_clears_uri() {
let mut s = DiagnosticStore::new();
s.set("a", vec![diag(0, DiagnosticSeverity::Error, "x")]);
s.set("a", Vec::new());
assert!(s.for_uri("a").is_empty());
assert_eq!(s.severity_counts_for("a"), (0, 0, 0, 0));
assert_eq!(s.uris().count(), 0);
}

View File

@ -889,12 +889,12 @@ impl SemanticRenderState {
/// last published value while the diag store is stale — mid-edit
/// positions are wrong but *counts* merely lag, and flickering
/// to zero on every keystroke would be worse. The freeze IS the
/// store's retained vector (rounds 34): `mark_stale` keeps the
/// last published diagnostics, so counting them while stale
/// yields the frozen value with no session state to lose — not
/// to a snapshot reset, and not by attaching mid-edit. The
/// daemon's write loop keeps the variant off wires negotiated
/// `< 8`.
/// store's retained state (rounds 35): `mark_stale` keeps the
/// last published diagnostics and their cached severity totals,
/// so reading the totals while stale yields the frozen value in
/// O(1) with no session state to lose — not to a snapshot reset,
/// and not by attaching mid-edit. The daemon's write loop keeps
/// the variant off wires negotiated `< 8`.
fn status_facts_msg(
&mut self,
state: &EditorState,
@ -917,25 +917,22 @@ impl SemanticRenderState {
buffer_file_uri(&core, buffer_id).map_or((0, 0), |uri| {
let store = state.lsp_manager.borrow().diag_store();
let guard = store.lock().expect("diag store mutex poisoned");
// Counted even while the store is STALE (round 4):
// `mark_stale` keeps the last published vector (T
// M11.8) — positions are invalid mid-edit, but counts
// merely lag, so the retained entries ARE the frozen
// value. Sourcing the freeze from the store rather
// than any per-session cache means a session first
// rendering during staleness — a late joiner, or a
// buffer first visited mid-edit — reports the
// Read even while the store is STALE (round 4):
// `mark_stale` keeps the last published diagnostics
// (T M11.8) — positions are invalid mid-edit, but
// counts merely lag, so the retained totals ARE the
// frozen value. Sourcing the freeze from the store
// rather than any per-session cache means a session
// first rendering during staleness — a late joiner,
// or a buffer first visited mid-edit — reports the
// preserved counts instead of zeros, and the snapshot
// reset has nothing count-related to preserve.
let mut errors = 0u32;
let mut warnings = 0u32;
for d in guard.for_uri(&uri) {
match d.severity {
crate::diag::DiagnosticSeverity::Error => errors += 1,
crate::diag::DiagnosticSeverity::Warning => warnings += 1,
_ => {}
}
}
// `DiagnosticStore::set` computes this tuple once
// (round 5). StatusFacts runs at frame cadence for
// every semantic session, so rescanning the retained
// vector here would make stale intervals
// O(frames * diagnostics).
let (errors, warnings, _, _) = guard.severity_counts_for(&uri);
(errors, warnings)
})
};

View File

@ -1,7 +1,8 @@
// theme_faces_acceptance.rs --- Themes Arc 4 stage 1 acceptance
// (docs/theme-faces-framing.md, acceptance items 119, 2426, 2829,
// and 3233; the GPU routes — 2023, 27, and 3031 — live in
// pmacs-gpu's headless suite).
// pmacs-gpu's headless suite; item 34 is a `DiagnosticStore` unit in
// src/diag.rs).
//! Named UI faces (`ui` / `ui.*` theme entries) + the `ThemeFacts`
//! wire channel (protocol v16).