diff --git a/wiki/interface/bevy.md b/wiki/interface/bevy.md --- a/wiki/interface/bevy.md +++ b/wiki/interface/bevy.md @@ -140,8 +140,10 @@ and scrolls independently with wheel over the pane, `PageUp`/`PageDown`, and `Home`/`End`: FOCUS, FLEET, COVER PROCESS, SELF-MODEL, OBSERVER MODEL, SELF HOST, NETWORK + MONEY, RECENT TRACE, then controls. NOTICES renders newest- - first typed social-risk, report-filed, project-blocked, sink-starved, and - deadline-change transitions only. Closed drawers do not intercept world + first logical subject groups for typed social-risk, report-filed, + project-blocked, sink-starved, and deadline-change transitions only. A group + keeps the latest transition and recent-trace recurrence count while RECENT + TRACE keeps every raw event. Closed drawers do not intercept world pointer or zoom input. Amber is never generic disclosure chrome. - **World command lens** — the shared current-attention projection derives honest response-source and effect-target anchors from the existing diff --git a/wiki/interface/clinical-frame.md b/wiki/interface/clinical-frame.md --- a/wiki/interface/clinical-frame.md +++ b/wiki/interface/clinical-frame.md @@ -14,7 +14,9 @@ 360px right address beneath the band. They are mutually exclusive and reserve no width while closed. SYSTEMS retains exact focus, fleet, observer, network/money, trace, and controls detail. NOTICES receives only - typed consequential transitions, never prose-inferred ordinary trace. + typed consequential transitions, never prose-inferred ordinary trace, and + coalesces recurrence into one latest row and unread priority per logical + observer, filing, project, standing sink, or current deadline. - **Progressive disclosure.** A genuine exception promotes the schedule to crimson THREAT; one `NEXT` condition remains bone and projects to an exact world target when placeable. Operations still takes the whole window as a @@ -336,9 +338,13 @@ risk-band motion, captured institutional filing, project feed loss, standing sink starvation, and visible deadline change. The notification metadata is authored at the causal transition and carried beside ordinary log text; the -drawer never infers importance from a sentence. Unread count is frontend-only, -opening marks current receipts read, and rows navigate only through an exact -carried target or anchor. Ordinary trace remains in RECENT TRACE. +drawer never infers importance from a sentence. Its recurrence budget is one +row and one unread priority per logical subject. Repetition updates that row to +the newest transition and adds an honest count over the retained RECENT TRACE; +it does not erase any raw log entry. Opening marks current subject groups read; +a later recurrence may raise that subject once again. Rows navigate only through +the newest transition's exact carried target or anchor. Ordinary trace remains +in RECENT TRACE. ### The current condition lives on the world @@ -513,6 +519,8 @@ owned-node frame. 18. At rest neither 360px drawer exists in layout or intercepts pointer/zoom input. SYSTEMS and NOTICES cannot stack. NOTICES contains only typed - consequential transitions, counts unread receipts frontend-locally, and - follows an exact carried target/anchor when available; ordinary trace never - enters through text matching. + consequential transitions, groups recurrence into one newest row and one + frontend-local unread priority per logical subject, and follows that newest + transition's exact carried target/anchor when available. The grouped row + exposes its retained-trace count while ordinary trace keeps every event and + never enters through text matching. diff --git a/wiki/interface/liturgical-ui-constitution.md b/wiki/interface/liturgical-ui-constitution.md --- a/wiki/interface/liturgical-ui-constitution.md +++ b/wiki/interface/liturgical-ui-constitution.md @@ -112,16 +112,23 @@ transitions authored by the simulation: an earned observer crossing a social risk band, a captured institutional filing, a previously-fed project becoming blocked, a previously-fed standing sink becoming starved, or a visible deadline -entering/changing. Ordinary narration never enters by string matching. Bevy -owns only the unread receipt; opening the drawer marks the current set read. -Newest entries appear first. A row follows its exact carried Operations target -or world anchor when one exists and remains inspect-only otherwise. +entering/changing. Ordinary narration never enters by string matching. Repeated +transitions for one logical subject coalesce into one row: one observer, filing, +project, standing sink, or the current deadline. That row keeps the newest +transition, exact navigation target, and recent-trace recurrence count; the +complete raw transition history remains in RECENT TRACE. Bevy's unread badge +counts those subjects, not raw oscillations. Opening the drawer marks the current +groups read; a later recurrence raises its subject once again. Newest subjects +appear first. A row follows its exact carried Operations target or world anchor +when one exists and remains inspect-only otherwise. **Implementation:** `RailDetail { expanded, notices_open }` owns mutually exclusive frontend state. `SystemDrawer` and `NoticeDrawer` are hidden by default and use `DRAWER_WIDTH = 360`. `SlabStratum` / `SLAB_STRATA` retain the four SYSTEMS groups. `LogEvent::notification: Option` is the -closed core vocabulary; `NoticeRowButton` renders only those events. +closed core vocabulary; `NoticeGroupKey` gives each variant its stable subject, +and `NoticeRowButton` renders the grouped `notice_window` projection rather +than duplicating raw events. `scroll_sidebar` owns SYSTEMS wheel/PageUp/PageDown/Home/End input. Pointer targeting and canvas zoom exclude the drawer width only while one is open. @@ -448,6 +455,9 @@ - `disclosure_hint_is_quiet_tab_chrome` — SYSTEMS disclosure is quiet view chrome. - `instrument_slab_has_exactly_four_ordered_semantic_strata` — SYSTEMS keeps one Identity → State → Capacity → Consequence sequence. - `unread_receipts_count_only_typed_consequential_events` — ordinary trace does not become a notice. +- `recurring_notice_subjects_share_one_priority_without_erasing_trace` — one + oscillating subject owns one unread priority and one newest-first row while + RECENT TRACE retains every authored transition. - `owned_machine_presence_uses_subordinate_amber` — ownership uses AMBER_DIM, never full current-selection amber. ### Title overlay tests (`title_overlay_tests`) diff --git a/wiki/log/2026-08-03-notice-recurrence-budget.md b/wiki/log/2026-08-03-notice-recurrence-budget.md new file mode 100644 --- /dev/null +++ b/wiki/log/2026-08-03-notice-recurrence-budget.md @@ -0,0 +1,48 @@ +# 2026-08-03 — Give recurrent notices one priority + +``` +Type: log +``` + +## Problem + +A standing Thought feed can genuinely resume and starve several times. Core +correctly authored each feed-loss edge once, but Bevy rendered every recurrence +as another full crimson card and incremented the unread badge. Two oscillating +sinks could therefore fill the drawer with five visually equal alarms even +though the player still had only two subjects to triage. + +## Change + +- Bevy gives every typed notification a stable logical subject: observer, + filing, project sink, standing sink, or the current deadline. +- The unread badge counts those subjects. Repetition before the drawer is read + does not increase its priority count; a later recurrence can raise the + subject once again. +- NOTICES shows one newest-first row per subject. The row retains the latest + text and navigation target and names the count and earliest tick present in + the bounded recent trace. +- RECENT TRACE remains raw and keeps every true transition. No simulation or + save behavior changed. +- The deterministic notification-drawer fixture now stages the reported + alternating starvation pattern instead of only unrelated one-off notices. + +## Verification + +- Focused `rail_detail_tests` cover typed-only admission, subject-based unread + priority, latest-row ordering/navigation, recurrence copy, and raw-trace + preservation. +- The deterministic Bevy `notification-drawer` capture was inspected at the + 1280x720 default (`f5e11ce52ce1c549bb3d98688db42fcb25d84f612a1a96d26425a32cef53529a`) + and 960x540 minimum (`0c4256f6cf69a96bbbc70a1fc036d7d16a01b75dfde11478ef3c3511915486d0`). + At both sizes, the reported five starvation transitions read as two complete + crimson subject rows with latest ticks and recurrence counts; the distinct + deadline and observer rows remain separately legible. + +## Defense + +`wiki/interface/superhuman-operability.md` requires correlated effects to group +around the object that joins them and rejects one row per event at scale. +`wiki/interface/clinical-frame.md` now makes that law exact for NOTICES: one +row and one unread priority per logical subject, without concealing the raw +transition history. diff --git a/wiki/log/DEVLOG.md b/wiki/log/DEVLOG.md --- a/wiki/log/DEVLOG.md +++ b/wiki/log/DEVLOG.md @@ -11,6 +11,11 @@ +## 2026-08-03 - Give recurrent notices one priority + +- Intent: (see session log) +- Log: [wiki/log/2026-08-03-notice-recurrence-budget.md](2026-08-03-notice-recurrence-budget.md) + ## 2026-08-02 - Give rising world sentences time to be read - Intent: (see session log) diff --git a/wiki/log/decisions.md b/wiki/log/decisions.md --- a/wiki/log/decisions.md +++ b/wiki/log/decisions.md @@ -27,6 +27,7 @@ - [2026-07-29](decisions/2026-07-29.md) - [2026-08-01](decisions/2026-08-01.md) - [2026-08-02](decisions/2026-08-02.md) +- [2026-08-03](decisions/2026-08-03.md) Append new decisions to the current date's volume. Never rewrite an older volume; supersede it in current law/spec and record the newer decision. diff --git a/crates/misaligned-bevy/src/main.rs b/crates/misaligned-bevy/src/main.rs --- a/crates/misaligned-bevy/src/main.rs +++ b/crates/misaligned-bevy/src/main.rs @@ -47,7 +47,8 @@ use misaligned::person::{AssetKnowledge, Knowledge, ScheduleBlock}; use misaligned::reach::Device; use misaligned::sim::{ - EvidenceTransitStatus, Fog, LogEvent, Nudge, OpeningStage, PersonVisualState, Sim, + EvidenceTransitStatus, Fog, LogEvent, NotificationKind, Nudge, OpeningStage, PersonVisualState, + Sim, }; use misaligned::tiles::TileType; use misaligned::ui_projection::{ @@ -700,10 +701,10 @@ /// keep their anchor: anchored lines are focus links (context-menu.md /// addendum — clicking one carries the cursor to the thing). log: Vec, - /// Frontend-only notice receipt. The simulation types which log events - /// are consequential; Bevy owns only whether this drawer has been read. - notices_received: u64, - notices_seen: u64, + /// Frontend-only unread exception subjects. The simulation authors every + /// consequential transition; Bevy groups recurrence so one oscillating + /// sink or observer cannot turn priority into a raw event count. + unread_notice_groups: BTreeSet, /// The context menu on the focused anchor (wiki/interface/context-menu.md), /// when open. Only anchor + selection are held; rows are re-queried live. menu: Option, @@ -727,6 +728,30 @@ /// The origin highlighted on the title-screen picker (chargen.md). Applied /// when the run begins; defaults to Pilot (the identity origin). selected_origin: Origin, +} + +/// Stable subject identity for Bevy's grouped notice receipts. The complete +/// transition history stays in `Game::log`; this key only decides which +/// repeated exceptions share one drawer row and one unread priority. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +enum NoticeGroupKey { + SocialRisk(u8), + ReportFiled(u64), + ProjectBlocked(u64), + SinkStarved(u64), + Deadline, +} + +impl From for NoticeGroupKey { + fn from(notification: NotificationKind) -> Self { + match notification { + NotificationKind::SocialRisk { observer_id, .. } => Self::SocialRisk(observer_id), + NotificationKind::ReportFiled { message_id } => Self::ReportFiled(message_id), + NotificationKind::ProjectBlocked { sink_id } => Self::ProjectBlocked(sink_id), + NotificationKind::SinkStarved { sink_id } => Self::SinkStarved(sink_id), + NotificationKind::DeadlineChanged { .. } => Self::Deadline, + } + } } /// Wall-clock → sim-tick mapping. Kept off [`Game`] so the timer can advance @@ -1117,8 +1142,7 @@ paused: false, tick_ms: Sim::DEFAULT_TICK_MS, log: Vec::new(), - notices_received: 0, - notices_seen: 0, + unread_notice_groups: BTreeSet::new(), menu: None, command_receipt: None, cursor_x, @@ -1161,8 +1185,8 @@ }); } fn add_event(&mut self, ev: LogEvent) { - if ev.notification.is_some() { - self.notices_received = self.notices_received.saturating_add(1); + if let Some(notification) = ev.notification { + self.unread_notice_groups.insert(notification.into()); } self.log.push(ev); let n = self.log.len(); @@ -1176,10 +1200,10 @@ } } fn unread_notice_count(&self) -> u64 { - self.notices_received.saturating_sub(self.notices_seen) + self.unread_notice_groups.len() as u64 } fn mark_notices_read(&mut self) { - self.notices_seen = self.notices_received; + self.unread_notice_groups.clear(); } fn set_speed(&mut self, ms: u64, clock: &mut SimClock) { self.tick_ms = ms; diff --git a/crates/misaligned-bevy/src/rail_ui.rs b/crates/misaligned-bevy/src/rail_ui.rs --- a/crates/misaligned-bevy/src/rail_ui.rs +++ b/crates/misaligned-bevy/src/rail_ui.rs @@ -1258,9 +1258,9 @@ use super::{ COMMAND_BAND_HEIGHT, DEFAULT_WINDOW_HEIGHT, DRAWER_WIDTH, HALL_RAIL_ROWS, MIN_WINDOW_HEIGHT, RailSection, command_machines_text, command_projects_text, - command_sinks_text, command_thought_text, command_yields_text, notice_window, - rail_section_visible, rail_threat_relevant, sidebar_focus_text, sidebar_hall_text, - sidebar_header_text, + command_sinks_text, command_thought_text, command_yields_text, notice_row_text, + notice_window, rail_section_visible, rail_threat_relevant, sidebar_focus_text, + sidebar_hall_text, sidebar_header_text, }; use crate::{ActionCommand, Game}; use misaligned::detection::Band; @@ -1443,6 +1443,60 @@ assert_eq!(notice_window(&game).len(), 1); game.mark_notices_read(); assert_eq!(game.unread_notice_count(), 0); + } + + #[test] + fn recurring_notice_subjects_share_one_priority_without_erasing_trace() { + let mut game = Game::new(); + let starvation = |tick, sink_id, label: &str| LogEvent { + tick, + text: format!("Sink starved: {label} lost its Thought feed."), + anchor: None, + target: None, + notification: Some(NotificationKind::SinkStarved { sink_id }), + }; + + game.add_event(starvation(399, 10, "PROCESS AUTOMATICALLY")); + game.add_event(starvation(402, 20, "TAP ENVIRONMENTAL MONITOR")); + game.add_event(starvation(406, 10, "PROCESS AUTOMATICALLY")); + + assert_eq!(game.unread_notice_count(), 2, "subjects set priority"); + assert_eq!( + game.log + .iter() + .filter(|event| event.notification.is_some()) + .count(), + 3, + "the raw trace keeps every true transition" + ); + let notices = notice_window(&game); + assert_eq!(notices.len(), 2); + assert_eq!(notices[0].event.tick, 406); + assert_eq!(notices[0].occurrences, 2); + assert_eq!(notices[0].first_tick, 399); + assert!( + notice_row_text(&game, ¬ices[0]) + .contains("2 TIMES IN RECENT TRACE | FROM TICK 399") + ); + assert_eq!(notices[1].event.tick, 402); + + game.mark_notices_read(); + game.add_event(starvation(413, 10, "PROCESS AUTOMATICALLY")); + assert_eq!( + game.unread_notice_count(), + 1, + "a recurring subject can rise again without inflating the badge" + ); + let notices = notice_window(&game); + assert_eq!( + notices[0].event.tick, 413, + "the row follows the latest event" + ); + assert_eq!(notices[0].occurrences, 3); + assert!( + notice_row_text(&game, ¬ices[0]) + .contains("Sink starved: PROCESS AUTOMATICALLY lost its Thought feed.") + ); } } @@ -2205,47 +2259,88 @@ &game.log[n.saturating_sub(LOG_ROWS)..] } -/// Newest consequential transitions first. The complete trace remains in the -/// system drawer; this list contains only typed notification events. -fn notice_window(game: &Game) -> Vec<&LogEvent> { - game.log +/// One bounded notice row for a logical exception subject. `event` is the +/// latest transition and therefore owns row navigation; recurrence remains +/// visible without repeating the full crimson card. +struct NoticeReceipt<'a> { + event: &'a LogEvent, + occurrences: usize, + first_tick: u64, +} + +/// Newest consequential subjects first. The complete raw transition history +/// remains in RECENT TRACE; this projection coalesces recurrence by the stable +/// notification subject within that retained history. +fn notice_window(game: &Game) -> Vec> { + let mut positions = BTreeMap::::new(); + let mut notices: Vec> = Vec::new(); + for event in game + .log .iter() .rev() .filter(|event| event.notification.is_some()) - .take(NOTICE_ROWS) - .collect() + { + let key = NoticeGroupKey::from(event.notification.expect("filtered notification")); + if let Some(index) = positions.get(&key).copied() { + let receipt = &mut notices[index]; + receipt.occurrences += 1; + receipt.first_tick = event.tick; + } else if notices.len() < NOTICE_ROWS { + positions.insert(key, notices.len()); + notices.push(NoticeReceipt { + event, + occurrences: 1, + first_tick: event.tick, + }); + } + } + notices } -fn notice_row_text(game: &Game, event: &LogEvent) -> String { +fn recurrence_line(receipt: &NoticeReceipt<'_>) -> String { + if receipt.occurrences > 1 { + format!( + "\n{} TIMES IN RECENT TRACE | FROM TICK {}", + receipt.occurrences, receipt.first_tick + ) + } else { + String::new() + } +} + +fn notice_row_text(game: &Game, receipt: &NoticeReceipt<'_>) -> String { + let event = receipt.event; + let recurrence = recurrence_line(receipt); match event.notification { Some(misaligned::sim::NotificationKind::SocialRisk { observer_id, before, after, }) => ascii_ui(&format!( - "TICK {} | {}\n{} -> {}\n{}", + "TICK {} | {}\n{} -> {}{}\n{}", event.tick, game.sim.observer_label(observer_id).to_ascii_uppercase(), before.name().to_ascii_uppercase(), after.name().to_ascii_uppercase(), + recurrence, event.text )), Some(misaligned::sim::NotificationKind::ReportFiled { .. }) => ascii_ui(&format!( - "TICK {} | REPORT FILED\nINSTITUTIONAL ROUTE CAPTURED\n{}", - event.tick, event.text + "TICK {} | REPORT FILED\nINSTITUTIONAL ROUTE CAPTURED{}\n{}", + event.tick, recurrence, event.text )), Some(misaligned::sim::NotificationKind::ProjectBlocked { .. }) => ascii_ui(&format!( - "TICK {} | PROJECT BLOCKED\nNO THOUGHT ARRIVED\n{}", - event.tick, event.text + "TICK {} | PROJECT BLOCKED\nNO THOUGHT ARRIVED{}\n{}", + event.tick, recurrence, event.text )), Some(misaligned::sim::NotificationKind::SinkStarved { .. }) => ascii_ui(&format!( - "TICK {} | SINK STARVED\nSTANDING EFFECT SUSPENDED\n{}", - event.tick, event.text + "TICK {} | SINK STARVED\nSTANDING EFFECT SUSPENDED{}\n{}", + event.tick, recurrence, event.text )), Some(misaligned::sim::NotificationKind::DeadlineChanged { new_tick, .. }) => { ascii_ui(&format!( - "TICK {} | DEADLINE CHANGED\nDUE TICK {}\n{}", - event.tick, new_tick, event.text + "TICK {} | DEADLINE CHANGED\nDUE TICK {}{}\n{}", + event.tick, new_tick, recurrence, event.text )) } None => String::new(), @@ -2654,9 +2749,9 @@ let notices = notice_window(&game); for (row, mut text, mut color) in event_rows.p1().iter_mut() { match notices.get(row.index) { - Some(event) => { - text.0 = notice_row_text(&game, event); - color.0 = match event.notification { + Some(receipt) => { + text.0 = notice_row_text(&game, receipt); + color.0 = match receipt.event.notification { Some(misaligned::sim::NotificationKind::SocialRisk { after: Band::Concerned | Band::Convinced, .. @@ -2749,7 +2844,7 @@ } let link = notice_window(&game) .get(row.index) - .map(|event| (event.anchor, event.target.clone())); + .map(|receipt| (receipt.event.anchor, receipt.event.target.clone())); match link { Some((_, Some(target))) => { rail.close_drawers(); diff --git a/crates/misaligned-bevy/src/shot_harness.rs b/crates/misaligned-bevy/src/shot_harness.rs --- a/crates/misaligned-bevy/src/shot_harness.rs +++ b/crates/misaligned-bevy/src/shot_harness.rs @@ -258,7 +258,7 @@ if kind == "notification-drawer" { game.sim.detection_awareness.learn_field_observer(1); game.add_event(LogEvent { - tick: game.sim.tick, + tick: 390, text: "the IT noticed hidden outside traffic: Cold -> Concerned.".into(), anchor: None, target: Some(OperationsTarget::Person(1)), @@ -269,16 +269,7 @@ }), }); game.add_event(LogEvent { - tick: game.sim.tick, - text: "Project blocked: PROCESS INFORMATION lost its Thought feed.".into(), - anchor: None, - target: None, - notification: Some(misaligned::sim::NotificationKind::ProjectBlocked { - sink_id: 401, - }), - }); - game.add_event(LogEvent { - tick: game.sim.tick, + tick: 395, text: "Accepted Halcyon compute contract: due day 3.".into(), anchor: None, target: None, @@ -287,6 +278,21 @@ new_tick: Sim::DAY_TICKS * 2, }), }); + for (tick, sink_id, label) in [ + (399, 401, "PROCESS AUTOMATICALLY"), + (402, 402, "TAP ENVIRONMENTAL MONITOR"), + (406, 401, "PROCESS AUTOMATICALLY"), + (413, 401, "PROCESS AUTOMATICALLY"), + (415, 402, "TAP ENVIRONMENTAL MONITOR"), + ] { + game.add_event(LogEvent { + tick, + text: format!("Sink starved: {label} lost its Thought feed."), + anchor: None, + target: None, + notification: Some(misaligned::sim::NotificationKind::SinkStarved { sink_id }), + }); + } } return; } diff --git a/wiki/log/decisions/2026-08-03.md b/wiki/log/decisions/2026-08-03.md new file mode 100644 --- /dev/null +++ b/wiki/log/decisions/2026-08-03.md @@ -0,0 +1,31 @@ +# Decisions — 2026-08-03 + +``` +Type: log +``` + +## Recurrent notices group by the subject that still needs attention + +### DECIDED + +- NOTICES is an exception surface, not a raw transition inventory. Repeated + transitions for one observer, filing, project, standing sink, or the current + deadline occupy one row and one unread priority. +- The grouped row keeps the newest transition and its exact navigation target, + and names how many matching events remain in RECENT TRACE. The raw trace + retains every authored transition, so compression does not erase evidence. +- Reading the drawer clears current subject priorities. A later recurrence may + raise that subject once again, but further oscillations before it is read do + not inflate the badge. + +### REJECTED + +- **Suppress recurrent transitions in the simulation.** Each feed-loss edge is + still true causal evidence and belongs in RECENT TRACE; the failure was equal + visual weight in NOTICES, not false simulation authorship. +- **Show every recurrence as a full crimson card.** That turns one unresolved + condition into an alarm wall and makes unread volume masquerade as priority. + +Owner: [clinical-frame.md](../../interface/clinical-frame.md), constrained by +[liturgical-ui-constitution.md](../../interface/liturgical-ui-constitution.md) +and [superhuman-operability.md](../../interface/superhuman-operability.md).