1/// Difficulty, measured from the shape of the state space.2///3/// The tempting metrics are the wrong ones. Optimal solution length correlates4/// only weakly with how long a person actually takes (Jarusek and Pelanek put5/// it near rho = 0.47 on Sokoban), and solver effort — nodes expanded — is6/// worse still, near rho = 0.2. Machine difficulty is not human difficulty.7///8/// What does predict it is a walker: someone who mostly steps toward the goal9/// but strays with probability eps, because the board rarely tells you which10/// way is downhill. Its expected hitting time absorbs branching factor,11/// corridor width and the quality of the local gradient in one number. A long12/// solution down a wide, well-signposted corridor stays easy; a short one that13/// makes you retreat does not.14///15/// We combine that with the count of counter-intuitive steps — moves on the16/// optimal path that carry the goal block *away* from its exit, which is the17/// move nobody finds.18///19/// The walk itself happens where the distance field is, which is the survey20/// worker: eighty thousand states is not something to hand across a boundary21/// forty times a rung. What lives here is the part that is a judgement rather22/// than a traversal — how the four measurements are weighed, and where the five23/// marks fall — so that changing the definition of difficulty is a change to24/// this file and not to a worker.25import gleam/float26import gleam/int2728pub type Metrics {29 Metrics(30 /// Optimal solution length, in unit steps.31 optimal: Int,32 /// Expected steps for an eps-greedy walker to arrive.33 wander: Float,34 /// Moves on the optimal path that carry the goal block away from its exit.35 retreats: Int,36 /// Mean legal moves available along that path.37 branch: Float,38 score: Float,39 )40}4142/// How strayed a walker is, and how many rollouts are averaged. Passed to the43/// worker rather than written there, because they are part of what difficulty44/// is defined to mean.45pub const stray = 0.154647pub const rollouts = 404849/// The weighing. Logarithms on the three unbounded measurements, because each50/// of them is worth much less at the top of its range than at the bottom: the51/// difference between a 5-step board and a 20-step one is felt, and the52/// difference between 150 and 165 is not.53pub fn score(54 optimal optimal: Int,55 wander wander: Float,56 retreats retreats: Int,57 branch branch: Float,58) -> Float {59 let retreat_rate = int.to_float(retreats) /. int.to_float(int.max(1, optimal))60 0.1561 *. log1p(int.to_float(optimal))62 +. 0.463 *. log1p(wander)64 +. 0.665 *. retreat_rate66 +. 0.167 *. log1p(branch)68}6970fn log1p(value: Float) -> Float {71 case float.logarithm(1.0 +. float.max(0.0, value)) {72 Ok(result) -> result73 Error(_) -> 0.074 }75}7677/// Five marks, so a plate can wear its difficulty without a number.78///79/// Calibrated against the observed range across all the families, roughly 1.780/// for an opening plate to 3.2 for the deepest one in the atlas.81pub fn grade(score: Float) -> Int {82 int.max(1, int.min(5, 1 + float.round({ score -. 1.65 } *. 2.6)))83}