approaches/value-policy-learning/klein-friedmann-linear-q/rust/src/features.rsRust274 lines · 9.4 KB
// Bit-faithful port of Drop7QLearning.py's Drop7FeatureExtractor (upstream
// lines 85-209): the six features Klein & Friedmann kept, computed the way
// their code computes them rather than the way the report describes them.
//
// Upstream conventions, and how they map here:
//   * `free_loc[col]` is the column height; the drop lands at row y = height,
//     rows counted from the bottom.  `PublicView::cell(x, y)` uses the same
//     coordinates.
//   * Gray discs: upstream 9 = untouched, 8 = cracked; this engine is the
//     other way round (8 = untouched, 9 = cracked).  `cover_weight` maps both
//     to the upstream weights (untouched 1, cracked 2).
//   * `groups_of_elements[x][y][0]` is the length of the contiguous occupied
//     run in row y through (x, y).  Upstream tracks it incrementally; the
//     bookkeeping is occupancy-based and therefore equals the run length
//     computed directly from the board, which is what `row_run` does.
//   * A feature is a (key, value) pair appended to a list; keys absent from
//     the list are NOT updated by the ridge term, so presence matters even
//     when the value is 0.  `Features` therefore carries a presence mask.

use drop7_rs::board::{BOARD_SIZE, CRACKED, SOLID};

use crate::view::PublicView;

pub const FEATURE_COUNT: usize = 6;
pub const MIN_EQ_ELEM: usize = 0;
pub const ROW_DETS: usize = 1;
pub const COL_DETS: usize = 2;
pub const MAX_EQ_ELEM: usize = 3;
pub const ONE_DETS: usize = 4;
pub const ELEM_DET: usize = 5;

/// Upstream dictionary keys, in the order the upstream list is built (which is
/// also the order its Q sum is accumulated in).
pub const FEATURE_NAMES: [&str; FEATURE_COUNT] = [
    "min_eq_elem_True",
    "row_dets",
    "col_dets",
    "max_eq_elem",
    "1_dets",
    "elem_det",
];

#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct Features {
    pub present: [bool; FEATURE_COUNT],
    pub values: [i32; FEATURE_COUNT],
}

impl Features {
    #[inline(always)]
    fn set(&mut self, index: usize, value: i32) {
        self.present[index] = true;
        self.values[index] = value;
    }

    /// Presence mask as six '0'/'1' characters, the parity-export format.
    pub fn mask_string(&self) -> String {
        self.present.iter().map(|&p| if p { '1' } else { '0' }).collect()
    }
}

/// Upstream `next_to_disc` weights: a blank (untouched) neighbour counts 1, a
/// cracked neighbour counts 2, anything else 0.
#[inline(always)]
fn cover_weight(cell: u8) -> i32 {
    match cell {
        SOLID => 1,
        CRACKED => 2,
        _ => 0,
    }
}

/// Upstream `next_to_disc(state, x, y)`: cover weight summed over the left,
/// lower, upper and right neighbours of (x, y); 0 above the board.
#[inline]
pub fn next_to_disc(view: &PublicView, x: usize, y: usize) -> i32 {
    if y >= BOARD_SIZE {
        return 0;
    }
    let mut total = 0;
    if x > 0 {
        total += cover_weight(view.cell(x - 1, y));
    }
    if y > 0 {
        total += cover_weight(view.cell(x, y - 1));
    }
    if y < BOARD_SIZE - 1 {
        total += cover_weight(view.cell(x, y + 1));
    }
    if x < BOARD_SIZE - 1 {
        total += cover_weight(view.cell(x + 1, y));
    }
    total
}

/// Length of the contiguous occupied run in row `y` through column `c`; 0 when
/// (c, y) is empty.  Equals upstream `groups_of_elements[c][y][0]`.
#[inline]
fn row_run(view: &PublicView, c: usize, y: usize) -> usize {
    if view.cell(c, y) == 0 {
        return 0;
    }
    let mut left = c;
    while left > 0 && view.cell(left - 1, y) != 0 {
        left -= 1;
    }
    let mut right = c;
    while right < BOARD_SIZE - 1 && view.cell(right + 1, y) != 0 {
        right += 1;
    }
    right - left + 1
}

/// Upstream `get_new_group_size(state, x, y)` minus its unused third return:
/// the row run the dropped disc would join at (x, y), and the number of discs
/// already in that merged run whose value equals the merged length, each
/// weighted 1 plus its gray adjacency.
#[inline]
fn new_group(view: &PublicView, x: usize, y: usize) -> (usize, i32) {
    if y >= BOARD_SIZE {
        return (0, 0);
    }
    let left = if x > 0 { row_run(view, x - 1, y) } else { 0 };
    let right = if x < BOARD_SIZE - 1 { row_run(view, x + 1, y) } else { 0 };
    let group_size = 1 + left + right;
    let start = x - left;
    let mut detonations = 0;
    for i in 0..group_size {
        let c = start + i;
        if view.cell(c, y) as usize == group_size {
            detonations += next_to_disc(view, c, y) + 1;
        }
    }
    (group_size, detonations)
}

/// The six features of dropping the visible disc into `action`.  `action` may
/// be a full column: upstream evaluates those too (and may choose them), so
/// the learner's bootstrap max needs them.
pub fn features(view: &PublicView, action: usize) -> Features {
    let y = view.heights[action] as usize;
    let mut max_height = 0usize;
    let mut min_height = BOARD_SIZE + 1;
    for col in 0..BOARD_SIZE {
        let h = view.heights[col] as usize;
        if h > max_height {
            max_height = h;
        }
        if h < min_height {
            min_height = h;
        }
    }
    let max_count = view
        .heights
        .iter()
        .filter(|&&h| h as usize == max_height)
        .count();

    let (group_size, row_dets) = new_group(view, action, y);
    let disc = view.next_disc as usize;
    let elem_det = group_size == disc || y + 1 == disc;

    let mut col_dets = 0;
    if y < BOARD_SIZE {
        for row in 0..y {
            if view.cell(action, row) as usize == y + 1 {
                col_dets += next_to_disc(view, action, row) + 1;
            }
        }
        // Upstream adds the landing cell's gray adjacency unconditionally.
        col_dets += next_to_disc(view, action, y);
    }

    let mut f = Features::default();
    if min_height == y {
        f.set(MIN_EQ_ELEM, 1);
    }
    f.set(ROW_DETS, row_dets);
    f.set(COL_DETS, col_dets);
    if max_height == y && max_count <= 2 {
        // Upstream appends ('max_eq_elem', col_dets), not 1.
        f.set(MAX_EQ_ELEM, col_dets);
    }
    if view.next_disc == 1 && elem_det {
        f.set(ONE_DETS, 1 + next_to_disc(view, action, y));
    }
    if elem_det {
        f.set(ELEM_DET, 1 + next_to_disc(view, action, y));
    }
    f
}

#[cfg(test)]
mod tests {
    use super::*;
    use drop7_rs::board::Board;

    fn view(text: &str, next: u8) -> PublicView {
        PublicView::new(Board::from_serialized(text).unwrap(), next, 5)
    }

    #[test]
    fn opening_position_features() {
        // Bottom row all solid gray, next disc 3: every column lands at height
        // 1 on a gray disc (col_dets = 1 from the lower neighbour), the row
        // run would be 1, no detonation, every column is both lowest and
        // tallest with seven-way ties (so max_eq_elem is absent).
        let v = view("0000000000000000000000000000000000000000008888888", 3);
        for a in 0..7 {
            let f = features(&v, a);
            assert_eq!(f.present, [true, true, true, false, false, false]);
            assert_eq!(f.values[MIN_EQ_ELEM], 1);
            assert_eq!(f.values[ROW_DETS], 0);
            assert_eq!(f.values[COL_DETS], 1);
        }
    }

    #[test]
    fn dropped_disc_detonating_by_column_height() {
        // Column 3 holds a gray at the bottom and a 7 above it; dropping a 3
        // makes height 3, so the disc detonates (elem_det present), and the
        // gray adjacency of the landing cell is 0 (its lower neighbour is 7).
        let mut text = String::from("0000000000000000000000000000000000000000008888888");
        // Row 5 (second from bottom), column 3 = '7'.
        let idx = 5 * 7 + 3;
        text.replace_range(idx..idx + 1, "7");
        let v = view(&text, 3);
        let f = features(&v, 3);
        assert!(f.present[ELEM_DET]);
        assert_eq!(f.values[ELEM_DET], 1);
        assert!(!f.present[ONE_DETS]);
        // Column 3 is the unique tallest: max_eq_elem present with col_dets.
        assert!(f.present[MAX_EQ_ELEM]);
        assert!(!f.present[MIN_EQ_ELEM]);
    }

    #[test]
    fn row_run_merges_left_and_right() {
        // Bottom row: cells 0..2 numbered 5, cell 3 empty, cells 4..6 gray.
        // Dropping into column 3 at height 0 joins a run of 7.
        let v = view("0000000000000000000000000000000000000000005550888", 7);
        let (size, dets) = new_group(&v, 3, 0);
        assert_eq!(size, 7);
        assert_eq!(dets, 0);
        let f = features(&v, 3);
        assert!(f.present[ELEM_DET], "a 7 completing a seven-run detonates");
    }

    #[test]
    fn cracked_neighbour_counts_double() {
        // Bottom row: column 2 cracked (9), column 4 solid (8); land in column 3.
        let v = view("0000000000000000000000000000000000000000000090800", 2);
        assert_eq!(next_to_disc(&v, 3, 0), 3);
    }

    #[test]
    fn full_column_has_no_features_beyond_height_indicators() {
        let mut text = String::from("0000000000000000000000000000000000000000008888888");
        for row in 0..7 {
            let idx = row * 7 + 6;
            text.replace_range(idx..idx + 1, "1");
        }
        let v = view(&text, 4);
        assert!(!v.is_legal(6));
        let f = features(&v, 6);
        assert_eq!(f.values[ROW_DETS], 0);
        assert_eq!(f.values[COL_DETS], 0);
        assert!(!f.present[ELEM_DET]);
        assert!(!f.present[MIN_EQ_ELEM]);
        // Unique tallest column: max_eq_elem present with value 0.
        assert!(f.present[MAX_EQ_ELEM]);
        assert_eq!(f.values[MAX_EQ_ELEM], 0);
    }
}