style(simulation): fmt + clippy fixes for the measurement batch (gate bounce)
cargo fmt across the four new files; needless_range_loop x2 (enumerate / iter_mut) and identity_op in hydrology_equilibrium.rs. cargo test was green on the bounced push — lint-only fixes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -219,9 +219,9 @@ pub fn solve(
|
||||
let mut spill_cell: Vec<Option<usize>> = vec![None; basin_count];
|
||||
let mut spill_rim_elev: Vec<i64> = vec![i64::MAX; basin_count];
|
||||
let mut basin_cells: Vec<Vec<usize>> = vec![Vec::new(); basin_count];
|
||||
for idx in 0..n {
|
||||
if let Some(b) = basin_of[idx] {
|
||||
basin_cells[b as usize].push(idx);
|
||||
for (idx, entry) in basin_of.iter().enumerate().take(n) {
|
||||
if let Some(b) = entry {
|
||||
basin_cells[*b as usize].push(idx);
|
||||
}
|
||||
}
|
||||
for idx in 0..n {
|
||||
@@ -345,11 +345,9 @@ pub fn solve(
|
||||
cells: basin_cells[b].clone(),
|
||||
spill_level_scaled: spill_level[b],
|
||||
spill_cell: spill_cell[b].unwrap_or(0),
|
||||
outcome: outcomes[b]
|
||||
.clone()
|
||||
.unwrap_or(BasinOutcome::Endorheic {
|
||||
reason: EndorheicReason::MoistureGoverned,
|
||||
}),
|
||||
outcome: outcomes[b].clone().unwrap_or(BasinOutcome::Endorheic {
|
||||
reason: EndorheicReason::MoistureGoverned,
|
||||
}),
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -569,7 +567,10 @@ fn cheapest_overflow_path(
|
||||
// open plain. This is intentionally the same bucket as
|
||||
// EdgeUnreachable's shape (no further basin/sea structure)
|
||||
// but WITH a real path, so callers still get carving data.
|
||||
return (reconstruct_path(&came, idx), DownstreamTarget::EdgeUnreachable);
|
||||
return (
|
||||
reconstruct_path(&came, idx),
|
||||
DownstreamTarget::EdgeUnreachable,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -757,7 +758,11 @@ mod tests {
|
||||
fn bowl_grid_produces_at_least_one_lake_basin() {
|
||||
let elev = bowl_grid(64, 32);
|
||||
let result = solve(&elev, 64, 32, 0.0, default_climate());
|
||||
let nonempty: Vec<_> = result.basins.iter().filter(|b| !b.cells.is_empty()).collect();
|
||||
let nonempty: Vec<_> = result
|
||||
.basins
|
||||
.iter()
|
||||
.filter(|b| !b.cells.is_empty())
|
||||
.collect();
|
||||
assert!(
|
||||
!nonempty.is_empty(),
|
||||
"a bowl-shaped depression must fill to at least one lake basin"
|
||||
@@ -787,13 +792,23 @@ mod tests {
|
||||
let elev = slope_grid(128, 64);
|
||||
let r1 = solve(&elev, 128, 64, 0.3, default_climate());
|
||||
let r2 = solve(&elev, 128, 64, 0.3, default_climate());
|
||||
assert_eq!(r1.filled_scaled, r2.filled_scaled, "filled surface must be deterministic");
|
||||
assert_eq!(
|
||||
r1.filled_scaled, r2.filled_scaled,
|
||||
"filled surface must be deterministic"
|
||||
);
|
||||
assert_eq!(
|
||||
r1.channel_depth_scaled, r2.channel_depth_scaled,
|
||||
"carved channel depth must be deterministic"
|
||||
);
|
||||
assert_eq!(r1.cliff_edge, r2.cliff_edge, "cliff-edge flags must be deterministic");
|
||||
assert_eq!(r1.basins.len(), r2.basins.len(), "basin count must be deterministic");
|
||||
assert_eq!(
|
||||
r1.cliff_edge, r2.cliff_edge,
|
||||
"cliff-edge flags must be deterministic"
|
||||
);
|
||||
assert_eq!(
|
||||
r1.basins.len(),
|
||||
r2.basins.len(),
|
||||
"basin count must be deterministic"
|
||||
);
|
||||
for (a, b) in r1.basins.iter().zip(r2.basins.iter()) {
|
||||
assert_eq!(a.basin_id, b.basin_id);
|
||||
assert_eq!(a.cells, b.cells);
|
||||
@@ -838,13 +853,7 @@ mod tests {
|
||||
fn overflowing_basin_has_nonempty_outlet_path() {
|
||||
let elev = bowl_grid(64, 32);
|
||||
// Force overflow classification via high moisture.
|
||||
let result = solve(
|
||||
&elev,
|
||||
64,
|
||||
32,
|
||||
0.0,
|
||||
ClimateInputs { moisture_q: 90 },
|
||||
);
|
||||
let result = solve(&elev, 64, 32, 0.0, ClimateInputs { moisture_q: 90 });
|
||||
let overflowed: Vec<_> = result
|
||||
.basins
|
||||
.iter()
|
||||
@@ -857,7 +866,10 @@ mod tests {
|
||||
);
|
||||
for basin in overflowed {
|
||||
if let BasinOutcome::Overflow { outlet_path, .. } = &basin.outcome {
|
||||
assert!(!outlet_path.is_empty(), "overflow basin must have a search path");
|
||||
assert!(
|
||||
!outlet_path.is_empty(),
|
||||
"overflow basin must have a search path"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -865,13 +877,7 @@ mod tests {
|
||||
#[test]
|
||||
fn large_dry_basin_classifies_endorheic() {
|
||||
let elev = bowl_grid(96, 48);
|
||||
let result = solve(
|
||||
&elev,
|
||||
96,
|
||||
48,
|
||||
0.0,
|
||||
ClimateInputs { moisture_q: 10 },
|
||||
);
|
||||
let result = solve(&elev, 96, 48, 0.0, ClimateInputs { moisture_q: 10 });
|
||||
let has_endorheic = result
|
||||
.basins
|
||||
.iter()
|
||||
@@ -905,7 +911,10 @@ mod tests {
|
||||
for basin in &result.basins {
|
||||
let mut sorted = basin.cells.clone();
|
||||
sorted.sort_unstable();
|
||||
assert_eq!(basin.cells, sorted, "basin cells must be in ascending row-major order");
|
||||
assert_eq!(
|
||||
basin.cells, sorted,
|
||||
"basin cells must be in ascending row-major order"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -964,8 +973,7 @@ mod tests {
|
||||
original[14] = 50_000; // valid open-low-ground terminus (row2,col4)
|
||||
let mut basin_of: Vec<Option<u32>> = vec![None; w * h];
|
||||
basin_of[12] = Some(0);
|
||||
let (path, target) =
|
||||
cheapest_overflow_path(12, 100_000, &original, &basin_of, 0, -1, w, h);
|
||||
let (path, target) = cheapest_overflow_path(12, 100_000, &original, &basin_of, 0, -1, w, h);
|
||||
assert_eq!(
|
||||
target,
|
||||
DownstreamTarget::EdgeUnreachable,
|
||||
@@ -995,8 +1003,8 @@ mod tests {
|
||||
let w = 20usize;
|
||||
let h = 2usize;
|
||||
let mut original = vec![999_999i64; w * h];
|
||||
for c in 0..w {
|
||||
original[c] = 50_000; // row 0, the corridor
|
||||
for cell in original.iter_mut().take(w) {
|
||||
*cell = 50_000; // row 0, the corridor
|
||||
}
|
||||
original[1] = 0; // spill cell (row0,col1)
|
||||
original[7] = -10_000; // sea-level terminus (row0,col7)
|
||||
@@ -1014,7 +1022,10 @@ mod tests {
|
||||
// level (0) — this is the exact shape `solve()`'s carving step
|
||||
// consumes: `original[cell] - spill_level` for each path cell.
|
||||
for &cell in &path[1..path.len() - 1] {
|
||||
assert!(original[cell] - 0 > 0, "intermediate cells must be above spill level");
|
||||
assert!(
|
||||
original[cell] > 0,
|
||||
"intermediate cells must be above spill level (spill level is 0 here)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -249,7 +249,9 @@ fn bench_square_window_production_fn_district_spacing_single_thread() {
|
||||
let climate = ClimateConstants::default();
|
||||
let seed = SeedChain::root(99).derive(SeedDomain::Body, 1);
|
||||
|
||||
println!("\n=== T-1178 square-window production-fn bench, SINGLE THREAD (District spacing) ===\n");
|
||||
println!(
|
||||
"\n=== T-1178 square-window production-fn bench, SINGLE THREAD (District spacing) ===\n"
|
||||
);
|
||||
|
||||
// 8.3M cells single-thread is the ~12s-class case the design doc
|
||||
// extrapolated (§7); 330K/2.07M included for the full comparison table.
|
||||
@@ -496,7 +498,9 @@ fn bench_rect_canvas_district_spacing() {
|
||||
let seed = SeedChain::root(99).derive(SeedDomain::Body, 1);
|
||||
let min_wl_m = scale::DISTRICT_M as f64;
|
||||
|
||||
println!("\n=== T-1178 real 16:9 canvas bench, replica row-chunked loop (District spacing) ===");
|
||||
println!(
|
||||
"\n=== T-1178 real 16:9 canvas bench, replica row-chunked loop (District spacing) ==="
|
||||
);
|
||||
println!(" {}\n", rayon_threads_report());
|
||||
|
||||
let shapes: [(u32, u32, &str); 3] = [
|
||||
@@ -612,9 +616,8 @@ fn bench_block_and_tile_spacing_4096_cells() {
|
||||
for col in 0..grid_side {
|
||||
let wx = col as f64 * step_m;
|
||||
let wy = row as f64 * step_m;
|
||||
let prof = derive_at_metres(
|
||||
seed, "bench", ¶ms, &ta, wx, wy, &climate, cutoff_m, &[],
|
||||
);
|
||||
let prof =
|
||||
derive_at_metres(seed, "bench", ¶ms, &ta, wx, wy, &climate, cutoff_m, &[]);
|
||||
std::hint::black_box(prof.elev_q);
|
||||
}
|
||||
}
|
||||
@@ -657,7 +660,10 @@ fn bench_deep_step_realistic_canvas_83k_cells() {
|
||||
let rows = 216u32; // world metres on the smaller (2160px/10) axis
|
||||
let cols = 384u32; // world metres on the larger (3840px/10) axis
|
||||
let cells = (rows as u64) * (cols as u64);
|
||||
assert_eq!(cells, 82_944, "geometry must match the ticket's stated ~83K cells exactly");
|
||||
assert_eq!(
|
||||
cells, 82_944,
|
||||
"geometry must match the ticket's stated ~83K cells exactly"
|
||||
);
|
||||
|
||||
let step_m = 1.0_f64;
|
||||
let cutoff_m = 1.0_f64; // Nyquist-matched to 1 m spacing
|
||||
@@ -747,9 +753,18 @@ fn bench_block_cutoff_confirms_savings() {
|
||||
|
||||
for (label, cutoff_m) in [
|
||||
("uncut (cutoff=0)", 0.0),
|
||||
("cutoff=128m (Block's own floor, expect NO delta vs uncut)", block_m),
|
||||
("cutoff=2048m (District-coarse, truncates the VOXEL band, real savings)", district_m),
|
||||
("cutoff=204800m (Region-coarse, expect EVERY octave truncated)", region_m),
|
||||
(
|
||||
"cutoff=128m (Block's own floor, expect NO delta vs uncut)",
|
||||
block_m,
|
||||
),
|
||||
(
|
||||
"cutoff=2048m (District-coarse, truncates the VOXEL band, real savings)",
|
||||
district_m,
|
||||
),
|
||||
(
|
||||
"cutoff=204800m (Region-coarse, expect EVERY octave truncated)",
|
||||
region_m,
|
||||
),
|
||||
] {
|
||||
let n_cells = (grid_side * grid_side) as u64;
|
||||
let t0 = Instant::now();
|
||||
@@ -760,9 +775,8 @@ fn bench_block_cutoff_confirms_savings() {
|
||||
// effect from a spacing change.
|
||||
let wx = col as f64 * block_m;
|
||||
let wy = row as f64 * block_m;
|
||||
let prof = derive_at_metres(
|
||||
seed, "bench", ¶ms, &ta, wx, wy, &climate, cutoff_m, &[],
|
||||
);
|
||||
let prof =
|
||||
derive_at_metres(seed, "bench", ¶ms, &ta, wx, wy, &climate, cutoff_m, &[]);
|
||||
std::hint::black_box(prof.elev_q);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,9 +52,15 @@ fn synthetic_elevation(width: u32, height: u32) -> Vec<f32> {
|
||||
// priority-flood + overflow-search work is representative, not
|
||||
// a degenerate monotonic slope.
|
||||
let v = 0.5
|
||||
+ 0.25 * (x * std::f64::consts::TAU * 3.0).sin() * (y * std::f64::consts::TAU * 2.0).cos()
|
||||
+ 0.15 * (x * std::f64::consts::TAU * 7.3 + 1.7).sin() * (y * std::f64::consts::TAU * 5.1).sin()
|
||||
+ 0.10 * (x * std::f64::consts::TAU * 13.0).cos() * (y * std::f64::consts::TAU * 11.0 + 0.4).sin();
|
||||
+ 0.25
|
||||
* (x * std::f64::consts::TAU * 3.0).sin()
|
||||
* (y * std::f64::consts::TAU * 2.0).cos()
|
||||
+ 0.15
|
||||
* (x * std::f64::consts::TAU * 7.3 + 1.7).sin()
|
||||
* (y * std::f64::consts::TAU * 5.1).sin()
|
||||
+ 0.10
|
||||
* (x * std::f64::consts::TAU * 13.0).cos()
|
||||
* (y * std::f64::consts::TAU * 11.0 + 0.4).sin();
|
||||
v.clamp(0.0, 1.0) as f32
|
||||
})
|
||||
.collect()
|
||||
@@ -63,8 +69,7 @@ fn synthetic_elevation(width: u32, height: u32) -> Vec<f32> {
|
||||
fn gj1c_512x256() -> (Vec<f32>, f32) {
|
||||
let src = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../wiki/star-systems/GJ-1/bodies/GJ1c/heightmap.png");
|
||||
let heightmap =
|
||||
load_heightmap_png(&src, "GJ1c", 0.3).expect("decode committed GJ1c heightmap");
|
||||
let heightmap = load_heightmap_png(&src, "GJ1c", 0.3).expect("decode committed GJ1c heightmap");
|
||||
let small = heightmap.downsample(512, 256); // GRID_W x GRID_H, the real production working grid
|
||||
(small.data, small.sea_level)
|
||||
}
|
||||
@@ -86,11 +91,7 @@ fn run_and_report(label: &str, width: u32, height: u32, elevation: &[f32], sea_l
|
||||
let result_warm = solve(elevation, width, height, sea_level, default_climate());
|
||||
let warm = t1.elapsed();
|
||||
|
||||
let lake_cells: usize = result_cold
|
||||
.basins
|
||||
.iter()
|
||||
.map(|b| b.cells.len())
|
||||
.sum();
|
||||
let lake_cells: usize = result_cold.basins.iter().map(|b| b.cells.len()).sum();
|
||||
let carved_cells = result_cold.cliff_edge.iter().filter(|&&c| c).count();
|
||||
let endorheic_count = result_cold
|
||||
.basins
|
||||
@@ -141,7 +142,13 @@ fn run_and_report(label: &str, width: u32, height: u32, elevation: &[f32], sea_l
|
||||
#[ignore]
|
||||
fn bench_512x256_real_gj1c() {
|
||||
let (elev, sea_level) = gj1c_512x256();
|
||||
run_and_report("512x256 (real GJ1c, production working-grid size)", 512, 256, &elev, sea_level);
|
||||
run_and_report(
|
||||
"512x256 (real GJ1c, production working-grid size)",
|
||||
512,
|
||||
256,
|
||||
&elev,
|
||||
sea_level,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -183,13 +190,23 @@ fn determinism_at_330k_cells() {
|
||||
let elev = synthetic_elevation(w, h);
|
||||
let r1 = solve(&elev, w, h, 0.35, default_climate());
|
||||
let r2 = solve(&elev, w, h, 0.35, default_climate());
|
||||
assert_eq!(r1.filled_scaled, r2.filled_scaled, "filled surface must be byte-identical");
|
||||
assert_eq!(
|
||||
r1.filled_scaled, r2.filled_scaled,
|
||||
"filled surface must be byte-identical"
|
||||
);
|
||||
assert_eq!(
|
||||
r1.channel_depth_scaled, r2.channel_depth_scaled,
|
||||
"carved channel depth must be byte-identical"
|
||||
);
|
||||
assert_eq!(r1.cliff_edge, r2.cliff_edge, "cliff-edge flags must be byte-identical");
|
||||
assert_eq!(r1.basins.len(), r2.basins.len(), "basin count must be identical");
|
||||
assert_eq!(
|
||||
r1.cliff_edge, r2.cliff_edge,
|
||||
"cliff-edge flags must be byte-identical"
|
||||
);
|
||||
assert_eq!(
|
||||
r1.basins.len(),
|
||||
r2.basins.len(),
|
||||
"basin count must be identical"
|
||||
);
|
||||
for (a, b) in r1.basins.iter().zip(r2.basins.iter()) {
|
||||
assert_eq!(a.basin_id, b.basin_id);
|
||||
assert_eq!(a.cells, b.cells);
|
||||
@@ -231,7 +248,9 @@ fn bench_parallel_273_bodies_at_512x256() {
|
||||
|
||||
println!(
|
||||
"\n=== {body_count} bodies x 512x256, Rayon par_iter ({} threads available) ===",
|
||||
std::thread::available_parallelism().map(|n| n.get()).unwrap_or(0)
|
||||
std::thread::available_parallelism()
|
||||
.map(|n| n.get())
|
||||
.unwrap_or(0)
|
||||
);
|
||||
println!(
|
||||
" {:>9.2} ms total, {:>7.2} ms/body average, {total_basins} basins summed",
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
use std::io::Cursor;
|
||||
use std::time::Instant;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use settled_reach_server::atlas::believability::seed_to_u64;
|
||||
use settled_reach_server::atlas::district_profile::{
|
||||
derive_at_metres, BodyParams, ClimateConstants,
|
||||
@@ -41,7 +42,6 @@ use settled_reach_server::atlas::heightmap::load_heightmap_png;
|
||||
use settled_reach_server::atlas::layer_proxy::REGION_TEMP_NONE_DC;
|
||||
use settled_reach_server::atlas::scale;
|
||||
use settled_reach_server::seed::SeedChain;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// The six wire arrays `DistrictWindowLayer` ships today (layer_proxy.rs),
|
||||
/// derived at full canvas size rather than the 4,096-cell window cap.
|
||||
@@ -103,9 +103,8 @@ fn derive_canvas(
|
||||
.map(|col| {
|
||||
let wx = col as f64 * step_m;
|
||||
let wy = row as f64 * step_m;
|
||||
let prof = derive_at_metres(
|
||||
seed, "GJ338Bd", params, ta, wx, wy, &climate, 0.0, &[],
|
||||
);
|
||||
let prof =
|
||||
derive_at_metres(seed, "GJ338Bd", params, ta, wx, wy, &climate, 0.0, &[]);
|
||||
let temp_dc = match prof.temperature_c {
|
||||
Some(t) => ((t * 10.0).round() as i32)
|
||||
.clamp(i16::MIN as i32 + 1, i16::MAX as i32)
|
||||
@@ -265,7 +264,13 @@ fn encode_bitpacked(canvas: &WireCanvas) -> (Vec<u8>, std::time::Duration, std::
|
||||
let vegetation = unpack_bits(&decoded.vegetation_bits, 3, n);
|
||||
let glaciation = unpack_bits(&decoded.glaciation_bits, 3, n);
|
||||
let dec_time = t1.elapsed();
|
||||
std::hint::black_box((morphology.len(), elev_q.len(), moisture_q.len(), vegetation.len(), glaciation.len()));
|
||||
std::hint::black_box((
|
||||
morphology.len(),
|
||||
elev_q.len(),
|
||||
moisture_q.len(),
|
||||
vegetation.len(),
|
||||
glaciation.len(),
|
||||
));
|
||||
(bytes, enc_time, dec_time)
|
||||
}
|
||||
|
||||
@@ -404,7 +409,9 @@ fn png_encode_u8_plane(cols: u32, rows: u32, data: &[u8]) -> Vec<u8> {
|
||||
}
|
||||
|
||||
fn png_decode_u8_plane(bytes: &[u8]) -> Vec<u8> {
|
||||
let mut decoder = png::Decoder::new(Cursor::new(bytes)).read_info().expect("png read_info");
|
||||
let mut decoder = png::Decoder::new(Cursor::new(bytes))
|
||||
.read_info()
|
||||
.expect("png read_info");
|
||||
let mut buf = vec![0u8; decoder.output_buffer_size()];
|
||||
let frame = decoder.next_frame(&mut buf).expect("png next_frame");
|
||||
buf[..frame.buffer_size()].to_vec()
|
||||
@@ -436,7 +443,8 @@ fn encode_png(canvas: &WireCanvas) -> (Vec<u8>, std::time::Duration, std::time::
|
||||
let moisture_q_d = png_decode_u8_plane(&moisture_q_png);
|
||||
let vegetation_d = png_decode_u8_plane(&vegetation_png);
|
||||
let glaciation_d = png_decode_u8_plane(&glaciation_png);
|
||||
let temp_dc_d: Vec<i16> = rmp_serde::from_slice(&temp_dc_bytes).expect("rmp_serde decode temp_dc");
|
||||
let temp_dc_d: Vec<i16> =
|
||||
rmp_serde::from_slice(&temp_dc_bytes).expect("rmp_serde decode temp_dc");
|
||||
let dec_time = t1.elapsed();
|
||||
std::hint::black_box((
|
||||
morphology_d.len(),
|
||||
@@ -504,7 +512,8 @@ fn encode_png_of_bitpacked(
|
||||
let moisture_q_d = unpack_bits(&png_decode_u8_plane(&moisture_q_png), 7, n);
|
||||
let vegetation_d = unpack_bits(&png_decode_u8_plane(&vegetation_png), 3, n);
|
||||
let glaciation_d = unpack_bits(&png_decode_u8_plane(&glaciation_png), 3, n);
|
||||
let temp_dc_d: Vec<i16> = rmp_serde::from_slice(&temp_dc_bytes).expect("rmp_serde decode temp_dc");
|
||||
let temp_dc_d: Vec<i16> =
|
||||
rmp_serde::from_slice(&temp_dc_bytes).expect("rmp_serde decode temp_dc");
|
||||
let dec_time = t1.elapsed();
|
||||
std::hint::black_box((
|
||||
morphology_d.len(),
|
||||
@@ -559,14 +568,19 @@ fn run_canvas_report(
|
||||
rows: u32,
|
||||
label: &str,
|
||||
) {
|
||||
println!("\n=== T-1179 wire-size table: {label} = {} gridunits ===", cols as u64 * rows as u64);
|
||||
println!(
|
||||
"\n=== T-1179 wire-size table: {label} = {} gridunits ===",
|
||||
cols as u64 * rows as u64
|
||||
);
|
||||
|
||||
let (canvas, derive_time) = derive_canvas(seed, params, ta, cols, rows);
|
||||
println!(
|
||||
" derive: {:.2} ms ({} cells, {} Rayon threads available)\n",
|
||||
derive_time.as_secs_f64() * 1000.0,
|
||||
canvas.morphology.len(),
|
||||
std::thread::available_parallelism().map(|n| n.get()).unwrap_or(0)
|
||||
std::thread::available_parallelism()
|
||||
.map(|n| n.get())
|
||||
.unwrap_or(0)
|
||||
);
|
||||
|
||||
let (rmp_bytes, rmp_enc, rmp_dec) = encode_rmp(&canvas);
|
||||
|
||||
Reference in New Issue
Block a user