feat(game): plumb ConvergenceOptions through to run_chain
Game and OwnedGame gain a convergence: ConvergenceOptions field set at
construction. Game::{ranked,scored} forward options.convergence into
OwnedGame::{new,new_scored} (previously dropped on the floor).
{ranked,scored}_with_arena take it as a parameter. run_chain reads
self.convergence.{epsilon, max_iter, alpha} instead of hardcoded
1e-6 / 10 / undamped. DiffFactor::propagate gains an alpha parameter
and dispatches into Trunc/MarginFactor::propagate_with_alpha.
In-tree callsites in src/time_slice.rs and src/history.rs pass
ConvergenceOptions::default(). Pre-existing T2 fallout in tests,
benches, and the atp example (struct literals missing the new alpha
field) is fixed by adding alpha: 1.0 so the workspace builds clean.
Default alpha is 1.0, so all 96 lib + 27 integration test goldens
remain bit-equal.
This commit is contained in:
@@ -51,6 +51,7 @@ fn build_history_1v1(
|
||||
.convergence(ConvergenceOptions {
|
||||
max_iter: 30,
|
||||
epsilon: 1e-6,
|
||||
alpha: 1.0,
|
||||
})
|
||||
.build();
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ fn main() {
|
||||
.convergence(trueskill_tt::ConvergenceOptions {
|
||||
max_iter: 10,
|
||||
epsilon: 0.01,
|
||||
alpha: 1.0,
|
||||
})
|
||||
.build();
|
||||
|
||||
|
||||
+94
-16
@@ -44,11 +44,14 @@ impl DiffFactor {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn propagate(&mut self, vars: &mut crate::factor::VarStore) -> (f64, f64) {
|
||||
use crate::factor::Factor;
|
||||
pub(crate) fn propagate(
|
||||
&mut self,
|
||||
vars: &mut crate::factor::VarStore,
|
||||
alpha: f64,
|
||||
) -> (f64, f64) {
|
||||
match self {
|
||||
Self::Trunc(f) => f.propagate(vars),
|
||||
Self::Margin(f) => f.propagate(vars),
|
||||
Self::Trunc(f) => f.propagate_with_alpha(vars, alpha),
|
||||
Self::Margin(f) => f.propagate_with_alpha(vars, alpha),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -87,6 +90,7 @@ pub struct OwnedGame<T: Time, D: Drift<T>> {
|
||||
result: Vec<f64>,
|
||||
weights: Vec<Vec<f64>>,
|
||||
p_draw: f64,
|
||||
pub(crate) convergence: crate::ConvergenceOptions,
|
||||
pub(crate) likelihoods: Vec<Vec<Gaussian>>,
|
||||
pub(crate) evidence: f64,
|
||||
}
|
||||
@@ -97,9 +101,17 @@ impl<T: Time, D: Drift<T>> OwnedGame<T, D> {
|
||||
result: Vec<f64>,
|
||||
weights: Vec<Vec<f64>>,
|
||||
p_draw: f64,
|
||||
convergence: crate::ConvergenceOptions,
|
||||
) -> Self {
|
||||
let mut arena = ScratchArena::new();
|
||||
let g = Game::ranked_with_arena(teams.clone(), &result, &weights, p_draw, &mut arena);
|
||||
let g = Game::ranked_with_arena(
|
||||
teams.clone(),
|
||||
&result,
|
||||
&weights,
|
||||
p_draw,
|
||||
convergence,
|
||||
&mut arena,
|
||||
);
|
||||
let likelihoods = g.likelihoods;
|
||||
let evidence = g.evidence;
|
||||
Self {
|
||||
@@ -107,6 +119,7 @@ impl<T: Time, D: Drift<T>> OwnedGame<T, D> {
|
||||
result,
|
||||
weights,
|
||||
p_draw,
|
||||
convergence,
|
||||
likelihoods,
|
||||
evidence,
|
||||
}
|
||||
@@ -117,9 +130,17 @@ impl<T: Time, D: Drift<T>> OwnedGame<T, D> {
|
||||
scores: Vec<f64>,
|
||||
weights: Vec<Vec<f64>>,
|
||||
score_sigma: f64,
|
||||
convergence: crate::ConvergenceOptions,
|
||||
) -> Self {
|
||||
let mut arena = ScratchArena::new();
|
||||
let g = Game::scored_with_arena(teams.clone(), &scores, &weights, score_sigma, &mut arena);
|
||||
let g = Game::scored_with_arena(
|
||||
teams.clone(),
|
||||
&scores,
|
||||
&weights,
|
||||
score_sigma,
|
||||
convergence,
|
||||
&mut arena,
|
||||
);
|
||||
let likelihoods = g.likelihoods;
|
||||
let evidence = g.evidence;
|
||||
Self {
|
||||
@@ -127,6 +148,7 @@ impl<T: Time, D: Drift<T>> OwnedGame<T, D> {
|
||||
result: scores,
|
||||
weights,
|
||||
p_draw: 0.0,
|
||||
convergence,
|
||||
likelihoods,
|
||||
evidence,
|
||||
}
|
||||
@@ -151,6 +173,7 @@ pub struct Game<'a, T: Time = i64, D: Drift<T> = crate::drift::ConstantDrift> {
|
||||
result: &'a [f64],
|
||||
weights: &'a [Vec<f64>],
|
||||
p_draw: f64,
|
||||
pub(crate) convergence: crate::ConvergenceOptions,
|
||||
pub(crate) likelihoods: Vec<Vec<Gaussian>>,
|
||||
pub(crate) evidence: f64,
|
||||
}
|
||||
@@ -161,6 +184,7 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
|
||||
result: &'a [f64],
|
||||
weights: &'a [Vec<f64>],
|
||||
p_draw: f64,
|
||||
convergence: crate::ConvergenceOptions,
|
||||
arena: &mut ScratchArena,
|
||||
) -> Self {
|
||||
debug_assert!(
|
||||
@@ -186,12 +210,17 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
|
||||
},
|
||||
"draw must be > 0.0 if there are teams with draw"
|
||||
);
|
||||
debug_assert!(
|
||||
convergence.alpha > 0.0 && convergence.alpha <= 1.0,
|
||||
"convergence alpha must be in (0.0, 1.0]"
|
||||
);
|
||||
|
||||
let mut this = Self {
|
||||
teams,
|
||||
result,
|
||||
weights,
|
||||
p_draw,
|
||||
convergence,
|
||||
likelihoods: Vec::new(),
|
||||
evidence: 0.0,
|
||||
};
|
||||
@@ -205,6 +234,7 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
|
||||
scores: &'a [f64],
|
||||
weights: &'a [Vec<f64>],
|
||||
score_sigma: f64,
|
||||
convergence: crate::ConvergenceOptions,
|
||||
arena: &mut ScratchArena,
|
||||
) -> Self {
|
||||
debug_assert!(
|
||||
@@ -219,12 +249,17 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
|
||||
"weights must have the same dimensions as teams"
|
||||
);
|
||||
debug_assert!(score_sigma > 0.0, "score_sigma must be positive");
|
||||
debug_assert!(
|
||||
convergence.alpha > 0.0 && convergence.alpha <= 1.0,
|
||||
"convergence alpha must be in (0.0, 1.0]"
|
||||
);
|
||||
|
||||
let mut this = Self {
|
||||
teams,
|
||||
result: scores,
|
||||
weights,
|
||||
p_draw: 0.0,
|
||||
convergence,
|
||||
likelihoods: Vec::new(),
|
||||
evidence: 0.0,
|
||||
};
|
||||
@@ -239,6 +274,10 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
|
||||
{
|
||||
arena.reset();
|
||||
|
||||
let alpha = self.convergence.alpha;
|
||||
let epsilon = self.convergence.epsilon;
|
||||
let max_iter = self.convergence.max_iter;
|
||||
|
||||
let n_teams = self.teams.len();
|
||||
|
||||
arena.sort_buf.extend(0..n_teams);
|
||||
@@ -267,7 +306,7 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
|
||||
let mut step = (f64::INFINITY, f64::INFINITY);
|
||||
let mut iter = 0;
|
||||
|
||||
while tuple_gt(step, 1e-6) && iter < 10 {
|
||||
while tuple_gt(step, epsilon) && iter < max_iter {
|
||||
step = (0.0_f64, 0.0_f64);
|
||||
|
||||
for (e, lf) in links[..n_diffs.saturating_sub(1)].iter_mut().enumerate() {
|
||||
@@ -275,7 +314,7 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
|
||||
let pl = arena.team_prior[e + 1] * arena.lhood_win[e + 1];
|
||||
let raw = pw - pl;
|
||||
arena.vars.set(lf.diff(), raw * lf.msg());
|
||||
let d = lf.propagate(&mut arena.vars);
|
||||
let d = lf.propagate(&mut arena.vars, alpha);
|
||||
step = tuple_max(step, d);
|
||||
|
||||
let new_ll = pw - lf.msg();
|
||||
@@ -289,7 +328,7 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
|
||||
let pl = arena.team_prior[e + 1] * arena.lhood_win[e + 1];
|
||||
let raw = pw - pl;
|
||||
arena.vars.set(lf.diff(), raw * lf.msg());
|
||||
let d = lf.propagate(&mut arena.vars);
|
||||
let d = lf.propagate(&mut arena.vars, alpha);
|
||||
step = tuple_max(step, d);
|
||||
|
||||
let new_lw = pl + lf.msg();
|
||||
@@ -305,7 +344,7 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
|
||||
let raw = (arena.team_prior[0] * arena.lhood_lose[0])
|
||||
- (arena.team_prior[1] * arena.lhood_win[1]);
|
||||
arena.vars.set(links[0].diff(), raw * links[0].msg());
|
||||
links[0].propagate(&mut arena.vars);
|
||||
links[0].propagate(&mut arena.vars, alpha);
|
||||
}
|
||||
|
||||
// Boundary updates: close the chain at both ends.
|
||||
@@ -429,7 +468,13 @@ impl<T: Time, D: Drift<T>> Game<'_, T, D> {
|
||||
let teams_owned: Vec<Vec<Rating<T, D>>> = teams.iter().map(|t| t.to_vec()).collect();
|
||||
let weights: Vec<Vec<f64>> = teams.iter().map(|t| vec![1.0; t.len()]).collect();
|
||||
|
||||
Ok(OwnedGame::new(teams_owned, result, weights, options.p_draw))
|
||||
Ok(OwnedGame::new(
|
||||
teams_owned,
|
||||
result,
|
||||
weights,
|
||||
options.p_draw,
|
||||
options.convergence,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn scored(
|
||||
@@ -465,6 +510,7 @@ impl<T: Time, D: Drift<T>> Game<'_, T, D> {
|
||||
scores,
|
||||
weights,
|
||||
options.score_sigma,
|
||||
options.convergence,
|
||||
))
|
||||
}
|
||||
|
||||
@@ -526,6 +572,7 @@ mod tests {
|
||||
&[0.0, 1.0],
|
||||
&w,
|
||||
0.0,
|
||||
crate::ConvergenceOptions::default(),
|
||||
&mut ScratchArena::new(),
|
||||
);
|
||||
let p = g.posteriors();
|
||||
@@ -553,6 +600,7 @@ mod tests {
|
||||
&[0.0, 1.0],
|
||||
&w,
|
||||
0.0,
|
||||
crate::ConvergenceOptions::default(),
|
||||
&mut ScratchArena::new(),
|
||||
);
|
||||
let p = g.posteriors();
|
||||
@@ -572,6 +620,7 @@ mod tests {
|
||||
&[0.0, 1.0],
|
||||
&w,
|
||||
0.0,
|
||||
crate::ConvergenceOptions::default(),
|
||||
&mut ScratchArena::new(),
|
||||
);
|
||||
|
||||
@@ -605,6 +654,7 @@ mod tests {
|
||||
&[1.0, 2.0, 0.0],
|
||||
&w,
|
||||
0.0,
|
||||
crate::ConvergenceOptions::default(),
|
||||
&mut ScratchArena::new(),
|
||||
);
|
||||
let p = g.posteriors();
|
||||
@@ -621,6 +671,7 @@ mod tests {
|
||||
&[2.0, 1.0, 0.0],
|
||||
&w,
|
||||
0.0,
|
||||
crate::ConvergenceOptions::default(),
|
||||
&mut ScratchArena::new(),
|
||||
);
|
||||
let p = g.posteriors();
|
||||
@@ -632,7 +683,14 @@ mod tests {
|
||||
assert_ulps_eq!(b, Gaussian::from_ms(25.000000, 6.238469), epsilon = 1e-6);
|
||||
|
||||
let w = [vec![1.0], vec![1.0], vec![1.0]];
|
||||
let g = Game::ranked_with_arena(teams, &[1.0, 2.0, 0.0], &w, 0.5, &mut ScratchArena::new());
|
||||
let g = Game::ranked_with_arena(
|
||||
teams,
|
||||
&[1.0, 2.0, 0.0],
|
||||
&w,
|
||||
0.5,
|
||||
crate::ConvergenceOptions::default(),
|
||||
&mut ScratchArena::new(),
|
||||
);
|
||||
let p = g.posteriors();
|
||||
|
||||
let a = p[0][0];
|
||||
@@ -664,6 +722,7 @@ mod tests {
|
||||
&[0.0, 0.0],
|
||||
&w,
|
||||
0.25,
|
||||
crate::ConvergenceOptions::default(),
|
||||
&mut ScratchArena::new(),
|
||||
);
|
||||
let p = g.posteriors();
|
||||
@@ -691,6 +750,7 @@ mod tests {
|
||||
&[0.0, 0.0],
|
||||
&w,
|
||||
0.25,
|
||||
crate::ConvergenceOptions::default(),
|
||||
&mut ScratchArena::new(),
|
||||
);
|
||||
let p = g.posteriors();
|
||||
@@ -726,6 +786,7 @@ mod tests {
|
||||
&[0.0, 0.0, 0.0],
|
||||
&w,
|
||||
0.25,
|
||||
crate::ConvergenceOptions::default(),
|
||||
&mut ScratchArena::new(),
|
||||
);
|
||||
let p = g.posteriors();
|
||||
@@ -762,6 +823,7 @@ mod tests {
|
||||
&[0.0, 0.0, 0.0],
|
||||
&w,
|
||||
0.25,
|
||||
crate::ConvergenceOptions::default(),
|
||||
&mut ScratchArena::new(),
|
||||
);
|
||||
let p = g.posteriors();
|
||||
@@ -813,6 +875,7 @@ mod tests {
|
||||
&[1.0, 0.0, 0.0],
|
||||
&w,
|
||||
0.25,
|
||||
crate::ConvergenceOptions::default(),
|
||||
&mut ScratchArena::new(),
|
||||
);
|
||||
let p = g.posteriors();
|
||||
@@ -846,6 +909,7 @@ mod tests {
|
||||
&[1.0, 0.0],
|
||||
&w,
|
||||
0.0,
|
||||
crate::ConvergenceOptions::default(),
|
||||
&mut ScratchArena::new(),
|
||||
);
|
||||
let p = g.posteriors();
|
||||
@@ -870,6 +934,7 @@ mod tests {
|
||||
&[1.0, 0.0],
|
||||
&w,
|
||||
0.0,
|
||||
crate::ConvergenceOptions::default(),
|
||||
&mut ScratchArena::new(),
|
||||
);
|
||||
let p = g.posteriors();
|
||||
@@ -894,6 +959,7 @@ mod tests {
|
||||
&[1.0, 0.0],
|
||||
&w,
|
||||
0.0,
|
||||
crate::ConvergenceOptions::default(),
|
||||
&mut ScratchArena::new(),
|
||||
);
|
||||
let p = g.posteriors();
|
||||
@@ -921,6 +987,7 @@ mod tests {
|
||||
&[1.0, 0.0],
|
||||
&w,
|
||||
0.0,
|
||||
crate::ConvergenceOptions::default(),
|
||||
&mut ScratchArena::new(),
|
||||
);
|
||||
let p = g.posteriors();
|
||||
@@ -948,6 +1015,7 @@ mod tests {
|
||||
&[1.0, 0.0],
|
||||
&w,
|
||||
0.0,
|
||||
crate::ConvergenceOptions::default(),
|
||||
&mut ScratchArena::new(),
|
||||
);
|
||||
let p = g.posteriors();
|
||||
@@ -967,8 +1035,8 @@ mod tests {
|
||||
let mut t = DiffFactor::Trunc(TruncFactor::new(dt, 0.0, false));
|
||||
let mut m = DiffFactor::Margin(MarginFactor::new(dm, 5.0, 1.0));
|
||||
|
||||
let _ = t.propagate(&mut vars);
|
||||
let _ = m.propagate(&mut vars);
|
||||
let _ = t.propagate(&mut vars, 1.0);
|
||||
let _ = m.propagate(&mut vars, 1.0);
|
||||
|
||||
// Smoke: both diffs got written; their msgs are non-N_INF.
|
||||
assert!(t.msg().pi() > 0.0);
|
||||
@@ -989,7 +1057,11 @@ mod tests {
|
||||
let weights = [vec![1.0], vec![1.0]];
|
||||
let mut arena = ScratchArena::new();
|
||||
let g = Game::scored_with_arena(
|
||||
teams, &result, &weights, 1.0, // score_sigma
|
||||
teams,
|
||||
&result,
|
||||
&weights,
|
||||
1.0,
|
||||
crate::ConvergenceOptions::default(),
|
||||
&mut arena,
|
||||
);
|
||||
let p = g.posteriors();
|
||||
@@ -1008,7 +1080,8 @@ mod tests {
|
||||
vec![vec![prior], vec![prior]],
|
||||
&result,
|
||||
&weights,
|
||||
0.1, // tighter score_sigma
|
||||
0.1,
|
||||
crate::ConvergenceOptions::default(),
|
||||
&mut arena2,
|
||||
);
|
||||
let p_tight = g_tight.posteriors();
|
||||
@@ -1116,6 +1189,7 @@ mod tests {
|
||||
&[1.0, 0.0],
|
||||
&w,
|
||||
0.0,
|
||||
crate::ConvergenceOptions::default(),
|
||||
&mut ScratchArena::new(),
|
||||
);
|
||||
let p = g.posteriors();
|
||||
@@ -1150,6 +1224,7 @@ mod tests {
|
||||
&[1.0, 0.0],
|
||||
&w,
|
||||
0.0,
|
||||
crate::ConvergenceOptions::default(),
|
||||
&mut ScratchArena::new(),
|
||||
);
|
||||
let p = g.posteriors();
|
||||
@@ -1184,6 +1259,7 @@ mod tests {
|
||||
&[1.0, 0.0],
|
||||
&w,
|
||||
0.0,
|
||||
crate::ConvergenceOptions::default(),
|
||||
&mut ScratchArena::new(),
|
||||
);
|
||||
let p = g.posteriors();
|
||||
@@ -1222,6 +1298,7 @@ mod tests {
|
||||
&[1.0, 0.0],
|
||||
&w,
|
||||
0.0,
|
||||
crate::ConvergenceOptions::default(),
|
||||
&mut ScratchArena::new(),
|
||||
);
|
||||
let post_2vs1 = g.posteriors();
|
||||
@@ -1235,6 +1312,7 @@ mod tests {
|
||||
&[1.0, 0.0],
|
||||
&w,
|
||||
0.0,
|
||||
crate::ConvergenceOptions::default(),
|
||||
&mut ScratchArena::new(),
|
||||
);
|
||||
let p = g.posteriors();
|
||||
|
||||
@@ -838,6 +838,7 @@ mod tests {
|
||||
&[0.0, 1.0],
|
||||
&w,
|
||||
P_DRAW,
|
||||
crate::ConvergenceOptions::default(),
|
||||
&mut ScratchArena::new(),
|
||||
)
|
||||
.posteriors();
|
||||
|
||||
+38
-16
@@ -138,12 +138,22 @@ impl Event {
|
||||
let teams = self.within_priors(false, false, skills, agents);
|
||||
let result = self.outputs();
|
||||
let g = match self.kind {
|
||||
EventKind::Ranked => {
|
||||
Game::ranked_with_arena(teams, &result, &self.weights, p_draw, arena)
|
||||
}
|
||||
EventKind::Scored { score_sigma } => {
|
||||
Game::scored_with_arena(teams, &result, &self.weights, score_sigma, arena)
|
||||
}
|
||||
EventKind::Ranked => Game::ranked_with_arena(
|
||||
teams,
|
||||
&result,
|
||||
&self.weights,
|
||||
p_draw,
|
||||
crate::ConvergenceOptions::default(),
|
||||
arena,
|
||||
),
|
||||
EventKind::Scored { score_sigma } => Game::scored_with_arena(
|
||||
teams,
|
||||
&result,
|
||||
&self.weights,
|
||||
score_sigma,
|
||||
crate::ConvergenceOptions::default(),
|
||||
arena,
|
||||
),
|
||||
};
|
||||
|
||||
for (t, team) in self.teams.iter_mut().enumerate() {
|
||||
@@ -322,6 +332,7 @@ impl<T: Time> TimeSlice<T> {
|
||||
&result,
|
||||
&event.weights,
|
||||
self.p_draw,
|
||||
crate::ConvergenceOptions::default(),
|
||||
&mut self.arena,
|
||||
),
|
||||
EventKind::Scored { score_sigma } => Game::scored_with_arena(
|
||||
@@ -329,6 +340,7 @@ impl<T: Time> TimeSlice<T> {
|
||||
&result,
|
||||
&event.weights,
|
||||
score_sigma,
|
||||
crate::ConvergenceOptions::default(),
|
||||
&mut self.arena,
|
||||
),
|
||||
};
|
||||
@@ -504,16 +516,26 @@ impl<T: Time> TimeSlice<T> {
|
||||
let teams = event.within_priors(online, forward, &self.skills, agents);
|
||||
let result = event.outputs();
|
||||
match event.kind {
|
||||
EventKind::Ranked => {
|
||||
Game::ranked_with_arena(teams, &result, &event.weights, self.p_draw, arena)
|
||||
.evidence
|
||||
.ln()
|
||||
}
|
||||
EventKind::Scored { score_sigma } => {
|
||||
Game::scored_with_arena(teams, &result, &event.weights, score_sigma, arena)
|
||||
.evidence
|
||||
.ln()
|
||||
}
|
||||
EventKind::Ranked => Game::ranked_with_arena(
|
||||
teams,
|
||||
&result,
|
||||
&event.weights,
|
||||
self.p_draw,
|
||||
crate::ConvergenceOptions::default(),
|
||||
arena,
|
||||
)
|
||||
.evidence
|
||||
.ln(),
|
||||
EventKind::Scored { score_sigma } => Game::scored_with_arena(
|
||||
teams,
|
||||
&result,
|
||||
&event.weights,
|
||||
score_sigma,
|
||||
crate::ConvergenceOptions::default(),
|
||||
arena,
|
||||
)
|
||||
.evidence
|
||||
.ln(),
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ fn add_events_bulk_via_iter() {
|
||||
.convergence(ConvergenceOptions {
|
||||
max_iter: 30,
|
||||
epsilon: 1e-6,
|
||||
alpha: 1.0,
|
||||
})
|
||||
.build();
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ fn build_and_converge(seed: u64) -> Vec<(i64, trueskill_tt::Gaussian)> {
|
||||
.convergence(ConvergenceOptions {
|
||||
max_iter: 30,
|
||||
epsilon: 1e-6,
|
||||
alpha: 1.0,
|
||||
})
|
||||
.build();
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ fn record_winner_builds_history() {
|
||||
.convergence(ConvergenceOptions {
|
||||
max_iter: 30,
|
||||
epsilon: 1e-6,
|
||||
alpha: 1.0,
|
||||
})
|
||||
.build();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user