Files
ARCADIA-Studio/harness/experiments/scenario-label-wgpu-20260913/main.rs
T

566 lines
21 KiB
Rust

//! Standalone offscreen evaluator; deliberately compiled WITHOUT --test.
//! Imports the actual candidate, never a copy of its shaping or truncation code.
#[path = "../../../crates/oa-desktop/src/behavior/scenario_label.rs"]
mod scenario_label;
use iced::advanced::{
graphics::{geometry::Renderer as _, text as graphics_text},
renderer::{Headless, Renderer as _},
text::{Alignment, LineHeight, Shaping},
};
use iced::{Color, Font, Pixels, Point, Rectangle, Size, Vector, widget::canvas};
use scenario_label::{Cache, Label, RenderSession, Style};
use std::{collections::BTreeSet, fmt::Write as _, fs, future::Future, path::Path, sync::Arc};
const PAN: Vector = Vector::new(37.25, 23.5);
// POSITION remains the TRUE corridor origin, unchanged from empirical policy 1.
const POSITION: Point = Point::new(36.25, 40.5);
const PIXEL_DELTA: u8 = 3;
const HORIZONTAL_INSET: f32 = 3.;
const POLICY: &str = "2-fixed-corridor-inset-3-world-units";
struct Case {
name: &'static str,
source: &'static str,
style: Style,
require_italic: bool,
}
fn cases() -> [Case; 4] {
let advanced = Style {
shaping: Shaping::Advanced,
..Style::default()
};
[
Case {
name: "realistic-default",
source: "1. call · Request valve repair · Technician service conversation",
style: Style::default(),
require_italic: false,
},
Case {
name: "cjk-advanced",
source: "2. call · 请求阀门维修并确认压力传感器状态 · 技术人员服务会话",
style: advanced,
require_italic: false,
},
Case {
name: "combining-advanced",
source: "3. call · A\u{0301}\u{0323}larm e\u{0301}valuation · verify technician's pressure-report acknowledgement",
style: advanced,
require_italic: false,
},
Case {
name: "italic-advanced",
source: "jfj · Verify valve qualification, pressure relief and technician signoff",
style: Style {
font: Font {
style: iced::font::Style::Italic,
..Font::DEFAULT
},
..advanced
},
require_italic: true,
},
]
}
// No runtime or test-suite global executor: this executable owns the GPU/font
// process. WGPU's adapter/device futures provide their normal wake notifications.
fn block_on<T>(future: impl Future<Output = T>) -> T {
struct WakeThread(std::thread::Thread);
impl std::task::Wake for WakeThread {
fn wake(self: Arc<Self>) {
self.0.unpark();
}
fn wake_by_ref(self: &Arc<Self>) {
self.0.unpark();
}
}
let waker = std::task::Waker::from(Arc::new(WakeThread(std::thread::current())));
let mut context = std::task::Context::from_waker(&waker);
let mut future = std::pin::pin!(future);
loop {
match future.as_mut().poll(&mut context) {
std::task::Poll::Ready(value) => return value,
std::task::Poll::Pending => std::thread::park(),
}
}
}
#[derive(Clone, Copy)]
enum Pass {
Text,
ManualText,
Decoration,
Composite,
}
struct Surface {
logical: Size,
physical: Size<u32>,
dpi: f32,
zoom: f32,
text_position: Point,
advance: Rectangle,
plate: Rectangle,
corridor: Rectangle,
blockers: [Rectangle; 2],
}
impl Surface {
fn new(label: &Label, session: &RenderSession, zoom: f32, dpi: f32, width: f32) -> Self {
let text_position = Point::new(POSITION.x + HORIZONTAL_INSET, POSITION.y);
let advance = label.bounds_at(session, zoom, text_position).unwrap();
// Explicit EXPERIMENT candidate: 3 horizontal / 2 vertical world units.
// It is not an accepted production padding or an assertion about ink.
let plate = grow(advance, HORIZONTAL_INSET, 2.);
// Do not derive corridor.x from the shifted text or widen the corridor
// to excuse overhang. Its original origin/width and blockers stay fixed.
let corridor = Rectangle {
x: POSITION.x,
width,
..advance
};
let blockers = [
Rectangle {
x: POSITION.x - 12.,
y: POSITION.y - 12.,
width: 8.,
height: 52.,
},
Rectangle {
x: POSITION.x + width + 4.,
y: POSITION.y - 12.,
width: 8.,
height: 52.,
},
];
let physical = Size::new(
((PAN.x + (POSITION.x + width + 70.) * zoom) * dpi).ceil() as u32,
((PAN.y + (POSITION.y + 90.) * zoom) * dpi).ceil() as u32,
);
let logical = Size::new(physical.width as f32 / dpi, physical.height as f32 / dpi);
Self {
logical,
physical,
dpi,
zoom,
text_position,
advance,
plate,
corridor,
blockers,
}
}
fn pixels(&self, rect: Rectangle) -> Rectangle {
Rectangle {
x: (PAN.x + rect.x * self.zoom) * self.dpi,
y: (PAN.y + rect.y * self.zoom) * self.dpi,
width: rect.width * self.zoom * self.dpi,
height: rect.height * self.zoom * self.dpi,
}
}
}
fn grow(rect: Rectangle, x: f32, y: f32) -> Rectangle {
Rectangle {
x: rect.x - x,
y: rect.y - y,
width: rect.width + 2. * x,
height: rect.height + 2. * y,
}
}
fn render(
renderer: &mut iced::Renderer,
surface: &Surface,
label: &Label,
session: &RenderSession,
style: Style,
pass: Pass,
pixel_shift: f32,
) -> Vec<u8> {
renderer.reset(Rectangle::with_size(surface.logical));
let mut frame = canvas::Frame::new(renderer, surface.logical);
// Same order as actual scenario Frame: camera translation, then uniform scale.
// The extra translation is only the independent integer-pixel shift oracle.
frame.translate(Vector::new(PAN.x + pixel_shift / surface.dpi, PAN.y));
frame.scale(surface.zoom);
if matches!(pass, Pass::Decoration | Pass::Composite) {
frame.fill_rectangle(
surface.plate.position(),
surface.plate.size(),
Color::from_rgb8(255, 230, 120),
);
// Backplate precedes activation-like blockers; text remains foreground.
// These are probes, not imported Scene activations or a corridor planner.
for blocker in surface.blockers {
frame.fill_rectangle(
blocker.position(),
blocker.size(),
Color::from_rgb8(0, 40, 255),
);
}
}
if !matches!(pass, Pass::Decoration) {
let text = if matches!(pass, Pass::ManualText) {
// Independently construct the documented paint payload. Both paths
// use actual Frame::fill_text; no alternate renderer is involved.
canvas::Text {
content: label.displayed(session, surface.zoom).unwrap().to_owned(),
position: surface.text_position,
color: Color::BLACK,
size: style.size,
line_height: style.line_height,
font: style.font,
shaping: style.shaping,
max_width: f32::INFINITY,
..canvas::Text::default()
}
} else {
label
.canvas_text(session, surface.zoom, surface.text_position, Color::BLACK)
.unwrap()
};
frame.fill_text(text);
}
renderer.draw_geometry(frame.into_geometry());
let pixels = Headless::screenshot(renderer, surface.physical, surface.dpi, Color::WHITE);
assert_eq!(
pixels.len(),
surface.physical.width as usize * surface.physical.height as usize * 4
);
pixels
}
fn differs(a: &[u8], b: &[u8]) -> bool {
a.iter().zip(b).any(|(&a, &b)| a.abs_diff(b) > PIXEL_DELTA)
}
fn inside(rect: Rectangle, x: usize, y: usize) -> bool {
let x = x as f32 + 0.5;
let y = y as f32 + 0.5;
x >= rect.x && x < rect.x + rect.width && y >= rect.y && y < rect.y + rect.height
}
#[derive(Default, Debug)]
struct Ink {
count: usize,
bounds: Option<(usize, usize, usize, usize)>,
outside_advance: usize,
outside_advance_plus_one_pixel: usize,
outside_plate: usize,
outside_plate_plus_one_pixel: usize,
outside_corridor_x_plus_one_pixel: usize,
touches_viewport: usize,
}
fn ink(pixels: &[u8], surface: &Surface) -> Ink {
let advance = surface.pixels(surface.advance);
let plate = surface.pixels(surface.plate);
let corridor = surface.pixels(surface.corridor);
let mut out = Ink::default();
for (i, rgba) in pixels.chunks_exact(4).enumerate() {
if !differs(rgba, &[255, 255, 255, 255]) {
continue;
}
let (x, y) = (
i % surface.physical.width as usize,
i / surface.physical.width as usize,
);
out.count += 1;
out.bounds = Some(match out.bounds {
None => (x, y, x + 1, y + 1),
Some((l, t, r, b)) => (l.min(x), t.min(y), r.max(x + 1), b.max(y + 1)),
});
out.outside_advance += usize::from(!inside(advance, x, y));
out.outside_advance_plus_one_pixel += usize::from(!inside(grow(advance, 1., 1.), x, y));
out.outside_plate += usize::from(!inside(plate, x, y));
out.outside_plate_plus_one_pixel += usize::from(!inside(grow(plate, 1., 1.), x, y));
let px = x as f32 + 0.5;
out.outside_corridor_x_plus_one_pixel +=
usize::from(px < corridor.x - 1. || px >= corridor.x + corridor.width + 1.);
out.touches_viewport += usize::from(
x == 0
|| y == 0
|| x + 1 == surface.physical.width as usize
|| y + 1 == surface.physical.height as usize,
);
}
out
}
// Independent inspection of the actual shaped glyphs is ONLY a coverage gate:
// metrics from this buffer do not serve as the painted-ink oracle.
fn font_coverage(
displayed: &str,
style: Style,
zoom: f32,
) -> (usize, usize, bool, BTreeSet<String>) {
let size = Pixels(style.size.0 * zoom);
let line_height = match style.line_height {
LineHeight::Absolute(value) => value.0 * zoom,
LineHeight::Relative(value) => value * size.0,
};
let mut fonts = graphics_text::font_system().write().unwrap();
let mut shapes = graphics_text::Cache::new();
let (_, entry) = shapes.allocate(
fonts.raw(),
graphics_text::cache::Key {
content: displayed,
size: size.0,
line_height,
font: style.font,
bounds: Size::INFINITE,
shaping: style.shaping,
align_x: Alignment::Default,
},
);
let (mut glyphs, mut missing, mut italic) = (0, 0, false);
let mut faces = BTreeSet::new();
for run in entry.buffer.layout_runs() {
for glyph in run.glyphs {
glyphs += 1;
missing += usize::from(glyph.glyph_id == 0);
if let Some(face) = fonts.raw().db().face(glyph.font_id) {
italic |= face.style != graphics_text::cosmic_text::fontdb::Style::Normal;
faces.insert(format!(
"{:?} {:?} {:?}",
glyph.font_id, face.families, face.style
));
}
}
}
(glyphs, missing, italic, faces)
}
fn save_pixels(dir: &Path, name: &str, pixels: &[u8], size: Size<u32>) {
fs::write(dir.join(format!("{name}.rgba")), pixels).unwrap();
// Standard portable pixmap for immediate image inspection, no image crate.
let mut ppm = format!("P6\n{} {}\n255\n", size.width, size.height).into_bytes();
for pixel in pixels.chunks_exact(4) {
ppm.extend_from_slice(&pixel[..3]);
}
fs::write(dir.join(format!("{name}.ppm")), ppm).unwrap();
}
fn evaluate(
renderer: &mut iced::Renderer,
session: &RenderSession,
case: &Case,
zoom: f32,
dpi: f32,
width: f32,
dir: &Path,
) -> (usize, usize, bool) {
fs::create_dir(dir).unwrap();
let mut cache = Cache::default();
let text_budget = width - 2. * HORIZONTAL_INSET;
assert!(
text_budget > 0.,
"these 32 probes require a positive inset text budget"
);
let label = cache
.resolve(session, case.source, text_budget, zoom, case.style)
.unwrap();
let displayed = label.displayed(session, zoom).unwrap();
let retained = label.retained_source(session, zoom).unwrap();
let surface = Surface::new(&label, session, zoom, dpi, width);
let text = render(
renderer,
&surface,
&label,
session,
case.style,
Pass::Text,
0.,
);
let manual = render(
renderer,
&surface,
&label,
session,
case.style,
Pass::ManualText,
0.,
);
let moved = render(
renderer,
&surface,
&label,
session,
case.style,
Pass::Text,
16.,
);
let decoration = render(
renderer,
&surface,
&label,
session,
case.style,
Pass::Decoration,
0.,
);
let composite = render(
renderer,
&surface,
&label,
session,
case.style,
Pass::Composite,
0.,
);
let measured_ink = ink(&text, &surface);
let (glyphs, missing, italic, faces) = font_coverage(displayed, case.style, zoom);
let payload_mismatch = text
.chunks_exact(4)
.zip(manual.chunks_exact(4))
.filter(|(a, b)| differs(a, b))
.count();
let mut translation_mismatch = 0;
let mut blocker_changed = 0;
let mut blocker_samples = 0;
let plate_inside_corridor_x = surface.plate.x >= surface.corridor.x
&& surface.plate.x + surface.plate.width <= surface.corridor.x + surface.corridor.width;
let corridor_pixels = surface.pixels(surface.corridor);
let mut painted_plate_samples = 0;
let mut painted_plate_outside_corridor_x = 0;
let mut painted_plate_outside_corridor_x_plus_one_pixel = 0;
for (i, rgba) in moved.chunks_exact(4).enumerate() {
let x = i % surface.physical.width as usize;
let expected = if x >= 16 {
&text[(i - 16) * 4..(i - 16) * 4 + 4]
} else {
&[255, 255, 255, 255]
};
translation_mismatch += usize::from(differs(rgba, expected));
}
for (i, (before, after)) in decoration
.chunks_exact(4)
.zip(composite.chunks_exact(4))
.enumerate()
{
// Sample actual opaque blue blocker interiors, not a guessed screen box.
if before[0] < 8 && before[1] < 60 && before[2] > 245 {
blocker_samples += 1;
blocker_changed += usize::from(differs(before, after));
}
// Yellow plate over white has full red and attenuated blue. This mask
// also includes weak antialiased plate edges; blue blockers cannot match.
if before[0] > 247 && before[2] < 252 {
painted_plate_samples += 1;
let x = (i % surface.physical.width as usize) as f32 + 0.5;
painted_plate_outside_corridor_x += usize::from(
x < corridor_pixels.x || x >= corridor_pixels.x + corridor_pixels.width,
);
painted_plate_outside_corridor_x_plus_one_pixel += usize::from(
x < corridor_pixels.x - 1. || x >= corridor_pixels.x + corridor_pixels.width + 1.,
);
}
}
let source_expectation = if width > 500. {
displayed == case.source && retained == (0..case.source.len())
} else {
displayed.ends_with('…') && retained.end < case.source.len() && retained.end > 0
};
// Infrastructure/contract failures differ from geometric candidate findings.
let invalid = usize::from(!source_expectation)
+ usize::from(glyphs == 0 || missing > 0 || (case.require_italic && !italic))
+ usize::from(measured_ink.count == 0 || measured_ink.touches_viewport > 0)
+ usize::from(
payload_mismatch > 0
|| translation_mismatch > 0
|| blocker_samples == 0
|| painted_plate_samples == 0,
);
let finding = usize::from(measured_ink.outside_plate_plus_one_pixel > 0)
+ usize::from(measured_ink.outside_corridor_x_plus_one_pixel > 0)
+ usize::from(blocker_changed > 0)
+ usize::from(!plate_inside_corridor_x)
+ usize::from(painted_plate_outside_corridor_x_plus_one_pixel > 0);
let mut report = format!(
"policy={POLICY}\nsource={:?}\ndisplayed={displayed:?}\nretained={retained:?}\nstyle={:?}\nzoom={zoom}\ndpi={dpi}\nwidth={width}\ntext_budget={text_budget}\nhorizontal_inset={HORIZONTAL_INSET}\nphysical={:?}\nadvance_world={:?}\nplate_world={:?}\nblockers_world={:?}\nink={measured_ink:#?}\nglyphs={glyphs}\nmissing_glyphs={missing}\nresolved_italic={italic}\npayload_mismatch_pixels={payload_mismatch}\ntranslation_mismatch_pixels={translation_mismatch}\nblocker_samples={blocker_samples}\nblocker_changed_pixels={blocker_changed}\nplate_inside_corridor_x={plate_inside_corridor_x}\npainted_plate_samples={painted_plate_samples}\npainted_plate_outside_corridor_x={painted_plate_outside_corridor_x}\npainted_plate_outside_corridor_x_plus_one_pixel={painted_plate_outside_corridor_x_plus_one_pixel}\ninvalid_checks={invalid}\ncandidate_findings={finding}\n",
case.source, case.style, surface.physical, surface.advance, surface.plate, surface.blockers,
);
writeln!(
&mut report,
"corridor_world={:?}\ntext_position={:?}",
surface.corridor, surface.text_position
)
.unwrap();
for face in faces {
writeln!(&mut report, "resolved_face={face}").unwrap();
}
fs::write(dir.join("metrics.txt"), report).unwrap();
for (name, pixels) in [
("text", &text),
("decoration", &decoration),
("composite", &composite),
] {
save_pixels(dir, name, pixels, surface.physical);
}
if payload_mismatch > 0 {
save_pixels(dir, "manual-control", &manual, surface.physical);
}
if translation_mismatch > 0 {
save_pixels(dir, "shift-control", &moved, surface.physical);
}
(invalid, finding, measured_ink.outside_advance > 0)
}
fn main() {
let args: Vec<_> = std::env::args_os().collect();
assert_eq!(
args.len(),
2,
"usage: scenario-label-wgpu.exe NEW_OUTPUT_DIRECTORY"
);
let out = Path::new(&args[1]);
fs::create_dir(out).expect("output directory must be new; preserve prior runs");
// Initialize the system font database before the new renderer. No fonts are
// loaded after this point, through all screenshots and coverage inspection.
let version = graphics_text::font_system().read().unwrap().version();
let mut renderer = block_on(<iced::Renderer as Headless>::new(
Font::DEFAULT,
Pixels(16.),
Some("wgpu"),
))
.expect("WGPU adapter/device unavailable: infrastructure failure, never a skipped pass");
assert_eq!(Headless::name(&renderer), "wgpu");
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
let mut summary = format!(
"policy={POLICY}\nhorizontal_inset={HORIZONTAL_INSET}\nfont_version={version:?}\nrenderer=wgpu\nchannel_delta_threshold={PIXEL_DELTA}\nimported_module=crates/oa-desktop/src/behavior/scenario_label.rs\n"
);
let (mut cases_run, mut invalid, mut findings, mut advance_spill_cases) = (0, 0, 0, 0);
for case in cases() {
for (zoom, dpi) in [(0.65, 1.), (1., 1.), (1.75, 1.), (1., 1.5)] {
for width in [160., 760.] {
let name = format!("{}-z{zoom}-dpi{dpi}-w{width}", case.name);
let dir = out.join(&name);
let (bad, found, advance_spill) =
evaluate(&mut renderer, &session, &case, zoom, dpi, width, &dir);
// Summary is only an index; full numeric ink fields are per case.
advance_spill_cases += usize::from(advance_spill);
writeln!(&mut summary, "{name}: invalid={bad} findings={found}").unwrap();
invalid += bad;
findings += found;
cases_run += 1;
}
}
}
assert_eq!(cases_run, 32);
writeln!(&mut summary, "cases={cases_run}\ninvalid_checks={invalid}\ncandidate_findings={findings}\nadvance_spill_cases={advance_spill_cases}\n").unwrap();
summary.push_str("Advance spill is measured evidence, not automatically a failure. Candidate findings reject this probe's padding/corridor assumptions. This is not Scene/native acceptance.\n");
fs::write(out.join("summary.txt"), &summary).unwrap();
print!("{summary}");
if invalid > 0 {
std::process::exit(3);
}
if findings > 0 {
std::process::exit(2);
}
}