566 lines
21 KiB
Rust
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);
|
|
}
|
|
}
|