Prepare measured scenario labels and clear corridor evaluators

This commit is contained in:
2026-09-12 18:35:52 -07:00
parent e811879857
commit 9b56a337f8
18 changed files with 2956 additions and 0 deletions
@@ -0,0 +1,429 @@
//! Unwired single-line scenario label experiment. No renderer or schema is changed.
//!
//! Iced 0.14.0 WGPU `geometry::Frame::fill_text` shapes positive uniform-scale
//! canvas text at `size * zoom`; its `text::prepare` uses graphics text::Cache.
//! This adapter uses that same allocator/font system/attributes, not a character
//! width estimate. Canvas Text::max_width is wrapping, so it stays infinite.
//! Experimental parity requires settled fonts and a fresh renderer/cache after
//! font loading. Iced's WGPU text cache key omits FontSystem::Version, so merely
//! remeasuring here cannot repair its cached glyph buffers after dynamic loading.
//! A RenderSession is caller attestation of that lifecycle, not its enforcement.
//! No fonts may be loaded between measurement, guarded access and actual draw.
//!
//! Integration seam: after lifecycle/activation endpoint alignment, a caller
//! supplies a usable text width in world units and retains one cache per live
//! labelled occurrence across Scene rebuilds. Paint, hit testing, navigation and extents
//! consume the returned Label. Position/color do not invalidate measurement.
//! This does NOT choose a self-call, reversed-arrow or obstacle corridor policy.
//! It measures layout advances, not raster ink; overhang/clip/activation-border
//! acceptance still requires the real WGPU evaluator. The old 32-grapheme/225px
//! renderer is deliberately untouched for a later baseline comparison.
use iced::advanced::{
graphics::text as graphics_text,
text::{Alignment, LineHeight, Shaping},
};
use iced::{Color, Font, Pixels, Point, Rectangle, Size, widget::canvas::Text};
use std::{collections::VecDeque, ops::Range, sync::Arc};
use unicode_segmentation::UnicodeSegmentation;
const MAX_SOURCE_BYTES: usize = 64 * 1024;
const MAX_GRAPHEMES: usize = 4096;
const MAX_ENTRIES: usize = 2;
#[derive(Clone, Copy, Debug, PartialEq)]
pub(super) struct Style {
pub font: Font,
pub size: Pixels,
pub line_height: LineHeight,
pub shaping: Shaping,
}
impl Default for Style {
fn default() -> Self {
let text = Text::default();
Self {
font: text.font,
size: Pixels(12.),
line_height: text.line_height,
shaping: text.shaping,
}
}
}
impl Style {
fn scaled(self, zoom: f32) -> (f32, f32) {
let size = Pixels(self.size.0 * zoom);
let line_height = match self.line_height {
LineHeight::Absolute(value) => LineHeight::Absolute(Pixels(value.0 * zoom)),
LineHeight::Relative(value) => LineHeight::Relative(value),
};
(size.0, line_height.to_absolute(size).0)
}
}
/// Explicit experimental contract: the caller finishes loading fonts and starts
/// a fresh renderer/cache BEFORE capturing this session, then keeps fonts fixed.
/// This module cannot inspect or reset the actual renderer's text cache.
#[derive(Clone, Debug)]
pub(super) struct RenderSession {
version: graphics_text::Version,
identity: Arc<()>,
}
impl RenderSession {
pub fn after_fonts_settled_and_renderer_reset() -> Result<Self, Error> {
let fonts = graphics_text::font_system()
.read()
.map_err(|_| Error::FontSystemUnavailable)?;
Ok(Self {
version: fonts.version(),
identity: Arc::new(()),
})
}
}
#[derive(Clone, Debug)]
pub(super) struct Label {
source: String,
displayed: String,
/// Original-label bytes represented before any added ellipsis. Includes
/// collapsed whitespace, but `retains_any` never counts whitespace as text.
retained_source: Range<usize>,
/// World-space layout advances, at origin; not glyph-ink containment.
bounds: Rectangle,
style: Style,
measured_session: RenderSession,
measured_zoom: f32,
}
impl Label {
fn check(&self, session: &RenderSession, zoom: f32) -> Result<(), Error> {
if !Arc::ptr_eq(&self.measured_session.identity, &session.identity) {
return Err(Error::StaleRenderSession);
}
if !zoom.is_finite() || zoom != self.measured_zoom {
return Err(Error::StaleZoom);
}
let fonts = graphics_text::font_system()
.read()
.map_err(|_| Error::FontSystemUnavailable)?;
if fonts.version() != self.measured_session.version || fonts.version() != session.version {
return Err(Error::FontSetChanged);
}
Ok(())
}
pub fn displayed(&self, session: &RenderSession, zoom: f32) -> Result<&str, Error> {
self.check(session, zoom)?;
Ok(&self.displayed)
}
pub fn retained_source(
&self,
session: &RenderSession,
zoom: f32,
) -> Result<Range<usize>, Error> {
self.check(session, zoom)?;
Ok(self.retained_source.clone())
}
pub fn retains_any(
&self,
session: &RenderSession,
zoom: f32,
field: Range<usize>,
) -> Result<bool, Error> {
self.check(session, zoom)?;
if field.start > field.end || self.source.get(field.clone()).is_none() {
return Ok(false);
}
let end = field.end.min(self.retained_source.end);
Ok(field.start < end
&& self
.source
.get(field.start..end)
.is_some_and(|text| text.chars().any(|c| !c.is_whitespace())))
}
/// Validate finite world position and rectangle endpoints only. Camera
/// transforms and raster bounds remain a separate integration contract.
pub fn bounds_at(
&self,
session: &RenderSession,
zoom: f32,
position: Point,
) -> Result<Rectangle, Error> {
self.check(session, zoom)?;
translated_bounds(position, self.bounds.size())
}
/// Paint under the same positive uniform zoom used for `Cache::resolve`.
/// No per-label wrapping or implicit text maximum is introduced.
/// The returned Text must be consumed immediately in this session/zoom;
/// retaining raw Text past another font load/zoom change bypasses the guard.
pub fn canvas_text(
&self,
session: &RenderSession,
zoom: f32,
position: Point,
color: Color,
) -> Result<Text, Error> {
self.bounds_at(session, zoom, position)?;
Ok(Text {
content: self.displayed.clone(),
position,
color,
size: self.style.size,
line_height: self.style.line_height,
font: self.style.font,
shaping: self.style.shaping,
max_width: f32::INFINITY,
..Text::default()
})
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum Error {
InvalidMetrics,
SourceBudget,
GraphemeBudget,
FontSystemUnavailable,
InvalidMeasurement,
InvalidPosition,
StaleZoom,
StaleRenderSession,
FontSetChanged,
}
struct Entry {
source: String,
style: Style,
zoom: f32,
width: f32,
label: Arc<Label>,
}
#[derive(Default)]
/// Two recent resolutions for ONE live labelled occurrence. A global small LRU
/// would thrash when a larger Scene is rebuilt in order. The integration owner
/// must retain these by occurrence identity and prune on removal/document change.
pub(super) struct Cache {
version: Option<graphics_text::Version>,
session: Option<Arc<()>>,
entries: VecDeque<Entry>,
#[cfg(test)]
allocations: usize,
}
impl Cache {
/// `width` excludes any caller-chosen backplate padding. Zero is valid.
/// Cache ownership must survive redraws; constructing one per Scene defeats
/// reuse. Changed fonts invalidate the SESSION: the caller must establish
/// a fresh renderer and a new settled session, not just remeasure this cache.
/// Error handling/fallback for these explicit experiment budgets is an
/// integration decision; never silently paint the old truncated contract.
pub fn resolve(
&mut self,
session: &RenderSession,
source: &str,
width: f32,
zoom: f32,
style: Style,
) -> Result<Arc<Label>, Error> {
let (size, line_height) = style.scaled(zoom);
if !width.is_finite()
|| width < 0.
|| !zoom.is_finite()
|| zoom <= 0.
|| !size.is_finite()
|| size <= 0.
|| !line_height.is_finite()
|| line_height <= 0.
|| !(width * zoom).is_finite()
|| !(size / zoom).is_finite()
|| !(line_height / zoom).is_finite()
{
return Err(Error::InvalidMetrics);
}
if source.len() > MAX_SOURCE_BYTES {
return Err(Error::SourceBudget);
}
let mut fonts = graphics_text::font_system()
.write()
.map_err(|_| Error::FontSystemUnavailable)?;
if session.version != fonts.version() {
return Err(Error::FontSetChanged);
}
if self.version != Some(fonts.version())
|| self
.session
.as_ref()
.is_none_or(|identity| !Arc::ptr_eq(identity, &session.identity))
{
self.entries.clear();
self.version = Some(fonts.version());
self.session = Some(session.identity.clone());
}
if let Some(index) = self.entries.iter().position(|entry| {
entry.source == source
&& entry.style == style
&& entry.zoom == zoom
&& entry.width == width
}) {
let entry = self.entries.remove(index).unwrap();
let label = entry.label.clone();
self.entries.push_back(entry);
return Ok(label);
}
let normalized = Normalized::new(source);
let cuts: Vec<usize> = normalized
.text
.grapheme_indices(true)
.map(|(i, _)| i)
.chain(std::iter::once(normalized.text.len()))
.collect();
if cuts.len().saturating_sub(1) > MAX_GRAPHEMES {
return Err(Error::GraphemeBudget);
}
// Candidate buffers are temporary. Only the resolved strings/metrics
// survive, bounded by MAX_ENTRIES; renderer caching remains independent.
let mut shapes = graphics_text::Cache::new();
let mut measure = |content: &str| -> Result<Size, Error> {
if content.is_empty() {
return Ok(Size::ZERO);
}
#[cfg(test)]
{
self.allocations += 1;
}
let (_, entry) = shapes.allocate(
fonts.raw(),
graphics_text::cache::Key {
content,
size,
line_height,
font: style.font,
bounds: Size::INFINITE,
shaping: style.shaping,
align_x: Alignment::Default,
},
);
if entry.buffer.layout_runs().count() != 1
|| !entry.min_bounds.width.is_finite()
|| !entry.min_bounds.height.is_finite()
{
return Err(Error::InvalidMeasurement);
}
world_size(entry.min_bounds, zoom)
};
let full = measure(&normalized.text)?;
let (displayed, retained, measured) = if full.width <= width {
(normalized.text.clone(), normalized.text.len(), full)
} else {
let ellipsis = measure("…")?;
if ellipsis.width > width {
(String::new(), 0, Size::ZERO)
} else {
// Every accepted candidate is actually shaped and fits. Search
// is bounded/logarithmic; contextual shaping is not universally
// monotone, so this does NOT promise the globally longest prefix.
// Full content is always checked first and never needlessly cut.
let mut best = ("…".to_owned(), 0, ellipsis);
let (mut low, mut high) = (1, cuts.len().saturating_sub(1));
while low < high {
let middle = low + (high - low) / 2;
let candidate = format!("{}…", &normalized.text[..cuts[middle]]);
let measured = measure(&candidate)?;
if measured.width <= width {
best = (candidate, cuts[middle], measured);
low = middle + 1;
} else {
high = middle;
}
}
best
}
};
let label = Arc::new(Label {
source: source.into(),
displayed,
retained_source: 0..normalized.source_end(retained),
bounds: Rectangle::with_size(measured),
style,
measured_session: session.clone(),
measured_zoom: zoom,
});
if self.entries.len() == MAX_ENTRIES {
self.entries.pop_front();
}
self.entries.push_back(Entry {
source: source.into(),
style,
zoom,
width,
label: label.clone(),
});
Ok(label)
}
}
fn world_size(scaled: Size, zoom: f32) -> Result<Size, Error> {
let world = Size::new(scaled.width / zoom, scaled.height / zoom);
if !world.width.is_finite()
|| !world.height.is_finite()
|| world.width < 0.
|| world.height < 0.
{
return Err(Error::InvalidMeasurement);
}
Ok(world)
}
fn translated_bounds(position: Point, size: Size) -> Result<Rectangle, Error> {
if !position.x.is_finite()
|| !position.y.is_finite()
|| !(position.x + size.width).is_finite()
|| !(position.y + size.height).is_finite()
{
return Err(Error::InvalidPosition);
}
Ok(Rectangle::new(position, size))
}
struct Normalized {
text: String,
ends: Vec<(usize, usize)>,
}
impl Normalized {
fn new(source: &str) -> Self {
let mut result = Self {
text: String::new(),
ends: vec![(0, 0)],
};
let mut separator = false;
for (start, c) in source.char_indices() {
if c.is_whitespace() {
separator = !result.text.is_empty();
continue;
}
if separator {
result.text.push(' ');
result.ends.push((result.text.len(), start));
separator = false;
}
result.text.push(c);
result.ends.push((result.text.len(), start + c.len_utf8()));
}
result
}
fn source_end(&self, normalized_end: usize) -> usize {
self.ends
.binary_search_by_key(&normalized_end, |(end, _)| *end)
.map(|index| self.ends[index].1)
.expect("grapheme cuts are character boundaries")
}
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,189 @@
//! Unwired corridor-policy candidate for the measured scenario-label experiment.
//!
//! The caller supplies a finite structural label band AFTER endpoint/activation
//! alignment, with padding already removed from its edges. Blockers include
//! padded activation/card/header bounds over the complete text band, including
//! stroke/ink clearance. This function does not infer a self-message corridor,
//! alter scene extents, change zoom, or establish raster-ink containment.
use iced::Rectangle;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) struct InvalidGeometry;
fn edges(rect: Rectangle) -> Result<(f32, f32, f32, f32), InvalidGeometry> {
let right = rect.x + rect.width;
let bottom = rect.y + rect.height;
if [rect.x, rect.y, rect.width, rect.height, right, bottom]
.into_iter()
.any(|value| !value.is_finite())
|| rect.width < 0.
|| rect.height < 0.
{
return Err(InvalidGeometry);
}
Ok((rect.x, right, rect.y, bottom))
}
/// Select one connected clear interval; never assemble text across blockers.
/// Prefer greater width, then proximity to the previous label anchor, then the
/// leftmost interval. Input order has no influence. A zero-area band has no
/// corridor. Touching the padded vertical band is conservatively obstructing;
/// a zero-area blocker has no area and must receive stroke padding upstream.
pub(super) fn widest_clear(
band: Rectangle,
blockers: &[Rectangle],
preferred_anchor: f32,
) -> Result<Option<Rectangle>, InvalidGeometry> {
let (left, right, top, bottom) = edges(band)?;
if !preferred_anchor.is_finite() {
return Err(InvalidGeometry);
}
let mut blocked = Vec::new();
for &blocker in blockers {
let (x0, x1, y0, y1) = edges(blocker)?;
if blocker.width > 0. && blocker.height > 0. && y1 >= top && y0 <= bottom {
let interval = (x0.max(left), x1.min(right));
if interval.0 < interval.1 {
blocked.push(interval);
}
}
}
if band.width == 0. || band.height == 0. {
return Ok(None);
}
blocked.sort_by(|a, b| a.0.total_cmp(&b.0).then(a.1.total_cmp(&b.1)));
let mut best: Option<Rectangle> = None;
let mut consider = |start: f32, end: f32| {
if start >= end {
return;
}
let candidate = Rectangle {
x: start,
width: end - start,
..band
};
// Use f64 only for comparison distance: subtracting two admitted f32
// world coordinates can overflow even though each rectangle is finite.
let distance = |x: f32| (f64::from(x) - f64::from(preferred_anchor)).abs();
if best.is_none_or(|old| {
candidate.width > old.width
|| (candidate.width == old.width
&& (distance(candidate.x) < distance(old.x)
|| (distance(candidate.x) == distance(old.x) && candidate.x < old.x)))
}) {
best = Some(candidate);
}
};
let mut cursor = left;
for (start, end) in blocked {
consider(cursor, start);
cursor = cursor.max(end);
}
consider(cursor, right);
Ok(best)
}
#[cfg(test)]
mod tests {
use super::*;
fn rect(x: f32, y: f32, width: f32, height: f32) -> Rectangle {
Rectangle {
x,
y,
width,
height,
}
}
#[test]
fn long_message_does_not_cross_an_intermediate_execution() {
let band = rect(20., 100., 700., 22.);
let blockers = [rect(250., 80., 16., 80.), rect(490., 90., 28., 140.)];
let result = widest_clear(band, &blockers, band.x).unwrap().unwrap();
assert_eq!(result, rect(20., 100., 230., 22.));
for blocker in blockers {
assert!(result.x + result.width <= blocker.x || result.x >= blocker.x + blocker.width);
}
assert_eq!(widest_clear(band, &[], band.x).unwrap(), Some(band));
}
#[test]
fn overlapping_unsorted_blockers_merge_and_widest_gap_wins() {
let band = rect(-100., 10., 600., 20.);
let blockers = [
rect(50., 0., 80., 60.),
rect(0., 5., 70., 30.),
rect(450., 0., 100., 60.),
];
let expected = Some(rect(130., 10., 320., 20.));
assert_eq!(widest_clear(band, &blockers, -100.).unwrap(), expected);
let mut reversed = blockers;
reversed.reverse();
assert_eq!(widest_clear(band, &reversed, -100.).unwrap(), expected);
}
#[test]
fn complete_padded_band_and_touching_boundaries_are_obstructed() {
let band = rect(0., 100., 200., 20.);
// The first blocker ends at the band top: checking only a baseline
// would miss it. The second starts at the band bottom.
let blockers = [rect(0., 70., 60., 30.), rect(150., 120., 50., 30.)];
assert_eq!(
widest_clear(band, &blockers, 0.).unwrap(),
Some(rect(60., 100., 90., 20.))
);
let outside = [rect(0., 70., 200., 29.), rect(0., 121., 200., 30.)];
assert_eq!(widest_clear(band, &outside, 0.).unwrap(), Some(band));
}
#[test]
fn ties_keep_the_nearest_anchor_then_choose_leftmost() {
let band = rect(0., 0., 300., 20.);
let blockers = [rect(100., 0., 100., 20.)];
assert_eq!(
widest_clear(band, &blockers, 190.).unwrap(),
Some(rect(200., 0., 100., 20.))
);
assert_eq!(
widest_clear(band, &blockers, 100.).unwrap(),
Some(rect(0., 0., 100., 20.))
);
}
#[test]
fn caller_defined_self_and_reversed_corridors_keep_their_geometry() {
// Direction and self-loop policy are explicit caller inputs. Neither
// a zero endpoint gap nor screen width can silently supply a corridor.
for band in [rect(420., 40., 180., 22.), rect(-400., 90., 350., 22.)] {
assert_eq!(widest_clear(band, &[], band.x).unwrap(), Some(band));
}
assert_eq!(
widest_clear(rect(420., 40., 0., 22.), &[], 420.).unwrap(),
None
);
assert_eq!(
widest_clear(rect(420., 40., 180., 0.), &[], 420.).unwrap(),
None
);
assert_eq!(
widest_clear(rect(0., 0., 100., 20.), &[rect(-10., -10., 200., 50.)], 0.).unwrap(),
None
);
}
#[test]
fn invalid_or_overflowing_geometry_never_enters_a_corridor() {
let band = rect(0., 0., 100., 20.);
for invalid in [
rect(f32::NAN, 0., 10., 20.),
rect(0., 0., -1., 20.),
rect(f32::MAX, 0., f32::MAX, 20.),
rect(0., f32::MAX, 10., f32::MAX),
] {
assert_eq!(widest_clear(invalid, &[], 0.), Err(InvalidGeometry));
assert_eq!(widest_clear(band, &[invalid], 0.), Err(InvalidGeometry));
}
assert_eq!(widest_clear(band, &[], f32::INFINITY), Err(InvalidGeometry));
}
}
@@ -0,0 +1,487 @@
//! Unexecuted evaluator candidates; the module is intentionally not wired yet.
use super::*;
use iced::advanced::{
graphics::text::Paragraph,
text::{Paragraph as _, Text as ParagraphText, Wrapping},
};
// Independent paragraph construction: no candidate cache, normalization, search,
// or character-width estimate is used by this measurement oracle.
fn paragraph_size(content: &str, zoom: f32, style: Style) -> Size {
let size = Pixels(style.size.0 * zoom);
let line_height = match style.line_height {
LineHeight::Relative(factor) => LineHeight::Relative(factor),
LineHeight::Absolute(pixels) => LineHeight::Absolute(Pixels(pixels.0 * zoom)),
};
let paragraph = Paragraph::with_text(ParagraphText {
content,
bounds: Size::INFINITE,
size,
line_height,
font: style.font,
align_x: Alignment::Default,
align_y: iced::alignment::Vertical::Top,
shaping: style.shaping,
wrapping: Wrapping::default(),
});
assert_eq!(paragraph.buffer().layout_runs().count(), 1);
Size::new(paragraph.min_width() / zoom, paragraph.min_height() / zoom)
}
#[test]
fn adequate_corridor_reveals_full_realistic_names_and_distinguishing_suffixes() {
let mut cache = Cache::default();
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
let style = Style::default();
for text in [
"1. call · Request valve repair · Technician service conversation",
"2. reply · Return pressure after repair · Technician service conversation",
"1. call · Request redundant hydraulic pressure calibration channel A",
"1. call · Request redundant hydraulic pressure calibration channel B",
] {
let wide = cache.resolve(&session, text, 1600., 1., style).unwrap();
assert_eq!(wide.displayed(&session, 1.).unwrap(), text);
assert_eq!(wide.retained_source(&session, 1.).unwrap(), 0..text.len());
assert_eq!(wide.bounds.size(), paragraph_size(text, 1., style));
let narrow = cache.resolve(&session, text, 80., 1., style).unwrap();
assert_ne!(narrow.displayed, text);
assert!(narrow.bounds.width <= 80.);
assert_eq!(
narrow.bounds.size(),
paragraph_size(&narrow.displayed, 1., style)
);
}
}
#[test]
fn normalized_visible_prefix_maps_to_original_bytes_without_claiming_hidden_operation() {
let raw = " \t1. call · Pump\r\n · Operation: Need\u{301}le ";
let expected = "1. call · Pump · Operation: Need\u{301}le";
let n = Normalized::new(raw);
assert_eq!(n.text, expected);
let prefix = "1. call · Pump ";
assert_eq!(n.source_end(prefix.len()), raw.find("· Operation").unwrap());
let mut cache = Cache::default();
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
let wide = cache
.resolve(&session, raw, 1600., 1., Style::default())
.unwrap();
assert_eq!(wide.displayed, expected);
assert_eq!(wide.retained_source.end, raw.trim_end().len());
let field = raw.find("Need").unwrap()..raw.trim_end().len();
assert!(wide.retains_any(&session, 1., field.clone()).unwrap());
assert!(
!wide.retains_any(&session, 1., 0..2).unwrap(),
"normalized whitespace is not a visible field"
);
let small = cache
.resolve(&session, raw, 70., 1., Style::default())
.unwrap();
assert!(!small.retains_any(&session, 1., field).unwrap());
assert!(small.retained_source.end < raw.find("Operation").unwrap());
}
#[test]
fn grapheme_prefixes_and_zoom_match_actual_paragraph_layout() {
let text = "call · 戻り値 圧力 e\u{301} 👩‍🔬 safety valve verification";
let mut cache = Cache::default();
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
for zoom in [0.66, 1., 1.75] {
let label = cache
.resolve(&session, text, 105., zoom, Style::default())
.unwrap();
assert!(label.bounds.width <= 105.);
assert_eq!(
label.bounds.size(),
paragraph_size(&label.displayed, zoom, Style::default())
);
assert!(
text.grapheme_indices(true)
.any(|(i, _)| i == label.retained_source.end)
|| label.retained_source.end == text.len()
);
let painted = label
.canvas_text(&session, zoom, Point::new(400., 90.), Color::BLACK)
.unwrap();
assert_eq!(painted.content, label.displayed);
assert!(painted.max_width.is_infinite());
assert_eq!(
label
.bounds_at(&session, zoom, painted.position)
.unwrap()
.size(),
label.bounds.size()
);
}
}
#[test]
fn empty_and_too_narrow_labels_never_claim_source_visibility() {
let mut cache = Cache::default();
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
for text in ["", " \t\n", "Operation: Pump"] {
let label = cache
.resolve(&session, text, 0., 1., Style::default())
.unwrap();
assert_eq!(label.displayed, "");
assert_eq!(label.retained_source, 0..0);
assert_eq!(label.bounds.size(), Size::ZERO);
assert!(!label.retains_any(&session, 1., 0..text.len()).unwrap());
}
}
#[test]
fn redraws_reuse_resolution_but_width_zoom_and_style_are_dependencies() {
let mut cache = Cache::default();
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
let text = "call · Pressure verification with a descriptive operation label";
let style = Style::default();
let a = cache.resolve(&session, text, 100., 1., style).unwrap();
let measured = cache.allocations;
let b = cache.resolve(&session, text, 100., 1., style).unwrap();
assert!(Arc::ptr_eq(&a, &b));
assert_eq!(cache.allocations, measured);
for (width, zoom, style) in [
(120., 1., style),
(100., 0.66, style),
(
100.,
1.,
Style {
size: Pixels(14.),
..style
},
),
] {
let other = cache.resolve(&session, text, width, zoom, style).unwrap();
assert!(!Arc::ptr_eq(&a, &other));
}
}
#[test]
fn resident_entry_is_remeasured_for_a_fresh_settled_render_session() {
let mut cache = Cache::default();
let old_session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
let text = "call · Pressure verification";
let a = cache
.resolve(&old_session, text, 100., 1., Style::default())
.unwrap();
assert_eq!(cache.entries.len(), 1);
assert!(Arc::ptr_eq(&cache.entries.front().unwrap().label, &a));
let measured = cache.allocations;
assert!(measured > 0);
assert!(Arc::ptr_eq(
&a,
&cache
.resolve(&old_session, text, 100., 1., Style::default())
.unwrap()
));
assert_eq!(cache.allocations, measured);
// A fresh renderer session invalidates a still-resident entry. This does
// not mutate global fonts or purport to test dynamic WGPU font loading.
let new_session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
let refreshed = cache
.resolve(&new_session, text, 100., 1., Style::default())
.unwrap();
assert!(!Arc::ptr_eq(&a, &refreshed));
assert!(cache.allocations > measured);
assert_eq!(cache.entries.len(), 1);
assert!(Arc::ptr_eq(
&cache.entries.front().unwrap().label,
&refreshed
));
}
#[test]
fn resident_entry_remeasures_after_local_font_epoch_invalidation() {
let mut cache = Cache::default();
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
let text = "call · Resident pressure label";
let a = cache
.resolve(&session, text, 100., 1., Style::default())
.unwrap();
assert_eq!(cache.entries.len(), 1);
assert!(Arc::ptr_eq(&cache.entries.front().unwrap().label, &a));
let measured = cache.allocations;
assert!(measured > 0);
// Exercise the local memo epoch branch while a is still resident. No
// intervening width/zoom/style resolution can cause incidental eviction.
// This is not an actual font-load or renderer-cache acceptance test.
cache.version = None;
let refreshed = cache
.resolve(&session, text, 100., 1., Style::default())
.unwrap();
assert!(!Arc::ptr_eq(&a, &refreshed));
assert!(cache.allocations > measured);
assert_eq!(cache.entries.len(), 1);
assert!(Arc::ptr_eq(
&cache.entries.front().unwrap().label,
&refreshed
));
}
#[test]
fn retained_artifact_rejects_other_zoom_and_render_session_for_every_consumer() {
let mut cache = Cache::default();
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
let label = cache
.resolve(&session, "Operation: Pressure", 200., 1., Style::default())
.unwrap();
let other = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
for (context, zoom, expected) in [
(&session, 0.66, Error::StaleZoom),
(&other, 1., Error::StaleRenderSession),
] {
assert_eq!(label.displayed(context, zoom).unwrap_err(), expected);
assert_eq!(label.retained_source(context, zoom).unwrap_err(), expected);
assert_eq!(
label.retains_any(context, zoom, 0..9).unwrap_err(),
expected
);
assert_eq!(
label.bounds_at(context, zoom, Point::ORIGIN).unwrap_err(),
expected
);
assert_eq!(
label
.canvas_text(context, zoom, Point::ORIGIN, Color::BLACK)
.unwrap_err(),
expected
);
}
assert!(
label
.canvas_text(&session, 1., Point::ORIGIN, Color::BLACK)
.is_ok()
);
}
#[test]
fn finite_scaled_metrics_cannot_escape_as_infinite_world_dimensions() {
let mut cache = Cache::default();
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
let style = Style {
size: Pixels(2.),
line_height: LineHeight::Relative(f32::MAX),
..Style::default()
};
// At zoom 0.5 the scaled size=1 and line height=MAX are finite. The
// required world line height is not; reject before passing it to shaping.
let scaled = style.scaled(0.5);
assert!(scaled.0.is_finite() && scaled.1.is_finite());
assert_eq!(
cache
.resolve(&session, "call", 100., 0.5, style)
.unwrap_err(),
Error::InvalidMetrics
);
assert_eq!(cache.allocations, 0);
assert!(cache.entries.is_empty());
// Independently exercise validation after division for actual measured
// dimensions: finite scaled width/height do not imply finite world values.
assert_eq!(
world_size(Size::new(f32::MAX, 1.), 0.5),
Err(Error::InvalidMeasurement)
);
assert_eq!(
world_size(Size::new(1., f32::MAX), 0.5),
Err(Error::InvalidMeasurement)
);
assert_eq!(
world_size(Size::new(20., 8.), 0.5).unwrap(),
Size::new(40., 16.)
);
}
#[test]
fn malformed_metrics_and_explicit_experiment_budgets_fail_without_a_cached_candidate() {
let mut cache = Cache::default();
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
for (width, zoom) in [(f32::NAN, 1.), (-1., 1.), (100., 0.), (100., f32::INFINITY)] {
assert_eq!(
cache
.resolve(&session, "call", width, zoom, Style::default())
.unwrap_err(),
Error::InvalidMetrics
);
}
assert_eq!(
cache
.resolve(
&session,
&"x".repeat(MAX_SOURCE_BYTES + 1),
100.,
1.,
Style::default()
)
.unwrap_err(),
Error::SourceBudget
);
assert_eq!(
cache
.resolve(
&session,
&"x".repeat(MAX_GRAPHEMES + 1),
100.,
1.,
Style::default()
)
.unwrap_err(),
Error::GraphemeBudget
);
assert!(cache.entries.is_empty());
}
#[test]
fn bounds_translation_rejects_nonfinite_world_positions_and_endpoints() {
let mut cache = Cache::default();
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
let label = cache
.resolve(&session, "call", 100., 1., Style::default())
.unwrap();
for position in [Point::new(f32::NAN, 0.), Point::new(0., f32::INFINITY)] {
assert_eq!(
label.bounds_at(&session, 1., position).unwrap_err(),
Error::InvalidPosition
);
assert_eq!(
label
.canvas_text(&session, 1., position, Color::BLACK)
.unwrap_err(),
Error::InvalidPosition
);
}
assert_eq!(
translated_bounds(Point::new(f32::MAX, 0.), Size::new(f32::MAX, 1.)),
Err(Error::InvalidPosition)
);
assert_eq!(
translated_bounds(Point::new(0., f32::MAX), Size::new(1., f32::MAX)),
Err(Error::InvalidPosition)
);
assert_eq!(
label
.bounds_at(&session, 1., Point::new(-20., -10.))
.unwrap()
.position(),
Point::new(-20., -10.)
);
}
/// Must run as the ONLY selected test in a dedicated, fresh test process.
/// The ignored marker keeps this global-font mutation out of normal parallel
/// desktop tests. See the evidence directory's exact executable runner.
#[test]
#[ignore = "mutates the global font database; run only this exact test in a fresh process"]
fn actual_font_load_rejects_retained_label_and_old_session() {
use graphics_text::cosmic_text::fontdb;
use std::borrow::Cow;
let mut cache = Cache::default();
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
let source = "Operation: Pressure";
let label = cache
.resolve(&session, source, 200., 1., Style::default())
.unwrap();
assert_eq!(label.displayed(&session, 1.).unwrap(), source);
assert!(
label
.canvas_text(&session, 1., Point::ORIGIN, Color::BLACK)
.is_ok()
);
let measured = cache.allocations;
assert!(measured > 0);
assert!(Arc::ptr_eq(&cache.entries.front().unwrap().label, &label));
let (version_before, version_after, faces_before, faces_after) = {
let mut fonts = graphics_text::font_system().write().unwrap();
let face = fonts
.raw()
.db()
.faces()
.find(|face| {
face.families
.iter()
.any(|(family, _)| family == "Iced-Icons")
})
.expect("Iced 0.14 embeds and loads its known Iced-Icons.ttf face")
.id;
let bytes = fonts
.raw()
.db()
.with_face_data(face, |data, _| data.to_vec())
.expect("the built-in binary font has accessible bytes");
assert!(!bytes.is_empty());
// load_font increments Version even for invalid bytes. Independently
// establish actual face admission so this cannot pass with invalid data.
let mut probe = fontdb::Database::new();
probe.load_font_data(bytes.clone());
let admitted = probe
.faces()
.filter(|face| {
face.families
.iter()
.any(|(family, _)| family == "Iced-Icons")
})
.count();
assert!(
admitted > 0,
"the extracted bytes parse as the expected real font"
);
let before = fonts.version();
assert_eq!(before, session.version);
let count = fonts.raw().db().faces().count();
// Owned bytes intentionally bypass Borrowed-address deduplication.
// This is an actual valid duplicate-face load, not a claim the chosen
// face or rendered metrics change; the stale-epoch rule is the oracle.
fonts.load_font(Cow::Owned(bytes));
(
before,
fonts.version(),
count,
fonts.raw().db().faces().count(),
)
};
assert_ne!(version_before, version_after);
assert!(
faces_after > faces_before,
"actual font admission must accompany the version increment"
);
assert_eq!(
label.displayed(&session, 1.).unwrap_err(),
Error::FontSetChanged
);
assert_eq!(
label.retained_source(&session, 1.).unwrap_err(),
Error::FontSetChanged
);
assert_eq!(
label.retains_any(&session, 1., 0..9).unwrap_err(),
Error::FontSetChanged
);
assert_eq!(
label.bounds_at(&session, 1., Point::ORIGIN).unwrap_err(),
Error::FontSetChanged
);
assert_eq!(
label
.canvas_text(&session, 1., Point::ORIGIN, Color::BLACK)
.unwrap_err(),
Error::FontSetChanged
);
assert_eq!(
cache
.resolve(&session, source, 200., 1., Style::default())
.unwrap_err(),
Error::FontSetChanged
);
assert_eq!(
cache.allocations, measured,
"an invalid session must not remeasure and imply renderer repair"
);
println!(
"Valid Iced-Icons load: faces {faces_before}->{faces_after}, font Version {version_before:?}->{version_after:?}; old label and session rejected"
);
// Do not create a fresh session or claim dynamic WGPU cache recovery here.
// Process exit is the isolation boundary; no other test may run afterward.
}
@@ -0,0 +1,3 @@
attempt1/* -text whitespace=cr-at-eol
attempt2/* -text whitespace=cr-at-eol
attempt3/* -text whitespace=cr-at-eol
@@ -0,0 +1,147 @@
# Scenario label measurement: preserved candidate and source correction
This is an **unwired, uncompiled experiment**, not an accepted renderer policy.
Source base: `208268c8aa7f6bc3c2bab6a3c0657358f3f7dc23`.
Parent's docs-only worktree HEAD: `75cb09dade2cf4b9a176b5e6159f43e2345d196e`.
Only the two new `crates/oa-desktop/src/behavior/scenario_label` module/test
paths and this evidence directory belong to this correction.
## Preserved attempt 1
`attempt1/scenario_label.rs` and `attempt1/tests.rs` are the exact raw files
reviewed before correction. Both SHA256 values were checked before copying and
again after copying; [manifest](attempt1/manifest.json) records source paths and
hashes. They remain historical evidence, not alternate compiler inputs.
Independent static review identified:
1. A retained Label Arc lacked its font epoch and zoom. Cached dimensions could
be reused by painting/hit/navigation after their measurement context changed.
2. Iced's WGPU text cache omits font Version; remeasuring only this module cannot
establish dynamic-font parity with an already-populated renderer cache.
3. The font-epoch test had already evicted its original entry with three variants
in a capacity-two cache. Different Arc identity did not prove invalidation.
4. Finite scaled metrics could overflow when converted back to world units.
Concrete example: size 2, Relative(MAX) line height, zoom 0.5.
These were source findings. Neither the old candidate nor the correction was
compiled or executed by this worker; no failure/pass count is inferred.
## Preserved attempt 2 and missing font-version branch
`attempt2/` preserves both corrected raw source files and this note before the
follow-up, with exact SHA256 checks before and after copying. The root reviewer
found that local cache epoch invalidation and a fresh session identity did not
exercise an actual global font Version change. That specific branch was still
untested; the historical ten tests were not evidence for it.
## Corrected experimental seam
`RenderSession::after_fonts_settled_and_renderer_reset()` explicitly records a
caller-established settled-font rendering lifetime. It does not reset or inspect
the renderer. The caller must finish font loading and create a fresh renderer
cache, then prohibit font changes between resolution, guarded consumption and
draw. A changed global font Version rejects the old session; a new local cache
alone is not a repair for WGPU's old glyph buffers.
Labels record that session identity/font Version and measured zoom. Displayed
text, retained-source bytes, bounds, field retention and canvas Text construction
use checked accessors. Another session, another zoom or a detected font change
rejects a retained artifact. Returned raw Text/bounds must be consumed in that
checked context: holding those values across context changes bypasses the guard.
This is an explicit limited contract, not atomic protection against a concurrent
font load or an incorrect renderer transform supplied by the caller.
Finite world line metrics are checked before shaping. Actual measured dimensions
are checked again after division by zoom. The existing 64KiB source and 4096
grapheme budgets remain explicit experiment errors, not adopted product limits.
Eleven ordinary test candidates now include separate resident-entry font-epoch invalidation,
fresh-session invalidation, stale artifact rejection for every consumer, and
the overflow counterexample, plus finite world-position/rectangle-endpoint
validation for bounds_at and canvas_text. This last check does not establish
validity under arbitrary camera transforms or contain raster ink.
The invalidation assertions require a still-resident
entry, positive prior measurement count and an increased count afterward.
These ordinary tests do not mutate the global font database or claim dynamic-font rendering
acceptance. The existing actual Paragraph comparisons remain layout-metric
oracles, not raster-ink/Scene collision evidence.
One additional **ignored** test, `actual_font_load_rejects_retained_label_and_old_session`,
must run alone in a fresh process. It resolves a label, extracts the actual
Iced-Icons font bytes that pinned Iced embeds in its global fontdb, independently
parses them into a fresh fontdb and confirms the expected family, then loads owned
valid bytes through actual FontSystem::load_font. Both a new face and a changed
Version must be observed. The old Label's five consumer accessors and old-session
Cache::resolve must return FontSetChanged; no new measurements may occur.
This is a valid duplicate-face load, not invalid input used merely to increment
a version. It does not claim the selected face or visual metrics change, create
a WGPU renderer, or test renderer recovery after dynamic loading. No font asset,
system-specific font path, download, or new dependency is needed. Version mutation
is not undone; dedicated process exit is the isolation boundary.
## Pinned primary APIs and remaining evaluator
Cargo.lock pins iced_graphics/iced_wgpu 0.14.0 and iced_widget 0.14.2. Relevant
local registry sources: `iced_wgpu-0.14.0/src/geometry.rs` (`fill_text`, scaled
font metrics), `iced_wgpu-0.14.0/src/text.rs` (`Text::Cached` allocation),
`iced_graphics-0.14.0/src/text/cache.rs` (Key/hash lacks Version), and
`iced_graphics-0.14.0/src/text.rs` (FontSystem::load_font/version).
Independent reviewer confirmed `geometry.rs:306-360`, WGPU `text.rs:443-500`
and its retained BufferCache at line 304 (trim at 435 only evicts unused
entries), graphics `text/cache.rs:109-146`, and graphics `compositor.rs:69`.
The compositor's font-load path changes the global FontSystem, not the WGPU
buffer cache. A check immediately before canvas_text is therefore not atomic
with the later WGPU prepare; fonts must remain unchanged for the entire session.
The module remains undeclared in behavior.rs. Parent may separately add test-only
`#[cfg(test)] mod scenario_label;` and run:
```
cargo test -p oa-desktop --locked --offline behavior::scenario_label::tests:: -- --test-threads=1
```
The normal filter selects twelve tests: eleven ordinary tests and one ignored
global-font test. This is an expected selection, not a claim they pass. Do not run
the whole desktop suite with `--include-ignored` to exercise the font test.
After the parent compiles and records a matching test executable, use the exact
isolation runner (no Cargo invocation inside the runner):
```powershell
& ./harness/experiments/scenario-label-measurement-20260913/run-font-version-test.ps1 `
-TestExecutable $desktopTestExe -ExpectedSha256 $expectedTestHash `
-RunDirectory $freshEvidenceDir
```
It verifies the supplied binary hash, lists exactly one ignored test, then starts
a separate fresh executable process with:
```
--ignored --exact behavior::scenario_label::tests::actual_font_load_rejects_retained_label_and_old_session --nocapture --test-threads=1
```
The runner refuses an existing result directory and requires exactly one passing
test, preserving selection/output/receipt. Parent must provide the matching DLL
environment and source-to-binary provenance. Runner and tests are unexecuted.
It also accepts `-TestName scenario_label::tests::actual_font_load_rejects_retained_label_and_old_session`
for a parent-built standalone test wrapper importing the exact module at crate
root. That result must be reported as standalone Iced/module evidence, not desktop
or native integration. Wrapper, dependency artifacts and source hashes must be
bound separately by the compiler owner.
Corridors remain
external; ordinary/reversed and self-message positioning is not selected here.
The bounded prefix search guarantees measured fit, not a globally maximal prefix
for all contextual shaping. No wrapping, row-height change, public schema or
production gesture is introduced. Real WGPU label ink, activation shoulders,
clipping, viewport transforms, and actual navigation integration remain required
before adopting the seam. Original fixed 32-grapheme/225-unit rendering is still
unchanged for baseline comparison.
## Parent corridor candidate and isolated evaluator
The parent added `scenario_label/corridor.rs`: a bounded geometric candidate that subtracts padded blockers intersecting the full label band and selects the widest connected interval, with deterministic anchor/leftmost tie-breaking. It takes an explicit finite caller-supplied band; self/reversed corridor construction, actual Scene integration, font/ink clearance and release gesture policy remain unimplemented. Six unexecuted tests cover intermediate activations, unsorted overlapping blockers, complete-band intersections, ties, explicit self/reversed bands and invalid/overflow coordinates. This is an evaluation candidate, not an adopted paint policy.
`attempt3` preserves both corrected raw label modules and the first raw corridor before scoped rustfmt. `standalone.rs` imports the actual three current source files without copying their implementation or wiring the desktop. Its expected listing is 18 tests: 17 ordinary and one ignored global-font mutation. Compiling/running that wrapper against explicitly identified pinned Iced/unicode dependencies is a standalone evaluator only; it does not establish desktop integration or WGPU/native acceptance. Use the font runner with the standalone exact test name documented above, in a separate process. No execution result is claimed by this prepared note; subsequent gate evidence will record actual results.
@@ -0,0 +1,17 @@
{
"base": "208268c8aa7f6bc3c2bab6a3c0657358f3f7dc23",
"status": "Original uncompiled, unwired candidate; static review found stale reuse, vacuous epoch test and post-division overflow gaps",
"files": [
{
"path": "crates/oa-desktop/src/behavior/scenario_label.rs",
"hash": "1CACEDE2BCB0E3E86C0D0B691D6E65FDADEA75F93FC74BBAF94E8E2082D6E877",
"name": "scenario_label.rs"
},
{
"path": "crates/oa-desktop/src/behavior/scenario_label/tests.rs",
"hash": "9AC3A0D091E3A319D7822C76B34DCEA56B8D400DBB7F55B3711659E84B6F2BDB",
"name": "tests.rs"
}
],
"docsOnlyHead": "75cb09dade2cf4b9a176b5e6159f43e2345d196e"
}
@@ -0,0 +1,206 @@
//! Unwired single-line scenario label experiment. No renderer or schema is changed.
//!
//! Iced 0.14.0 WGPU `geometry::Frame::fill_text` shapes positive uniform-scale
//! canvas text at `size * zoom`; its `text::prepare` uses graphics text::Cache.
//! This adapter uses that same allocator/font system/attributes, not a character
//! width estimate. Canvas Text::max_width is wrapping, so it stays infinite.
//!
//! Integration seam: after lifecycle/activation endpoint alignment, a caller
//! supplies a usable text width in world units and retains one cache per live
//! labelled occurrence across Scene rebuilds. Paint, hit testing, navigation and extents
//! consume the returned Label. Position/color do not invalidate measurement.
//! This does NOT choose a self-call, reversed-arrow or obstacle corridor policy.
//! It measures layout advances, not raster ink; overhang/clip/activation-border
//! acceptance still requires the real WGPU evaluator. The old 32-grapheme/225px
//! renderer is deliberately untouched for a later baseline comparison.
use iced::advanced::{graphics::text as graphics_text, text::{Alignment, LineHeight, Shaping}};
use iced::{Color, Font, Pixels, Point, Rectangle, Size, widget::canvas::Text};
use std::{collections::VecDeque, ops::Range, sync::Arc};
use unicode_segmentation::UnicodeSegmentation;
const MAX_SOURCE_BYTES: usize = 64 * 1024;
const MAX_GRAPHEMES: usize = 4096;
const MAX_ENTRIES: usize = 2;
#[derive(Clone, Copy, Debug, PartialEq)]
pub(super) struct Style {
pub font: Font,
pub size: Pixels,
pub line_height: LineHeight,
pub shaping: Shaping,
}
impl Default for Style {
fn default() -> Self {
let text = Text::default();
Self { font: text.font, size: Pixels(12.), line_height: text.line_height, shaping: text.shaping }
}
}
impl Style {
fn scaled(self, zoom: f32) -> (f32, f32) {
let size = Pixels(self.size.0 * zoom);
let line_height = match self.line_height {
LineHeight::Absolute(value) => LineHeight::Absolute(Pixels(value.0 * zoom)),
LineHeight::Relative(value) => LineHeight::Relative(value),
};
(size.0, line_height.to_absolute(size).0)
}
}
#[derive(Clone, Debug)]
pub(super) struct Label {
source: String,
pub displayed: String,
/// Original-label bytes represented before any added ellipsis. Includes
/// collapsed whitespace, but `retains_any` never counts whitespace as text.
pub retained_source: Range<usize>,
/// World-space layout advances, at origin; not glyph-ink containment.
pub bounds: Rectangle,
style: Style,
}
impl Label {
pub fn retains_any(&self, field: Range<usize>) -> bool {
if field.start > field.end || self.source.get(field.clone()).is_none() {
return false;
}
let end = field.end.min(self.retained_source.end);
field.start < end && self.source.get(field.start..end)
.is_some_and(|text| text.chars().any(|c| !c.is_whitespace()))
}
pub fn bounds_at(&self, position: Point) -> Rectangle {
Rectangle::new(position, self.bounds.size())
}
/// Paint under the same positive uniform zoom used for `Cache::resolve`.
/// No per-label wrapping or implicit text maximum is introduced.
pub fn canvas_text(&self, position: Point, color: Color) -> Text {
Text { content: self.displayed.clone(), position, color,
size: self.style.size, line_height: self.style.line_height,
font: self.style.font, shaping: self.style.shaping,
max_width: f32::INFINITY, ..Text::default() }
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum Error { InvalidMetrics, SourceBudget, GraphemeBudget, FontSystemUnavailable, InvalidMeasurement }
struct Entry { source: String, style: Style, zoom: f32, width: f32, label: Arc<Label> }
#[derive(Default)]
/// Two recent resolutions for ONE live labelled occurrence. A global small LRU
/// would thrash when a larger Scene is rebuilt in order. The integration owner
/// must retain these by occurrence identity and prune on removal/document change.
pub(super) struct Cache {
version: Option<graphics_text::Version>,
entries: VecDeque<Entry>,
#[cfg(test)]
allocations: usize,
}
impl Cache {
/// `width` excludes any caller-chosen backplate padding. Zero is valid.
/// Cache ownership must survive redraws; constructing one per Scene defeats
/// reuse. Fonts loaded through Iced invalidate all entries by Version.
/// Error handling/fallback for these explicit experiment budgets is an
/// integration decision; never silently paint the old truncated contract.
pub fn resolve(&mut self, source: &str, width: f32, zoom: f32, style: Style) -> Result<Arc<Label>, Error> {
let (size, line_height) = style.scaled(zoom);
if !width.is_finite() || width < 0. || !zoom.is_finite() || zoom <= 0.
|| !size.is_finite() || size <= 0. || !line_height.is_finite() || line_height <= 0.
|| !(width * zoom).is_finite() {
return Err(Error::InvalidMetrics);
}
if source.len() > MAX_SOURCE_BYTES { return Err(Error::SourceBudget); }
let mut fonts = graphics_text::font_system().write().map_err(|_| Error::FontSystemUnavailable)?;
if self.version != Some(fonts.version()) {
self.entries.clear();
self.version = Some(fonts.version());
}
if let Some(index) = self.entries.iter().position(|entry|
entry.source == source && entry.style == style && entry.zoom == zoom && entry.width == width) {
let entry = self.entries.remove(index).unwrap();
let label = entry.label.clone();
self.entries.push_back(entry);
return Ok(label);
}
let normalized = Normalized::new(source);
let cuts: Vec<usize> = normalized.text.grapheme_indices(true).map(|(i, _)| i)
.chain(std::iter::once(normalized.text.len())).collect();
if cuts.len().saturating_sub(1) > MAX_GRAPHEMES { return Err(Error::GraphemeBudget); }
// Candidate buffers are temporary. Only the resolved strings/metrics
// survive, bounded by MAX_ENTRIES; renderer caching remains independent.
let mut shapes = graphics_text::Cache::new();
let mut measure = |content: &str| -> Result<Size, Error> {
if content.is_empty() { return Ok(Size::ZERO); }
#[cfg(test)] { self.allocations += 1; }
let (_, entry) = shapes.allocate(fonts.raw(), graphics_text::cache::Key {
content, size, line_height, font: style.font, bounds: Size::INFINITE,
shaping: style.shaping, align_x: Alignment::Default,
});
if entry.buffer.layout_runs().count() != 1 || !entry.min_bounds.width.is_finite()
|| !entry.min_bounds.height.is_finite() { return Err(Error::InvalidMeasurement); }
Ok(Size::new(entry.min_bounds.width / zoom, entry.min_bounds.height / zoom))
};
let full = measure(&normalized.text)?;
let (displayed, retained, measured) = if full.width <= width {
(normalized.text.clone(), normalized.text.len(), full)
} else {
let ellipsis = measure("…")?;
if ellipsis.width > width {
(String::new(), 0, Size::ZERO)
} else {
// Every accepted candidate is actually shaped and fits. Search
// is bounded/logarithmic; contextual shaping is not universally
// monotone, so this does NOT promise the globally longest prefix.
// Full content is always checked first and never needlessly cut.
let mut best = ("…".to_owned(), 0, ellipsis);
let (mut low, mut high) = (1, cuts.len().saturating_sub(1));
while low < high {
let middle = low + (high - low) / 2;
let candidate = format!("{}…", &normalized.text[..cuts[middle]]);
let measured = measure(&candidate)?;
if measured.width <= width {
best = (candidate, cuts[middle], measured);
low = middle + 1;
} else { high = middle; }
}
best
}
};
let label = Arc::new(Label { source: source.into(), displayed,
retained_source: 0..normalized.source_end(retained),
bounds: Rectangle::with_size(measured), style });
if self.entries.len() == MAX_ENTRIES { self.entries.pop_front(); }
self.entries.push_back(Entry { source: source.into(), style, zoom, width, label: label.clone() });
Ok(label)
}
}
struct Normalized { text: String, ends: Vec<(usize, usize)> }
impl Normalized {
fn new(source: &str) -> Self {
let mut result = Self { text: String::new(), ends: vec![(0, 0)] };
let mut separator = false;
for (start, c) in source.char_indices() {
if c.is_whitespace() { separator = !result.text.is_empty(); continue; }
if separator {
result.text.push(' ');
result.ends.push((result.text.len(), start));
separator = false;
}
result.text.push(c);
result.ends.push((result.text.len(), start + c.len_utf8()));
}
result
}
fn source_end(&self, normalized_end: usize) -> usize {
self.ends.binary_search_by_key(&normalized_end, |(end, _)| *end)
.map(|index| self.ends[index].1).expect("grapheme cuts are character boundaries")
}
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,120 @@
//! Unexecuted evaluator candidates; the module is intentionally not wired yet.
use super::*;
use iced::advanced::{graphics::text::Paragraph, text::{Paragraph as _, Text as ParagraphText, Wrapping}};
// Independent paragraph construction: no candidate cache, normalization, search,
// or character-width estimate is used by this measurement oracle.
fn paragraph_size(content: &str, zoom: f32, style: Style) -> Size {
let size = Pixels(style.size.0 * zoom);
let line_height = match style.line_height {
LineHeight::Relative(factor) => LineHeight::Relative(factor),
LineHeight::Absolute(pixels) => LineHeight::Absolute(Pixels(pixels.0 * zoom)),
};
let paragraph = Paragraph::with_text(ParagraphText { content, bounds: Size::INFINITE,
size, line_height, font: style.font, align_x: Alignment::Default,
align_y: iced::alignment::Vertical::Top, shaping: style.shaping, wrapping: Wrapping::default() });
assert_eq!(paragraph.buffer().layout_runs().count(), 1);
Size::new(paragraph.min_width() / zoom, paragraph.min_height() / zoom)
}
#[test]
fn adequate_corridor_reveals_full_realistic_names_and_distinguishing_suffixes() {
let mut cache = Cache::default();
let style = Style::default();
for text in [
"1. call · Request valve repair · Technician service conversation",
"2. reply · Return pressure after repair · Technician service conversation",
"1. call · Request redundant hydraulic pressure calibration channel A",
"1. call · Request redundant hydraulic pressure calibration channel B",
] {
let wide = cache.resolve(text, 1600., 1., style).unwrap();
assert_eq!(wide.displayed, text);
assert_eq!(wide.retained_source, 0..text.len());
assert_eq!(wide.bounds.size(), paragraph_size(text, 1., style));
let narrow = cache.resolve(text, 80., 1., style).unwrap();
assert_ne!(narrow.displayed, text);
assert!(narrow.bounds.width <= 80.);
assert_eq!(narrow.bounds.size(), paragraph_size(&narrow.displayed, 1., style));
}
}
#[test]
fn normalized_visible_prefix_maps_to_original_bytes_without_claiming_hidden_operation() {
let raw = " \t1. call · Pump\r\n · Operation: Need\u{301}le ";
let expected = "1. call · Pump · Operation: Need\u{301}le";
let n = Normalized::new(raw);
assert_eq!(n.text, expected);
let prefix = "1. call · Pump ";
assert_eq!(n.source_end(prefix.len()), raw.find("· Operation").unwrap());
let mut cache = Cache::default();
let wide = cache.resolve(raw, 1600., 1., Style::default()).unwrap();
assert_eq!(wide.displayed, expected);
assert_eq!(wide.retained_source.end, raw.trim_end().len());
let field = raw.find("Need").unwrap()..raw.trim_end().len();
assert!(wide.retains_any(field.clone()));
assert!(!wide.retains_any(0..2), "normalized whitespace is not a visible field");
let small = cache.resolve(raw, 70., 1., Style::default()).unwrap();
assert!(!small.retains_any(field));
assert!(small.retained_source.end < raw.find("Operation").unwrap());
}
#[test]
fn grapheme_prefixes_and_zoom_match_actual_paragraph_layout() {
let text = "call · 戻り値 圧力 e\u{301} 👩‍🔬 safety valve verification";
let mut cache = Cache::default();
for zoom in [0.66, 1., 1.75] {
let label = cache.resolve(text, 105., zoom, Style::default()).unwrap();
assert!(label.bounds.width <= 105.);
assert_eq!(label.bounds.size(), paragraph_size(&label.displayed, zoom, Style::default()));
assert!(text.grapheme_indices(true).any(|(i, _)| i == label.retained_source.end)
|| label.retained_source.end == text.len());
let painted = label.canvas_text(Point::new(400., 90.), Color::BLACK);
assert_eq!(painted.content, label.displayed);
assert!(painted.max_width.is_infinite());
assert_eq!(label.bounds_at(painted.position).size(), label.bounds.size());
}
}
#[test]
fn empty_and_too_narrow_labels_never_claim_source_visibility() {
let mut cache = Cache::default();
for text in ["", " \t\n", "Operation: Pump"] {
let label = cache.resolve(text, 0., 1., Style::default()).unwrap();
assert_eq!(label.displayed, "");
assert_eq!(label.retained_source, 0..0);
assert_eq!(label.bounds.size(), Size::ZERO);
assert!(!label.retains_any(0..text.len()));
}
}
#[test]
fn redraws_reuse_resolution_but_width_zoom_style_and_font_epoch_are_dependencies() {
let mut cache = Cache::default();
let text = "call · Pressure verification with a descriptive operation label";
let style = Style::default();
let a = cache.resolve(text, 100., 1., style).unwrap();
let measured = cache.allocations;
let b = cache.resolve(text, 100., 1., style).unwrap();
assert!(Arc::ptr_eq(&a, &b));
assert_eq!(cache.allocations, measured);
for (width, zoom, style) in [(120., 1., style), (100., 0.66, style),
(100., 1., Style { size: Pixels(14.), ..style })] {
let other = cache.resolve(text, width, zoom, style).unwrap();
assert!(!Arc::ptr_eq(&a, &other));
}
// Simulate an invalidated local epoch without mutating the shared font DB.
cache.version = None;
let refreshed = cache.resolve(text, 100., 1., style).unwrap();
assert!(!Arc::ptr_eq(&a, &refreshed));
}
#[test]
fn malformed_metrics_and_explicit_experiment_budgets_fail_without_a_cached_candidate() {
let mut cache = Cache::default();
for (width, zoom) in [(f32::NAN, 1.), (-1., 1.), (100., 0.), (100., f32::INFINITY)] {
assert_eq!(cache.resolve("call", width, zoom, Style::default()).unwrap_err(), Error::InvalidMetrics);
}
assert_eq!(cache.resolve(&"x".repeat(MAX_SOURCE_BYTES + 1), 100., 1., Style::default()).unwrap_err(), Error::SourceBudget);
assert_eq!(cache.resolve(&"x".repeat(MAX_GRAPHEMES + 1), 100., 1., Style::default()).unwrap_err(), Error::GraphemeBudget);
assert!(cache.entries.is_empty());
}
@@ -0,0 +1,87 @@
# Scenario label measurement: preserved candidate and source correction
This is an **unwired, uncompiled experiment**, not an accepted renderer policy.
Source base: `208268c8aa7f6bc3c2bab6a3c0657358f3f7dc23`.
Parent's docs-only worktree HEAD: `75cb09dade2cf4b9a176b5e6159f43e2345d196e`.
Only the two new `crates/oa-desktop/src/behavior/scenario_label` module/test
paths and this evidence directory belong to this correction.
## Preserved attempt 1
`attempt1/scenario_label.rs` and `attempt1/tests.rs` are the exact raw files
reviewed before correction. Both SHA256 values were checked before copying and
again after copying; [manifest](attempt1/manifest.json) records source paths and
hashes. They remain historical evidence, not alternate compiler inputs.
Independent static review identified:
1. A retained Label Arc lacked its font epoch and zoom. Cached dimensions could
be reused by painting/hit/navigation after their measurement context changed.
2. Iced's WGPU text cache omits font Version; remeasuring only this module cannot
establish dynamic-font parity with an already-populated renderer cache.
3. The font-epoch test had already evicted its original entry with three variants
in a capacity-two cache. Different Arc identity did not prove invalidation.
4. Finite scaled metrics could overflow when converted back to world units.
Concrete example: size 2, Relative(MAX) line height, zoom 0.5.
These were source findings. Neither the old candidate nor the correction was
compiled or executed by this worker; no failure/pass count is inferred.
## Corrected experimental seam
`RenderSession::after_fonts_settled_and_renderer_reset()` explicitly records a
caller-established settled-font rendering lifetime. It does not reset or inspect
the renderer. The caller must finish font loading and create a fresh renderer
cache, then prohibit font changes between resolution, guarded consumption and
draw. A changed global font Version rejects the old session; a new local cache
alone is not a repair for WGPU's old glyph buffers.
Labels record that session identity/font Version and measured zoom. Displayed
text, retained-source bytes, bounds, field retention and canvas Text construction
use checked accessors. Another session, another zoom or a detected font change
rejects a retained artifact. Returned raw Text/bounds must be consumed in that
checked context: holding those values across context changes bypasses the guard.
This is an explicit limited contract, not atomic protection against a concurrent
font load or an incorrect renderer transform supplied by the caller.
Finite world line metrics are checked before shaping. Actual measured dimensions
are checked again after division by zoom. The existing 64KiB source and 4096
grapheme budgets remain explicit experiment errors, not adopted product limits.
Ten test candidates now include separate resident-entry font-epoch invalidation,
fresh-session invalidation, stale artifact rejection for every consumer, and
the overflow counterexample. The invalidation assertions require a still-resident
entry, positive prior measurement count and an increased count afterward.
These tests do not mutate the global font database or claim dynamic-font rendering
acceptance. The existing actual Paragraph comparisons remain layout-metric
oracles, not raster-ink/Scene collision evidence.
## Pinned primary APIs and remaining evaluator
Cargo.lock pins iced_graphics/iced_wgpu 0.14.0 and iced_widget 0.14.2. Relevant
local registry sources: `iced_wgpu-0.14.0/src/geometry.rs` (`fill_text`, scaled
font metrics), `iced_wgpu-0.14.0/src/text.rs` (`Text::Cached` allocation),
`iced_graphics-0.14.0/src/text/cache.rs` (Key/hash lacks Version), and
`iced_graphics-0.14.0/src/text.rs` (FontSystem::load_font/version).
Independent reviewer confirmed `geometry.rs:306-360`, WGPU `text.rs:443-500`
and its retained BufferCache at line 304 (trim at 435 only evicts unused
entries), graphics `text/cache.rs:109-146`, and graphics `compositor.rs:69`.
The compositor's font-load path changes the global FontSystem, not the WGPU
buffer cache. A check immediately before canvas_text is therefore not atomic
with the later WGPU prepare; fonts must remain unchanged for the entire session.
The module remains undeclared in behavior.rs. Parent may separately add test-only
`#[cfg(test)] mod scenario_label;` and run:
```
cargo test -p oa-desktop --locked --offline behavior::scenario_label::tests:: -- --test-threads=1
```
Expected selection is exactly ten tests, not a claim they pass. Corridors remain
external; ordinary/reversed and self-message positioning is not selected here.
The bounded prefix search guarantees measured fit, not a globally maximal prefix
for all contextual shaping. No wrapping, row-height change, public schema or
production gesture is introduced. Real WGPU label ink, activation shoulders,
clipping, viewport transforms, and actual navigation integration remain required
before adopting the seam. Original fixed 32-grapheme/225-unit rendering is still
unchanged for baseline comparison.
@@ -0,0 +1,22 @@
{
"sourceBase": "208268c8aa7f6bc3c2bab6a3c0657358f3f7dc23",
"docsOnlyHead": "75cb09dade2cf4b9a176b5e6159f43e2345d196e",
"files": [
{
"name": "scenario_label.rs",
"hash": "82789CF6C9D0C3F0B027671D13E286F869A359DF1F163D8B47EA1B1E442CAAF4",
"path": "crates/oa-desktop/src/behavior/scenario_label.rs"
},
{
"name": "tests.rs",
"hash": "0C7A0CE80570B8DA347B5147AD00DB9AF26AE4E3FDE0F445683D1958348972AF",
"path": "crates/oa-desktop/src/behavior/scenario_label/tests.rs"
},
{
"name": "README.md",
"hash": "D49F5A48B6C943C4FBA3DB86BFC5C916719DB01A9D962FAB381D976B96E5D993",
"path": "harness/experiments/scenario-label-measurement-20260913/README.md"
}
],
"status": "Uncompiled corrected candidate before actual font-load coverage"
}
@@ -0,0 +1,271 @@
//! Unwired single-line scenario label experiment. No renderer or schema is changed.
//!
//! Iced 0.14.0 WGPU `geometry::Frame::fill_text` shapes positive uniform-scale
//! canvas text at `size * zoom`; its `text::prepare` uses graphics text::Cache.
//! This adapter uses that same allocator/font system/attributes, not a character
//! width estimate. Canvas Text::max_width is wrapping, so it stays infinite.
//! Experimental parity requires settled fonts and a fresh renderer/cache after
//! font loading. Iced's WGPU text cache key omits FontSystem::Version, so merely
//! remeasuring here cannot repair its cached glyph buffers after dynamic loading.
//! A RenderSession is caller attestation of that lifecycle, not its enforcement.
//! No fonts may be loaded between measurement, guarded access and actual draw.
//!
//! Integration seam: after lifecycle/activation endpoint alignment, a caller
//! supplies a usable text width in world units and retains one cache per live
//! labelled occurrence across Scene rebuilds. Paint, hit testing, navigation and extents
//! consume the returned Label. Position/color do not invalidate measurement.
//! This does NOT choose a self-call, reversed-arrow or obstacle corridor policy.
//! It measures layout advances, not raster ink; overhang/clip/activation-border
//! acceptance still requires the real WGPU evaluator. The old 32-grapheme/225px
//! renderer is deliberately untouched for a later baseline comparison.
use iced::advanced::{graphics::text as graphics_text, text::{Alignment, LineHeight, Shaping}};
use iced::{Color, Font, Pixels, Point, Rectangle, Size, widget::canvas::Text};
use std::{collections::VecDeque, ops::Range, sync::Arc};
use unicode_segmentation::UnicodeSegmentation;
const MAX_SOURCE_BYTES: usize = 64 * 1024;
const MAX_GRAPHEMES: usize = 4096;
const MAX_ENTRIES: usize = 2;
#[derive(Clone, Copy, Debug, PartialEq)]
pub(super) struct Style {
pub font: Font,
pub size: Pixels,
pub line_height: LineHeight,
pub shaping: Shaping,
}
impl Default for Style {
fn default() -> Self {
let text = Text::default();
Self { font: text.font, size: Pixels(12.), line_height: text.line_height, shaping: text.shaping }
}
}
impl Style {
fn scaled(self, zoom: f32) -> (f32, f32) {
let size = Pixels(self.size.0 * zoom);
let line_height = match self.line_height {
LineHeight::Absolute(value) => LineHeight::Absolute(Pixels(value.0 * zoom)),
LineHeight::Relative(value) => LineHeight::Relative(value),
};
(size.0, line_height.to_absolute(size).0)
}
}
/// Explicit experimental contract: the caller finishes loading fonts and starts
/// a fresh renderer/cache BEFORE capturing this session, then keeps fonts fixed.
/// This module cannot inspect or reset the actual renderer's text cache.
#[derive(Clone, Debug)]
pub(super) struct RenderSession {
version: graphics_text::Version,
identity: Arc<()>,
}
impl RenderSession {
pub fn after_fonts_settled_and_renderer_reset() -> Result<Self, Error> {
let fonts = graphics_text::font_system().read().map_err(|_| Error::FontSystemUnavailable)?;
Ok(Self { version: fonts.version(), identity: Arc::new(()) })
}
}
#[derive(Clone, Debug)]
pub(super) struct Label {
source: String,
displayed: String,
/// Original-label bytes represented before any added ellipsis. Includes
/// collapsed whitespace, but `retains_any` never counts whitespace as text.
retained_source: Range<usize>,
/// World-space layout advances, at origin; not glyph-ink containment.
bounds: Rectangle,
style: Style,
measured_session: RenderSession,
measured_zoom: f32,
}
impl Label {
fn check(&self, session: &RenderSession, zoom: f32) -> Result<(), Error> {
if !Arc::ptr_eq(&self.measured_session.identity, &session.identity) {
return Err(Error::StaleRenderSession);
}
if !zoom.is_finite() || zoom != self.measured_zoom { return Err(Error::StaleZoom); }
let fonts = graphics_text::font_system().read().map_err(|_| Error::FontSystemUnavailable)?;
if fonts.version() != self.measured_session.version || fonts.version() != session.version {
return Err(Error::FontSetChanged);
}
Ok(())
}
pub fn displayed(&self, session: &RenderSession, zoom: f32) -> Result<&str, Error> {
self.check(session, zoom)?;
Ok(&self.displayed)
}
pub fn retained_source(&self, session: &RenderSession, zoom: f32) -> Result<Range<usize>, Error> {
self.check(session, zoom)?;
Ok(self.retained_source.clone())
}
pub fn retains_any(&self, session: &RenderSession, zoom: f32, field: Range<usize>) -> Result<bool, Error> {
self.check(session, zoom)?;
if field.start > field.end || self.source.get(field.clone()).is_none() {
return Ok(false);
}
let end = field.end.min(self.retained_source.end);
Ok(field.start < end && self.source.get(field.start..end)
.is_some_and(|text| text.chars().any(|c| !c.is_whitespace())))
}
pub fn bounds_at(&self, session: &RenderSession, zoom: f32, position: Point) -> Result<Rectangle, Error> {
self.check(session, zoom)?;
Ok(Rectangle::new(position, self.bounds.size()))
}
/// Paint under the same positive uniform zoom used for `Cache::resolve`.
/// No per-label wrapping or implicit text maximum is introduced.
/// The returned Text must be consumed immediately in this session/zoom;
/// retaining raw Text past another font load/zoom change bypasses the guard.
pub fn canvas_text(&self, session: &RenderSession, zoom: f32, position: Point, color: Color) -> Result<Text, Error> {
self.check(session, zoom)?;
Ok(Text { content: self.displayed.clone(), position, color,
size: self.style.size, line_height: self.style.line_height,
font: self.style.font, shaping: self.style.shaping,
max_width: f32::INFINITY, ..Text::default() })
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum Error { InvalidMetrics, SourceBudget, GraphemeBudget, FontSystemUnavailable, InvalidMeasurement, StaleZoom, StaleRenderSession, FontSetChanged }
struct Entry { source: String, style: Style, zoom: f32, width: f32, label: Arc<Label> }
#[derive(Default)]
/// Two recent resolutions for ONE live labelled occurrence. A global small LRU
/// would thrash when a larger Scene is rebuilt in order. The integration owner
/// must retain these by occurrence identity and prune on removal/document change.
pub(super) struct Cache {
version: Option<graphics_text::Version>,
session: Option<Arc<()>>,
entries: VecDeque<Entry>,
#[cfg(test)]
allocations: usize,
}
impl Cache {
/// `width` excludes any caller-chosen backplate padding. Zero is valid.
/// Cache ownership must survive redraws; constructing one per Scene defeats
/// reuse. Changed fonts invalidate the SESSION: the caller must establish
/// a fresh renderer and a new settled session, not just remeasure this cache.
/// Error handling/fallback for these explicit experiment budgets is an
/// integration decision; never silently paint the old truncated contract.
pub fn resolve(&mut self, session: &RenderSession, source: &str, width: f32, zoom: f32, style: Style) -> Result<Arc<Label>, Error> {
let (size, line_height) = style.scaled(zoom);
if !width.is_finite() || width < 0. || !zoom.is_finite() || zoom <= 0.
|| !size.is_finite() || size <= 0. || !line_height.is_finite() || line_height <= 0.
|| !(width * zoom).is_finite() || !(size / zoom).is_finite()
|| !(line_height / zoom).is_finite() {
return Err(Error::InvalidMetrics);
}
if source.len() > MAX_SOURCE_BYTES { return Err(Error::SourceBudget); }
let mut fonts = graphics_text::font_system().write().map_err(|_| Error::FontSystemUnavailable)?;
if session.version != fonts.version() { return Err(Error::FontSetChanged); }
if self.version != Some(fonts.version())
|| self.session.as_ref().is_none_or(|identity| !Arc::ptr_eq(identity, &session.identity)) {
self.entries.clear();
self.version = Some(fonts.version());
self.session = Some(session.identity.clone());
}
if let Some(index) = self.entries.iter().position(|entry|
entry.source == source && entry.style == style && entry.zoom == zoom && entry.width == width) {
let entry = self.entries.remove(index).unwrap();
let label = entry.label.clone();
self.entries.push_back(entry);
return Ok(label);
}
let normalized = Normalized::new(source);
let cuts: Vec<usize> = normalized.text.grapheme_indices(true).map(|(i, _)| i)
.chain(std::iter::once(normalized.text.len())).collect();
if cuts.len().saturating_sub(1) > MAX_GRAPHEMES { return Err(Error::GraphemeBudget); }
// Candidate buffers are temporary. Only the resolved strings/metrics
// survive, bounded by MAX_ENTRIES; renderer caching remains independent.
let mut shapes = graphics_text::Cache::new();
let mut measure = |content: &str| -> Result<Size, Error> {
if content.is_empty() { return Ok(Size::ZERO); }
#[cfg(test)] { self.allocations += 1; }
let (_, entry) = shapes.allocate(fonts.raw(), graphics_text::cache::Key {
content, size, line_height, font: style.font, bounds: Size::INFINITE,
shaping: style.shaping, align_x: Alignment::Default,
});
if entry.buffer.layout_runs().count() != 1 || !entry.min_bounds.width.is_finite()
|| !entry.min_bounds.height.is_finite() { return Err(Error::InvalidMeasurement); }
world_size(entry.min_bounds, zoom)
};
let full = measure(&normalized.text)?;
let (displayed, retained, measured) = if full.width <= width {
(normalized.text.clone(), normalized.text.len(), full)
} else {
let ellipsis = measure("…")?;
if ellipsis.width > width {
(String::new(), 0, Size::ZERO)
} else {
// Every accepted candidate is actually shaped and fits. Search
// is bounded/logarithmic; contextual shaping is not universally
// monotone, so this does NOT promise the globally longest prefix.
// Full content is always checked first and never needlessly cut.
let mut best = ("…".to_owned(), 0, ellipsis);
let (mut low, mut high) = (1, cuts.len().saturating_sub(1));
while low < high {
let middle = low + (high - low) / 2;
let candidate = format!("{}…", &normalized.text[..cuts[middle]]);
let measured = measure(&candidate)?;
if measured.width <= width {
best = (candidate, cuts[middle], measured);
low = middle + 1;
} else { high = middle; }
}
best
}
};
let label = Arc::new(Label { source: source.into(), displayed,
retained_source: 0..normalized.source_end(retained),
bounds: Rectangle::with_size(measured), style,
measured_session: session.clone(), measured_zoom: zoom });
if self.entries.len() == MAX_ENTRIES { self.entries.pop_front(); }
self.entries.push_back(Entry { source: source.into(), style, zoom, width, label: label.clone() });
Ok(label)
}
}
fn world_size(scaled: Size, zoom: f32) -> Result<Size, Error> {
let world = Size::new(scaled.width / zoom, scaled.height / zoom);
if !world.width.is_finite() || !world.height.is_finite() || world.width < 0. || world.height < 0. {
return Err(Error::InvalidMeasurement);
}
Ok(world)
}
struct Normalized { text: String, ends: Vec<(usize, usize)> }
impl Normalized {
fn new(source: &str) -> Self {
let mut result = Self { text: String::new(), ends: vec![(0, 0)] };
let mut separator = false;
for (start, c) in source.char_indices() {
if c.is_whitespace() { separator = !result.text.is_empty(); continue; }
if separator {
result.text.push(' ');
result.ends.push((result.text.len(), start));
separator = false;
}
result.text.push(c);
result.ends.push((result.text.len(), start + c.len_utf8()));
}
result
}
fn source_end(&self, normalized_end: usize) -> usize {
self.ends.binary_search_by_key(&normalized_end, |(end, _)| *end)
.map(|index| self.ends[index].1).expect("grapheme cuts are character boundaries")
}
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,200 @@
//! Unexecuted evaluator candidates; the module is intentionally not wired yet.
use super::*;
use iced::advanced::{graphics::text::Paragraph, text::{Paragraph as _, Text as ParagraphText, Wrapping}};
// Independent paragraph construction: no candidate cache, normalization, search,
// or character-width estimate is used by this measurement oracle.
fn paragraph_size(content: &str, zoom: f32, style: Style) -> Size {
let size = Pixels(style.size.0 * zoom);
let line_height = match style.line_height {
LineHeight::Relative(factor) => LineHeight::Relative(factor),
LineHeight::Absolute(pixels) => LineHeight::Absolute(Pixels(pixels.0 * zoom)),
};
let paragraph = Paragraph::with_text(ParagraphText { content, bounds: Size::INFINITE,
size, line_height, font: style.font, align_x: Alignment::Default,
align_y: iced::alignment::Vertical::Top, shaping: style.shaping, wrapping: Wrapping::default() });
assert_eq!(paragraph.buffer().layout_runs().count(), 1);
Size::new(paragraph.min_width() / zoom, paragraph.min_height() / zoom)
}
#[test]
fn adequate_corridor_reveals_full_realistic_names_and_distinguishing_suffixes() {
let mut cache = Cache::default();
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
let style = Style::default();
for text in [
"1. call · Request valve repair · Technician service conversation",
"2. reply · Return pressure after repair · Technician service conversation",
"1. call · Request redundant hydraulic pressure calibration channel A",
"1. call · Request redundant hydraulic pressure calibration channel B",
] {
let wide = cache.resolve(&session, text, 1600., 1., style).unwrap();
assert_eq!(wide.displayed(&session, 1.).unwrap(), text);
assert_eq!(wide.retained_source(&session, 1.).unwrap(), 0..text.len());
assert_eq!(wide.bounds.size(), paragraph_size(text, 1., style));
let narrow = cache.resolve(&session, text, 80., 1., style).unwrap();
assert_ne!(narrow.displayed, text);
assert!(narrow.bounds.width <= 80.);
assert_eq!(narrow.bounds.size(), paragraph_size(&narrow.displayed, 1., style));
}
}
#[test]
fn normalized_visible_prefix_maps_to_original_bytes_without_claiming_hidden_operation() {
let raw = " \t1. call · Pump\r\n · Operation: Need\u{301}le ";
let expected = "1. call · Pump · Operation: Need\u{301}le";
let n = Normalized::new(raw);
assert_eq!(n.text, expected);
let prefix = "1. call · Pump ";
assert_eq!(n.source_end(prefix.len()), raw.find("· Operation").unwrap());
let mut cache = Cache::default();
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
let wide = cache.resolve(&session, raw, 1600., 1., Style::default()).unwrap();
assert_eq!(wide.displayed, expected);
assert_eq!(wide.retained_source.end, raw.trim_end().len());
let field = raw.find("Need").unwrap()..raw.trim_end().len();
assert!(wide.retains_any(&session, 1., field.clone()).unwrap());
assert!(!wide.retains_any(&session, 1., 0..2).unwrap(), "normalized whitespace is not a visible field");
let small = cache.resolve(&session, raw, 70., 1., Style::default()).unwrap();
assert!(!small.retains_any(&session, 1., field).unwrap());
assert!(small.retained_source.end < raw.find("Operation").unwrap());
}
#[test]
fn grapheme_prefixes_and_zoom_match_actual_paragraph_layout() {
let text = "call · 戻り値 圧力 e\u{301} 👩‍🔬 safety valve verification";
let mut cache = Cache::default();
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
for zoom in [0.66, 1., 1.75] {
let label = cache.resolve(&session, text, 105., zoom, Style::default()).unwrap();
assert!(label.bounds.width <= 105.);
assert_eq!(label.bounds.size(), paragraph_size(&label.displayed, zoom, Style::default()));
assert!(text.grapheme_indices(true).any(|(i, _)| i == label.retained_source.end)
|| label.retained_source.end == text.len());
let painted = label.canvas_text(&session, zoom, Point::new(400., 90.), Color::BLACK).unwrap();
assert_eq!(painted.content, label.displayed);
assert!(painted.max_width.is_infinite());
assert_eq!(label.bounds_at(&session, zoom, painted.position).unwrap().size(), label.bounds.size());
}
}
#[test]
fn empty_and_too_narrow_labels_never_claim_source_visibility() {
let mut cache = Cache::default();
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
for text in ["", " \t\n", "Operation: Pump"] {
let label = cache.resolve(&session, text, 0., 1., Style::default()).unwrap();
assert_eq!(label.displayed, "");
assert_eq!(label.retained_source, 0..0);
assert_eq!(label.bounds.size(), Size::ZERO);
assert!(!label.retains_any(&session, 1., 0..text.len()).unwrap());
}
}
#[test]
fn redraws_reuse_resolution_but_width_zoom_and_style_are_dependencies() {
let mut cache = Cache::default();
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
let text = "call · Pressure verification with a descriptive operation label";
let style = Style::default();
let a = cache.resolve(&session, text, 100., 1., style).unwrap();
let measured = cache.allocations;
let b = cache.resolve(&session, text, 100., 1., style).unwrap();
assert!(Arc::ptr_eq(&a, &b));
assert_eq!(cache.allocations, measured);
for (width, zoom, style) in [(120., 1., style), (100., 0.66, style),
(100., 1., Style { size: Pixels(14.), ..style })] {
let other = cache.resolve(&session, text, width, zoom, style).unwrap();
assert!(!Arc::ptr_eq(&a, &other));
}
}
#[test]
fn resident_entry_is_remeasured_for_a_fresh_settled_render_session() {
let mut cache = Cache::default();
let old_session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
let text = "call · Pressure verification";
let a = cache.resolve(&old_session, text, 100., 1., Style::default()).unwrap();
assert_eq!(cache.entries.len(), 1);
assert!(Arc::ptr_eq(&cache.entries.front().unwrap().label, &a));
let measured = cache.allocations;
assert!(measured > 0);
assert!(Arc::ptr_eq(&a, &cache.resolve(&old_session, text, 100., 1., Style::default()).unwrap()));
assert_eq!(cache.allocations, measured);
// A fresh renderer session invalidates a still-resident entry. This does
// not mutate global fonts or purport to test dynamic WGPU font loading.
let new_session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
let refreshed = cache.resolve(&new_session, text, 100., 1., Style::default()).unwrap();
assert!(!Arc::ptr_eq(&a, &refreshed));
assert!(cache.allocations > measured);
assert_eq!(cache.entries.len(), 1);
assert!(Arc::ptr_eq(&cache.entries.front().unwrap().label, &refreshed));
}
#[test]
fn resident_entry_remeasures_after_local_font_epoch_invalidation() {
let mut cache = Cache::default();
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
let text = "call · Resident pressure label";
let a = cache.resolve(&session, text, 100., 1., Style::default()).unwrap();
assert_eq!(cache.entries.len(), 1);
assert!(Arc::ptr_eq(&cache.entries.front().unwrap().label, &a));
let measured = cache.allocations;
assert!(measured > 0);
// Exercise the local memo epoch branch while a is still resident. No
// intervening width/zoom/style resolution can cause incidental eviction.
// This is not an actual font-load or renderer-cache acceptance test.
cache.version = None;
let refreshed = cache.resolve(&session, text, 100., 1., Style::default()).unwrap();
assert!(!Arc::ptr_eq(&a, &refreshed));
assert!(cache.allocations > measured);
assert_eq!(cache.entries.len(), 1);
assert!(Arc::ptr_eq(&cache.entries.front().unwrap().label, &refreshed));
}
#[test]
fn retained_artifact_rejects_other_zoom_and_render_session_for_every_consumer() {
let mut cache = Cache::default();
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
let label = cache.resolve(&session, "Operation: Pressure", 200., 1., Style::default()).unwrap();
let other = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
for (context, zoom, expected) in [(&session, 0.66, Error::StaleZoom), (&other, 1., Error::StaleRenderSession)] {
assert_eq!(label.displayed(context, zoom).unwrap_err(), expected);
assert_eq!(label.retained_source(context, zoom).unwrap_err(), expected);
assert_eq!(label.retains_any(context, zoom, 0..9).unwrap_err(), expected);
assert_eq!(label.bounds_at(context, zoom, Point::ORIGIN).unwrap_err(), expected);
assert_eq!(label.canvas_text(context, zoom, Point::ORIGIN, Color::BLACK).unwrap_err(), expected);
}
assert!(label.canvas_text(&session, 1., Point::ORIGIN, Color::BLACK).is_ok());
}
#[test]
fn finite_scaled_metrics_cannot_escape_as_infinite_world_dimensions() {
let mut cache = Cache::default();
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
let style = Style { size: Pixels(2.), line_height: LineHeight::Relative(f32::MAX), ..Style::default() };
// At zoom 0.5 the scaled size=1 and line height=MAX are finite. The
// required world line height is not; reject before passing it to shaping.
let scaled = style.scaled(0.5);
assert!(scaled.0.is_finite() && scaled.1.is_finite());
assert_eq!(cache.resolve(&session, "call", 100., 0.5, style).unwrap_err(), Error::InvalidMetrics);
assert_eq!(cache.allocations, 0);
assert!(cache.entries.is_empty());
// Independently exercise validation after division for actual measured
// dimensions: finite scaled width/height do not imply finite world values.
assert_eq!(world_size(Size::new(f32::MAX, 1.), 0.5), Err(Error::InvalidMeasurement));
assert_eq!(world_size(Size::new(1., f32::MAX), 0.5), Err(Error::InvalidMeasurement));
assert_eq!(world_size(Size::new(20., 8.), 0.5).unwrap(), Size::new(40., 16.));
}
#[test]
fn malformed_metrics_and_explicit_experiment_budgets_fail_without_a_cached_candidate() {
let mut cache = Cache::default();
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
for (width, zoom) in [(f32::NAN, 1.), (-1., 1.), (100., 0.), (100., f32::INFINITY)] {
assert_eq!(cache.resolve(&session, "call", width, zoom, Style::default()).unwrap_err(), Error::InvalidMetrics);
}
assert_eq!(cache.resolve(&session, &"x".repeat(MAX_SOURCE_BYTES + 1), 100., 1., Style::default()).unwrap_err(), Error::SourceBudget);
assert_eq!(cache.resolve(&session, &"x".repeat(MAX_GRAPHEMES + 1), 100., 1., Style::default()).unwrap_err(), Error::GraphemeBudget);
assert!(cache.entries.is_empty());
}
@@ -0,0 +1,154 @@
//! Unwired corridor-policy candidate for the measured scenario-label experiment.
//!
//! The caller supplies a finite structural label band AFTER endpoint/activation
//! alignment, with padding already removed from its edges. Blockers include
//! padded activation/card/header bounds over the complete text band, including
//! stroke/ink clearance. This function does not infer a self-message corridor,
//! alter scene extents, change zoom, or establish raster-ink containment.
use iced::Rectangle;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) struct InvalidGeometry;
fn edges(rect: Rectangle) -> Result<(f32, f32, f32, f32), InvalidGeometry> {
let right = rect.x + rect.width;
let bottom = rect.y + rect.height;
if [rect.x, rect.y, rect.width, rect.height, right, bottom]
.into_iter()
.any(|value| !value.is_finite())
|| rect.width < 0.
|| rect.height < 0.
{
return Err(InvalidGeometry);
}
Ok((rect.x, right, rect.y, bottom))
}
/// Select one connected clear interval; never assemble text across blockers.
/// Prefer greater width, then proximity to the previous label anchor, then the
/// leftmost interval. Input order has no influence. A zero-area band has no
/// corridor. Touching the padded vertical band is conservatively obstructing;
/// a zero-area blocker has no area and must receive stroke padding upstream.
pub(super) fn widest_clear(
band: Rectangle,
blockers: &[Rectangle],
preferred_anchor: f32,
) -> Result<Option<Rectangle>, InvalidGeometry> {
let (left, right, top, bottom) = edges(band)?;
if !preferred_anchor.is_finite() {
return Err(InvalidGeometry);
}
let mut blocked = Vec::new();
for &blocker in blockers {
let (x0, x1, y0, y1) = edges(blocker)?;
if blocker.width > 0. && blocker.height > 0. && y1 >= top && y0 <= bottom {
let interval = (x0.max(left), x1.min(right));
if interval.0 < interval.1 {
blocked.push(interval);
}
}
}
if band.width == 0. || band.height == 0. {
return Ok(None);
}
blocked.sort_by(|a, b| a.0.total_cmp(&b.0).then(a.1.total_cmp(&b.1)));
let mut best: Option<Rectangle> = None;
let mut consider = |start: f32, end: f32| {
if start >= end {
return;
}
let candidate = Rectangle { x: start, width: end - start, ..band };
// Use f64 only for comparison distance: subtracting two admitted f32
// world coordinates can overflow even though each rectangle is finite.
let distance = |x: f32| (f64::from(x) - f64::from(preferred_anchor)).abs();
if best.is_none_or(|old| {
candidate.width > old.width
|| (candidate.width == old.width
&& (distance(candidate.x) < distance(old.x)
|| (distance(candidate.x) == distance(old.x) && candidate.x < old.x)))
}) {
best = Some(candidate);
}
};
let mut cursor = left;
for (start, end) in blocked {
consider(cursor, start);
cursor = cursor.max(end);
}
consider(cursor, right);
Ok(best)
}
#[cfg(test)]
mod tests {
use super::*;
fn rect(x: f32, y: f32, width: f32, height: f32) -> Rectangle {
Rectangle { x, y, width, height }
}
#[test]
fn long_message_does_not_cross_an_intermediate_execution() {
let band = rect(20., 100., 700., 22.);
let blockers = [rect(250., 80., 16., 80.), rect(490., 90., 28., 140.)];
let result = widest_clear(band, &blockers, band.x).unwrap().unwrap();
assert_eq!(result, rect(20., 100., 230., 22.));
for blocker in blockers {
assert!(result.x + result.width <= blocker.x || result.x >= blocker.x + blocker.width);
}
assert_eq!(widest_clear(band, &[], band.x).unwrap(), Some(band));
}
#[test]
fn overlapping_unsorted_blockers_merge_and_widest_gap_wins() {
let band = rect(-100., 10., 600., 20.);
let blockers = [rect(50., 0., 80., 60.), rect(0., 5., 70., 30.), rect(450., 0., 100., 60.)];
let expected = Some(rect(130., 10., 320., 20.));
assert_eq!(widest_clear(band, &blockers, -100.).unwrap(), expected);
let mut reversed = blockers;
reversed.reverse();
assert_eq!(widest_clear(band, &reversed, -100.).unwrap(), expected);
}
#[test]
fn complete_padded_band_and_touching_boundaries_are_obstructed() {
let band = rect(0., 100., 200., 20.);
// The first blocker ends at the band top: checking only a baseline
// would miss it. The second starts at the band bottom.
let blockers = [rect(0., 70., 60., 30.), rect(150., 120., 50., 30.)];
assert_eq!(widest_clear(band, &blockers, 0.).unwrap(), Some(rect(60., 100., 90., 20.)));
let outside = [rect(0., 70., 200., 29.), rect(0., 121., 200., 30.)];
assert_eq!(widest_clear(band, &outside, 0.).unwrap(), Some(band));
}
#[test]
fn ties_keep_the_nearest_anchor_then_choose_leftmost() {
let band = rect(0., 0., 300., 20.);
let blockers = [rect(100., 0., 100., 20.)];
assert_eq!(widest_clear(band, &blockers, 190.).unwrap(), Some(rect(200., 0., 100., 20.)));
assert_eq!(widest_clear(band, &blockers, 100.).unwrap(), Some(rect(0., 0., 100., 20.)));
}
#[test]
fn caller_defined_self_and_reversed_corridors_keep_their_geometry() {
// Direction and self-loop policy are explicit caller inputs. Neither
// a zero endpoint gap nor screen width can silently supply a corridor.
for band in [rect(420., 40., 180., 22.), rect(-400., 90., 350., 22.)] {
assert_eq!(widest_clear(band, &[], band.x).unwrap(), Some(band));
}
assert_eq!(widest_clear(rect(420., 40., 0., 22.), &[], 420.).unwrap(), None);
assert_eq!(widest_clear(rect(420., 40., 180., 0.), &[], 420.).unwrap(), None);
assert_eq!(widest_clear(rect(0., 0., 100., 20.), &[rect(-10., -10., 200., 50.)], 0.).unwrap(), None);
}
#[test]
fn invalid_or_overflowing_geometry_never_enters_a_corridor() {
let band = rect(0., 0., 100., 20.);
for invalid in [rect(f32::NAN, 0., 10., 20.), rect(0., 0., -1., 20.),
rect(f32::MAX, 0., f32::MAX, 20.), rect(0., f32::MAX, 10., f32::MAX)] {
assert_eq!(widest_clear(invalid, &[], 0.), Err(InvalidGeometry));
assert_eq!(widest_clear(band, &[invalid], 0.), Err(InvalidGeometry));
}
assert_eq!(widest_clear(band, &[], f32::INFINITY), Err(InvalidGeometry));
}
}
@@ -0,0 +1,17 @@
[
{
"name": "scenario_label.rs",
"bytes": 13548,
"sha256": "b452249c45b1225760d1b9bd8f2d9241c4d599e8a68d7328641d61381c28cbbd"
},
{
"name": "tests.rs",
"bytes": 15683,
"sha256": "1af0115850ae826f3af211499006c26bd290c17cc186d08b41be0ee40981a83b"
},
{
"name": "corridor.rs",
"bytes": 6657,
"sha256": "94747ab1b161564c8901f3d0702034e43f14f263decd8e7b823c801fbcb80dd7"
}
]
@@ -0,0 +1,281 @@
//! Unwired single-line scenario label experiment. No renderer or schema is changed.
//!
//! Iced 0.14.0 WGPU `geometry::Frame::fill_text` shapes positive uniform-scale
//! canvas text at `size * zoom`; its `text::prepare` uses graphics text::Cache.
//! This adapter uses that same allocator/font system/attributes, not a character
//! width estimate. Canvas Text::max_width is wrapping, so it stays infinite.
//! Experimental parity requires settled fonts and a fresh renderer/cache after
//! font loading. Iced's WGPU text cache key omits FontSystem::Version, so merely
//! remeasuring here cannot repair its cached glyph buffers after dynamic loading.
//! A RenderSession is caller attestation of that lifecycle, not its enforcement.
//! No fonts may be loaded between measurement, guarded access and actual draw.
//!
//! Integration seam: after lifecycle/activation endpoint alignment, a caller
//! supplies a usable text width in world units and retains one cache per live
//! labelled occurrence across Scene rebuilds. Paint, hit testing, navigation and extents
//! consume the returned Label. Position/color do not invalidate measurement.
//! This does NOT choose a self-call, reversed-arrow or obstacle corridor policy.
//! It measures layout advances, not raster ink; overhang/clip/activation-border
//! acceptance still requires the real WGPU evaluator. The old 32-grapheme/225px
//! renderer is deliberately untouched for a later baseline comparison.
use iced::advanced::{graphics::text as graphics_text, text::{Alignment, LineHeight, Shaping}};
use iced::{Color, Font, Pixels, Point, Rectangle, Size, widget::canvas::Text};
use std::{collections::VecDeque, ops::Range, sync::Arc};
use unicode_segmentation::UnicodeSegmentation;
const MAX_SOURCE_BYTES: usize = 64 * 1024;
const MAX_GRAPHEMES: usize = 4096;
const MAX_ENTRIES: usize = 2;
#[derive(Clone, Copy, Debug, PartialEq)]
pub(super) struct Style {
pub font: Font,
pub size: Pixels,
pub line_height: LineHeight,
pub shaping: Shaping,
}
impl Default for Style {
fn default() -> Self {
let text = Text::default();
Self { font: text.font, size: Pixels(12.), line_height: text.line_height, shaping: text.shaping }
}
}
impl Style {
fn scaled(self, zoom: f32) -> (f32, f32) {
let size = Pixels(self.size.0 * zoom);
let line_height = match self.line_height {
LineHeight::Absolute(value) => LineHeight::Absolute(Pixels(value.0 * zoom)),
LineHeight::Relative(value) => LineHeight::Relative(value),
};
(size.0, line_height.to_absolute(size).0)
}
}
/// Explicit experimental contract: the caller finishes loading fonts and starts
/// a fresh renderer/cache BEFORE capturing this session, then keeps fonts fixed.
/// This module cannot inspect or reset the actual renderer's text cache.
#[derive(Clone, Debug)]
pub(super) struct RenderSession {
version: graphics_text::Version,
identity: Arc<()>,
}
impl RenderSession {
pub fn after_fonts_settled_and_renderer_reset() -> Result<Self, Error> {
let fonts = graphics_text::font_system().read().map_err(|_| Error::FontSystemUnavailable)?;
Ok(Self { version: fonts.version(), identity: Arc::new(()) })
}
}
#[derive(Clone, Debug)]
pub(super) struct Label {
source: String,
displayed: String,
/// Original-label bytes represented before any added ellipsis. Includes
/// collapsed whitespace, but `retains_any` never counts whitespace as text.
retained_source: Range<usize>,
/// World-space layout advances, at origin; not glyph-ink containment.
bounds: Rectangle,
style: Style,
measured_session: RenderSession,
measured_zoom: f32,
}
impl Label {
fn check(&self, session: &RenderSession, zoom: f32) -> Result<(), Error> {
if !Arc::ptr_eq(&self.measured_session.identity, &session.identity) {
return Err(Error::StaleRenderSession);
}
if !zoom.is_finite() || zoom != self.measured_zoom { return Err(Error::StaleZoom); }
let fonts = graphics_text::font_system().read().map_err(|_| Error::FontSystemUnavailable)?;
if fonts.version() != self.measured_session.version || fonts.version() != session.version {
return Err(Error::FontSetChanged);
}
Ok(())
}
pub fn displayed(&self, session: &RenderSession, zoom: f32) -> Result<&str, Error> {
self.check(session, zoom)?;
Ok(&self.displayed)
}
pub fn retained_source(&self, session: &RenderSession, zoom: f32) -> Result<Range<usize>, Error> {
self.check(session, zoom)?;
Ok(self.retained_source.clone())
}
pub fn retains_any(&self, session: &RenderSession, zoom: f32, field: Range<usize>) -> Result<bool, Error> {
self.check(session, zoom)?;
if field.start > field.end || self.source.get(field.clone()).is_none() {
return Ok(false);
}
let end = field.end.min(self.retained_source.end);
Ok(field.start < end && self.source.get(field.start..end)
.is_some_and(|text| text.chars().any(|c| !c.is_whitespace())))
}
/// Validate finite world position and rectangle endpoints only. Camera
/// transforms and raster bounds remain a separate integration contract.
pub fn bounds_at(&self, session: &RenderSession, zoom: f32, position: Point) -> Result<Rectangle, Error> {
self.check(session, zoom)?;
translated_bounds(position, self.bounds.size())
}
/// Paint under the same positive uniform zoom used for `Cache::resolve`.
/// No per-label wrapping or implicit text maximum is introduced.
/// The returned Text must be consumed immediately in this session/zoom;
/// retaining raw Text past another font load/zoom change bypasses the guard.
pub fn canvas_text(&self, session: &RenderSession, zoom: f32, position: Point, color: Color) -> Result<Text, Error> {
self.bounds_at(session, zoom, position)?;
Ok(Text { content: self.displayed.clone(), position, color,
size: self.style.size, line_height: self.style.line_height,
font: self.style.font, shaping: self.style.shaping,
max_width: f32::INFINITY, ..Text::default() })
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum Error { InvalidMetrics, SourceBudget, GraphemeBudget, FontSystemUnavailable, InvalidMeasurement, InvalidPosition, StaleZoom, StaleRenderSession, FontSetChanged }
struct Entry { source: String, style: Style, zoom: f32, width: f32, label: Arc<Label> }
#[derive(Default)]
/// Two recent resolutions for ONE live labelled occurrence. A global small LRU
/// would thrash when a larger Scene is rebuilt in order. The integration owner
/// must retain these by occurrence identity and prune on removal/document change.
pub(super) struct Cache {
version: Option<graphics_text::Version>,
session: Option<Arc<()>>,
entries: VecDeque<Entry>,
#[cfg(test)]
allocations: usize,
}
impl Cache {
/// `width` excludes any caller-chosen backplate padding. Zero is valid.
/// Cache ownership must survive redraws; constructing one per Scene defeats
/// reuse. Changed fonts invalidate the SESSION: the caller must establish
/// a fresh renderer and a new settled session, not just remeasure this cache.
/// Error handling/fallback for these explicit experiment budgets is an
/// integration decision; never silently paint the old truncated contract.
pub fn resolve(&mut self, session: &RenderSession, source: &str, width: f32, zoom: f32, style: Style) -> Result<Arc<Label>, Error> {
let (size, line_height) = style.scaled(zoom);
if !width.is_finite() || width < 0. || !zoom.is_finite() || zoom <= 0.
|| !size.is_finite() || size <= 0. || !line_height.is_finite() || line_height <= 0.
|| !(width * zoom).is_finite() || !(size / zoom).is_finite()
|| !(line_height / zoom).is_finite() {
return Err(Error::InvalidMetrics);
}
if source.len() > MAX_SOURCE_BYTES { return Err(Error::SourceBudget); }
let mut fonts = graphics_text::font_system().write().map_err(|_| Error::FontSystemUnavailable)?;
if session.version != fonts.version() { return Err(Error::FontSetChanged); }
if self.version != Some(fonts.version())
|| self.session.as_ref().is_none_or(|identity| !Arc::ptr_eq(identity, &session.identity)) {
self.entries.clear();
self.version = Some(fonts.version());
self.session = Some(session.identity.clone());
}
if let Some(index) = self.entries.iter().position(|entry|
entry.source == source && entry.style == style && entry.zoom == zoom && entry.width == width) {
let entry = self.entries.remove(index).unwrap();
let label = entry.label.clone();
self.entries.push_back(entry);
return Ok(label);
}
let normalized = Normalized::new(source);
let cuts: Vec<usize> = normalized.text.grapheme_indices(true).map(|(i, _)| i)
.chain(std::iter::once(normalized.text.len())).collect();
if cuts.len().saturating_sub(1) > MAX_GRAPHEMES { return Err(Error::GraphemeBudget); }
// Candidate buffers are temporary. Only the resolved strings/metrics
// survive, bounded by MAX_ENTRIES; renderer caching remains independent.
let mut shapes = graphics_text::Cache::new();
let mut measure = |content: &str| -> Result<Size, Error> {
if content.is_empty() { return Ok(Size::ZERO); }
#[cfg(test)] { self.allocations += 1; }
let (_, entry) = shapes.allocate(fonts.raw(), graphics_text::cache::Key {
content, size, line_height, font: style.font, bounds: Size::INFINITE,
shaping: style.shaping, align_x: Alignment::Default,
});
if entry.buffer.layout_runs().count() != 1 || !entry.min_bounds.width.is_finite()
|| !entry.min_bounds.height.is_finite() { return Err(Error::InvalidMeasurement); }
world_size(entry.min_bounds, zoom)
};
let full = measure(&normalized.text)?;
let (displayed, retained, measured) = if full.width <= width {
(normalized.text.clone(), normalized.text.len(), full)
} else {
let ellipsis = measure("…")?;
if ellipsis.width > width {
(String::new(), 0, Size::ZERO)
} else {
// Every accepted candidate is actually shaped and fits. Search
// is bounded/logarithmic; contextual shaping is not universally
// monotone, so this does NOT promise the globally longest prefix.
// Full content is always checked first and never needlessly cut.
let mut best = ("…".to_owned(), 0, ellipsis);
let (mut low, mut high) = (1, cuts.len().saturating_sub(1));
while low < high {
let middle = low + (high - low) / 2;
let candidate = format!("{}…", &normalized.text[..cuts[middle]]);
let measured = measure(&candidate)?;
if measured.width <= width {
best = (candidate, cuts[middle], measured);
low = middle + 1;
} else { high = middle; }
}
best
}
};
let label = Arc::new(Label { source: source.into(), displayed,
retained_source: 0..normalized.source_end(retained),
bounds: Rectangle::with_size(measured), style,
measured_session: session.clone(), measured_zoom: zoom });
if self.entries.len() == MAX_ENTRIES { self.entries.pop_front(); }
self.entries.push_back(Entry { source: source.into(), style, zoom, width, label: label.clone() });
Ok(label)
}
}
fn world_size(scaled: Size, zoom: f32) -> Result<Size, Error> {
let world = Size::new(scaled.width / zoom, scaled.height / zoom);
if !world.width.is_finite() || !world.height.is_finite() || world.width < 0. || world.height < 0. {
return Err(Error::InvalidMeasurement);
}
Ok(world)
}
fn translated_bounds(position: Point, size: Size) -> Result<Rectangle, Error> {
if !position.x.is_finite() || !position.y.is_finite()
|| !(position.x + size.width).is_finite() || !(position.y + size.height).is_finite() {
return Err(Error::InvalidPosition);
}
Ok(Rectangle::new(position, size))
}
struct Normalized { text: String, ends: Vec<(usize, usize)> }
impl Normalized {
fn new(source: &str) -> Self {
let mut result = Self { text: String::new(), ends: vec![(0, 0)] };
let mut separator = false;
for (start, c) in source.char_indices() {
if c.is_whitespace() { separator = !result.text.is_empty(); continue; }
if separator {
result.text.push(' ');
result.ends.push((result.text.len(), start));
separator = false;
}
result.text.push(c);
result.ends.push((result.text.len(), start + c.len_utf8()));
}
result
}
fn source_end(&self, normalized_end: usize) -> usize {
self.ends.binary_search_by_key(&normalized_end, |(end, _)| *end)
.map(|index| self.ends[index].1).expect("grapheme cuts are character boundaries")
}
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,271 @@
//! Unexecuted evaluator candidates; the module is intentionally not wired yet.
use super::*;
use iced::advanced::{graphics::text::Paragraph, text::{Paragraph as _, Text as ParagraphText, Wrapping}};
// Independent paragraph construction: no candidate cache, normalization, search,
// or character-width estimate is used by this measurement oracle.
fn paragraph_size(content: &str, zoom: f32, style: Style) -> Size {
let size = Pixels(style.size.0 * zoom);
let line_height = match style.line_height {
LineHeight::Relative(factor) => LineHeight::Relative(factor),
LineHeight::Absolute(pixels) => LineHeight::Absolute(Pixels(pixels.0 * zoom)),
};
let paragraph = Paragraph::with_text(ParagraphText { content, bounds: Size::INFINITE,
size, line_height, font: style.font, align_x: Alignment::Default,
align_y: iced::alignment::Vertical::Top, shaping: style.shaping, wrapping: Wrapping::default() });
assert_eq!(paragraph.buffer().layout_runs().count(), 1);
Size::new(paragraph.min_width() / zoom, paragraph.min_height() / zoom)
}
#[test]
fn adequate_corridor_reveals_full_realistic_names_and_distinguishing_suffixes() {
let mut cache = Cache::default();
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
let style = Style::default();
for text in [
"1. call · Request valve repair · Technician service conversation",
"2. reply · Return pressure after repair · Technician service conversation",
"1. call · Request redundant hydraulic pressure calibration channel A",
"1. call · Request redundant hydraulic pressure calibration channel B",
] {
let wide = cache.resolve(&session, text, 1600., 1., style).unwrap();
assert_eq!(wide.displayed(&session, 1.).unwrap(), text);
assert_eq!(wide.retained_source(&session, 1.).unwrap(), 0..text.len());
assert_eq!(wide.bounds.size(), paragraph_size(text, 1., style));
let narrow = cache.resolve(&session, text, 80., 1., style).unwrap();
assert_ne!(narrow.displayed, text);
assert!(narrow.bounds.width <= 80.);
assert_eq!(narrow.bounds.size(), paragraph_size(&narrow.displayed, 1., style));
}
}
#[test]
fn normalized_visible_prefix_maps_to_original_bytes_without_claiming_hidden_operation() {
let raw = " \t1. call · Pump\r\n · Operation: Need\u{301}le ";
let expected = "1. call · Pump · Operation: Need\u{301}le";
let n = Normalized::new(raw);
assert_eq!(n.text, expected);
let prefix = "1. call · Pump ";
assert_eq!(n.source_end(prefix.len()), raw.find("· Operation").unwrap());
let mut cache = Cache::default();
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
let wide = cache.resolve(&session, raw, 1600., 1., Style::default()).unwrap();
assert_eq!(wide.displayed, expected);
assert_eq!(wide.retained_source.end, raw.trim_end().len());
let field = raw.find("Need").unwrap()..raw.trim_end().len();
assert!(wide.retains_any(&session, 1., field.clone()).unwrap());
assert!(!wide.retains_any(&session, 1., 0..2).unwrap(), "normalized whitespace is not a visible field");
let small = cache.resolve(&session, raw, 70., 1., Style::default()).unwrap();
assert!(!small.retains_any(&session, 1., field).unwrap());
assert!(small.retained_source.end < raw.find("Operation").unwrap());
}
#[test]
fn grapheme_prefixes_and_zoom_match_actual_paragraph_layout() {
let text = "call · 戻り値 圧力 e\u{301} 👩‍🔬 safety valve verification";
let mut cache = Cache::default();
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
for zoom in [0.66, 1., 1.75] {
let label = cache.resolve(&session, text, 105., zoom, Style::default()).unwrap();
assert!(label.bounds.width <= 105.);
assert_eq!(label.bounds.size(), paragraph_size(&label.displayed, zoom, Style::default()));
assert!(text.grapheme_indices(true).any(|(i, _)| i == label.retained_source.end)
|| label.retained_source.end == text.len());
let painted = label.canvas_text(&session, zoom, Point::new(400., 90.), Color::BLACK).unwrap();
assert_eq!(painted.content, label.displayed);
assert!(painted.max_width.is_infinite());
assert_eq!(label.bounds_at(&session, zoom, painted.position).unwrap().size(), label.bounds.size());
}
}
#[test]
fn empty_and_too_narrow_labels_never_claim_source_visibility() {
let mut cache = Cache::default();
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
for text in ["", " \t\n", "Operation: Pump"] {
let label = cache.resolve(&session, text, 0., 1., Style::default()).unwrap();
assert_eq!(label.displayed, "");
assert_eq!(label.retained_source, 0..0);
assert_eq!(label.bounds.size(), Size::ZERO);
assert!(!label.retains_any(&session, 1., 0..text.len()).unwrap());
}
}
#[test]
fn redraws_reuse_resolution_but_width_zoom_and_style_are_dependencies() {
let mut cache = Cache::default();
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
let text = "call · Pressure verification with a descriptive operation label";
let style = Style::default();
let a = cache.resolve(&session, text, 100., 1., style).unwrap();
let measured = cache.allocations;
let b = cache.resolve(&session, text, 100., 1., style).unwrap();
assert!(Arc::ptr_eq(&a, &b));
assert_eq!(cache.allocations, measured);
for (width, zoom, style) in [(120., 1., style), (100., 0.66, style),
(100., 1., Style { size: Pixels(14.), ..style })] {
let other = cache.resolve(&session, text, width, zoom, style).unwrap();
assert!(!Arc::ptr_eq(&a, &other));
}
}
#[test]
fn resident_entry_is_remeasured_for_a_fresh_settled_render_session() {
let mut cache = Cache::default();
let old_session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
let text = "call · Pressure verification";
let a = cache.resolve(&old_session, text, 100., 1., Style::default()).unwrap();
assert_eq!(cache.entries.len(), 1);
assert!(Arc::ptr_eq(&cache.entries.front().unwrap().label, &a));
let measured = cache.allocations;
assert!(measured > 0);
assert!(Arc::ptr_eq(&a, &cache.resolve(&old_session, text, 100., 1., Style::default()).unwrap()));
assert_eq!(cache.allocations, measured);
// A fresh renderer session invalidates a still-resident entry. This does
// not mutate global fonts or purport to test dynamic WGPU font loading.
let new_session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
let refreshed = cache.resolve(&new_session, text, 100., 1., Style::default()).unwrap();
assert!(!Arc::ptr_eq(&a, &refreshed));
assert!(cache.allocations > measured);
assert_eq!(cache.entries.len(), 1);
assert!(Arc::ptr_eq(&cache.entries.front().unwrap().label, &refreshed));
}
#[test]
fn resident_entry_remeasures_after_local_font_epoch_invalidation() {
let mut cache = Cache::default();
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
let text = "call · Resident pressure label";
let a = cache.resolve(&session, text, 100., 1., Style::default()).unwrap();
assert_eq!(cache.entries.len(), 1);
assert!(Arc::ptr_eq(&cache.entries.front().unwrap().label, &a));
let measured = cache.allocations;
assert!(measured > 0);
// Exercise the local memo epoch branch while a is still resident. No
// intervening width/zoom/style resolution can cause incidental eviction.
// This is not an actual font-load or renderer-cache acceptance test.
cache.version = None;
let refreshed = cache.resolve(&session, text, 100., 1., Style::default()).unwrap();
assert!(!Arc::ptr_eq(&a, &refreshed));
assert!(cache.allocations > measured);
assert_eq!(cache.entries.len(), 1);
assert!(Arc::ptr_eq(&cache.entries.front().unwrap().label, &refreshed));
}
#[test]
fn retained_artifact_rejects_other_zoom_and_render_session_for_every_consumer() {
let mut cache = Cache::default();
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
let label = cache.resolve(&session, "Operation: Pressure", 200., 1., Style::default()).unwrap();
let other = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
for (context, zoom, expected) in [(&session, 0.66, Error::StaleZoom), (&other, 1., Error::StaleRenderSession)] {
assert_eq!(label.displayed(context, zoom).unwrap_err(), expected);
assert_eq!(label.retained_source(context, zoom).unwrap_err(), expected);
assert_eq!(label.retains_any(context, zoom, 0..9).unwrap_err(), expected);
assert_eq!(label.bounds_at(context, zoom, Point::ORIGIN).unwrap_err(), expected);
assert_eq!(label.canvas_text(context, zoom, Point::ORIGIN, Color::BLACK).unwrap_err(), expected);
}
assert!(label.canvas_text(&session, 1., Point::ORIGIN, Color::BLACK).is_ok());
}
#[test]
fn finite_scaled_metrics_cannot_escape_as_infinite_world_dimensions() {
let mut cache = Cache::default();
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
let style = Style { size: Pixels(2.), line_height: LineHeight::Relative(f32::MAX), ..Style::default() };
// At zoom 0.5 the scaled size=1 and line height=MAX are finite. The
// required world line height is not; reject before passing it to shaping.
let scaled = style.scaled(0.5);
assert!(scaled.0.is_finite() && scaled.1.is_finite());
assert_eq!(cache.resolve(&session, "call", 100., 0.5, style).unwrap_err(), Error::InvalidMetrics);
assert_eq!(cache.allocations, 0);
assert!(cache.entries.is_empty());
// Independently exercise validation after division for actual measured
// dimensions: finite scaled width/height do not imply finite world values.
assert_eq!(world_size(Size::new(f32::MAX, 1.), 0.5), Err(Error::InvalidMeasurement));
assert_eq!(world_size(Size::new(1., f32::MAX), 0.5), Err(Error::InvalidMeasurement));
assert_eq!(world_size(Size::new(20., 8.), 0.5).unwrap(), Size::new(40., 16.));
}
#[test]
fn malformed_metrics_and_explicit_experiment_budgets_fail_without_a_cached_candidate() {
let mut cache = Cache::default();
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
for (width, zoom) in [(f32::NAN, 1.), (-1., 1.), (100., 0.), (100., f32::INFINITY)] {
assert_eq!(cache.resolve(&session, "call", width, zoom, Style::default()).unwrap_err(), Error::InvalidMetrics);
}
assert_eq!(cache.resolve(&session, &"x".repeat(MAX_SOURCE_BYTES + 1), 100., 1., Style::default()).unwrap_err(), Error::SourceBudget);
assert_eq!(cache.resolve(&session, &"x".repeat(MAX_GRAPHEMES + 1), 100., 1., Style::default()).unwrap_err(), Error::GraphemeBudget);
assert!(cache.entries.is_empty());
}
#[test]
fn bounds_translation_rejects_nonfinite_world_positions_and_endpoints() {
let mut cache = Cache::default();
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
let label = cache.resolve(&session, "call", 100., 1., Style::default()).unwrap();
for position in [Point::new(f32::NAN, 0.), Point::new(0., f32::INFINITY)] {
assert_eq!(label.bounds_at(&session, 1., position).unwrap_err(), Error::InvalidPosition);
assert_eq!(label.canvas_text(&session, 1., position, Color::BLACK).unwrap_err(), Error::InvalidPosition);
}
assert_eq!(translated_bounds(Point::new(f32::MAX, 0.), Size::new(f32::MAX, 1.)), Err(Error::InvalidPosition));
assert_eq!(translated_bounds(Point::new(0., f32::MAX), Size::new(1., f32::MAX)), Err(Error::InvalidPosition));
assert_eq!(label.bounds_at(&session, 1., Point::new(-20., -10.)).unwrap().position(), Point::new(-20., -10.));
}
/// Must run as the ONLY selected test in a dedicated, fresh test process.
/// The ignored marker keeps this global-font mutation out of normal parallel
/// desktop tests. See the evidence directory's exact executable runner.
#[test]
#[ignore = "mutates the global font database; run only this exact test in a fresh process"]
fn actual_font_load_rejects_retained_label_and_old_session() {
use std::borrow::Cow;
use graphics_text::cosmic_text::fontdb;
let mut cache = Cache::default();
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
let source = "Operation: Pressure";
let label = cache.resolve(&session, source, 200., 1., Style::default()).unwrap();
assert_eq!(label.displayed(&session, 1.).unwrap(), source);
assert!(label.canvas_text(&session, 1., Point::ORIGIN, Color::BLACK).is_ok());
let measured = cache.allocations;
assert!(measured > 0);
assert!(Arc::ptr_eq(&cache.entries.front().unwrap().label, &label));
let (version_before, version_after, faces_before, faces_after) = {
let mut fonts = graphics_text::font_system().write().unwrap();
let face = fonts.raw().db().faces()
.find(|face| face.families.iter().any(|(family, _)| family == "Iced-Icons"))
.expect("Iced 0.14 embeds and loads its known Iced-Icons.ttf face").id;
let bytes = fonts.raw().db().with_face_data(face, |data, _| data.to_vec())
.expect("the built-in binary font has accessible bytes");
assert!(!bytes.is_empty());
// load_font increments Version even for invalid bytes. Independently
// establish actual face admission so this cannot pass with invalid data.
let mut probe = fontdb::Database::new();
probe.load_font_data(bytes.clone());
let admitted = probe.faces().filter(|face|
face.families.iter().any(|(family, _)| family == "Iced-Icons")).count();
assert!(admitted > 0, "the extracted bytes parse as the expected real font");
let before = fonts.version();
assert_eq!(before, session.version);
let count = fonts.raw().db().faces().count();
// Owned bytes intentionally bypass Borrowed-address deduplication.
// This is an actual valid duplicate-face load, not a claim the chosen
// face or rendered metrics change; the stale-epoch rule is the oracle.
fonts.load_font(Cow::Owned(bytes));
(before, fonts.version(), count, fonts.raw().db().faces().count())
};
assert_ne!(version_before, version_after);
assert!(faces_after > faces_before, "actual font admission must accompany the version increment");
assert_eq!(label.displayed(&session, 1.).unwrap_err(), Error::FontSetChanged);
assert_eq!(label.retained_source(&session, 1.).unwrap_err(), Error::FontSetChanged);
assert_eq!(label.retains_any(&session, 1., 0..9).unwrap_err(), Error::FontSetChanged);
assert_eq!(label.bounds_at(&session, 1., Point::ORIGIN).unwrap_err(), Error::FontSetChanged);
assert_eq!(label.canvas_text(&session, 1., Point::ORIGIN, Color::BLACK).unwrap_err(), Error::FontSetChanged);
assert_eq!(cache.resolve(&session, source, 200., 1., Style::default()).unwrap_err(), Error::FontSetChanged);
assert_eq!(cache.allocations, measured, "an invalid session must not remeasure and imply renderer repair");
println!("Valid Iced-Icons load: faces {faces_before}->{faces_after}, font Version {version_before:?}->{version_after:?}; old label and session rejected");
// Do not create a fresh session or claim dynamic WGPU cache recovery here.
// Process exit is the isolation boundary; no other test may run afterward.
}
@@ -0,0 +1,50 @@
# Runs an ALREADY BUILT desktop test executable. This script never invokes Cargo.
# Supply the matching runtime DLL environment prepared by the compiler owner.
[CmdletBinding()]
param(
[Parameter(Mandatory=$true)][string]$TestExecutable,
[Parameter(Mandatory=$true)][ValidatePattern('^[0-9a-fA-F]{64}$')][string]$ExpectedSha256,
[Parameter(Mandatory=$true)][string]$RunDirectory,
[ValidateSet('behavior::scenario_label::tests::actual_font_load_rejects_retained_label_and_old_session', 'scenario_label::tests::actual_font_load_rejects_retained_label_and_old_session')]
[string]$TestName = 'behavior::scenario_label::tests::actual_font_load_rejects_retained_label_and_old_session'
)
$ErrorActionPreference = 'Stop'
$fontTestName = $TestName
$fontTestExe = (Resolve-Path -LiteralPath $TestExecutable).Path
$fontTestHash = (Get-FileHash -LiteralPath $fontTestExe -Algorithm SHA256).Hash
if ($fontTestHash -ne $ExpectedSha256) { throw 'Test executable SHA256 differs from the supplied compiler receipt' }
if (Test-Path -LiteralPath $RunDirectory) { throw 'Use a fresh evidence directory; refusing to overwrite a previous run' }
$fontTestOutput = (New-Item -ItemType Directory -Path $RunDirectory).FullName
# Listing is a separate process and runs no test. Refuse zero/multiple matches
# before launching the one process that will mutate global font state.
$fontTestList = & $fontTestExe --list --ignored --exact $fontTestName 2>&1
$fontTestListExit = $LASTEXITCODE
$fontTestListText = $fontTestList | Out-String
$fontTestListText | Set-Content -LiteralPath (Join-Path $fontTestOutput 'selection.log') -Encoding utf8
$fontTestSelected = $fontTestListExit -eq 0 -and
$fontTestListText -match ('(?m)^' + [regex]::Escape($fontTestName) + ': test\s*$') -and
$fontTestListText -match '(?m)^1 test, 0 benchmarks\s*$'
if (-not $fontTestSelected) { throw 'The exact ignored-test selection did not list exactly one test; inspect selection.log' }
# Fresh dedicated executable invocation; no normal/other ignored test is chosen.
$fontTestStarted = (Get-Date).ToUniversalTime().ToString('o')
$fontTestRun = & $fontTestExe --ignored --exact $fontTestName --nocapture --test-threads=1 2>&1
$fontTestExit = $LASTEXITCODE
$fontTestRunText = $fontTestRun | Out-String
$fontTestRunText | Set-Content -LiteralPath (Join-Path $fontTestOutput 'test.log') -Encoding utf8
$fontTestPassed = $fontTestExit -eq 0 -and
$fontTestRunText -match 'test result: ok\. 1 passed; 0 failed; 0 ignored;'
@{
test = $fontTestName
executable = $fontTestExe
sha256 = $fontTestHash
exactSelection = $fontTestSelected
startedUtc = $fontTestStarted
finishedUtc = (Get-Date).ToUniversalTime().ToString('o')
exitCode = $fontTestExit
passedExactlyOne = $fontTestPassed
scope = 'Actual global font Version invalidation and artifact rejection; no WGPU dynamic cache recovery claim'
} | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $fontTestOutput 'receipt.json') -Encoding utf8
Write-Output $fontTestRunText
if (-not $fontTestPassed) { throw 'Isolated font-version test did not pass exactly one test; inspect test.log and receipt.json' }
@@ -0,0 +1,5 @@
//! Isolated evaluator only: does not wire the desktop renderer.
#[path = "../../../crates/oa-desktop/src/behavior/scenario_label.rs"]
mod scenario_label;
#[path = "../../../crates/oa-desktop/src/behavior/scenario_label/corridor.rs"]
mod scenario_label_corridor;