crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

main.swift (8872B)


      1 import Foundation
      2 
      3 let gridSize = 15
      4 
      5 struct Options {
      6     var gridsPath = "Sources/Fillmake/Resources/grid_list.json"
      7     var limit: Int?
      8     var format = "tsv"
      9 }
     10 
     11 struct GridEntry: Decodable {
     12     let grid: [String]
     13     let date: String
     14 }
     15 
     16 struct Slot {
     17     enum Direction: String {
     18         case across
     19         case down
     20     }
     21 
     22     let id: Int
     23     let cells: [Int]
     24     let direction: Direction
     25 }
     26 
     27 struct GridStats {
     28     let index: Int
     29     let date: String
     30     let score: Int
     31     let slotCount: Int
     32     let openCellCount: Int
     33     let threeLetterSlots: Int
     34     let fourLetterSlots: Int
     35     let fiveSixLetterSlots: Int
     36     let sevenPlusLetterSlots: Int
     37     let maxShortCluster: Int
     38     let averageSlotLength: Double
     39 }
     40 
     41 struct GridmakeError: Error, CustomStringConvertible {
     42     let description: String
     43 }
     44 
     45 func printUsage() {
     46     print("""
     47     Usage: Gridmake [options]
     48 
     49     Options:
     50       --grids PATH        Grid list JSON path. Default: Sources/Fillmake/Resources/grid_list.json
     51       --limit N           Print only the top N ranked grids.
     52       --format tsv|json   Output format. Default: tsv.
     53       -h, --help          Show this help.
     54     """)
     55 }
     56 
     57 func requireValue(_ arguments: [String], _ index: inout Int, _ name: String) throws -> String {
     58     index += 1
     59     guard index < arguments.count else {
     60         throw GridmakeError(description: "Missing value for \(name)")
     61     }
     62     return arguments[index]
     63 }
     64 
     65 func parseOptions() throws -> Options {
     66     var options = Options()
     67     let arguments = Array(CommandLine.arguments.dropFirst())
     68     var index = 0
     69 
     70     while index < arguments.count {
     71         let argument = arguments[index]
     72         switch argument {
     73         case "--grids":
     74             options.gridsPath = try requireValue(arguments, &index, argument)
     75         case "--limit":
     76             let value = try requireValue(arguments, &index, argument)
     77             guard let limit = Int(value), limit > 0 else {
     78                 throw GridmakeError(description: "--limit must be a positive integer")
     79             }
     80             options.limit = limit
     81         case "--format":
     82             let format = try requireValue(arguments, &index, argument)
     83             guard ["tsv", "json"].contains(format) else {
     84                 throw GridmakeError(description: "--format must be tsv or json")
     85             }
     86             options.format = format
     87         case "-h", "--help":
     88             printUsage()
     89             exit(0)
     90         default:
     91             throw GridmakeError(description: "Unknown argument: \(argument)")
     92         }
     93         index += 1
     94     }
     95 
     96     return options
     97 }
     98 
     99 func loadJSON<T: Decodable>(_ type: T.Type, from path: String) throws -> T {
    100     let url = URL(fileURLWithPath: path)
    101     let data = try Data(contentsOf: url)
    102     return try JSONDecoder().decode(T.self, from: data)
    103 }
    104 
    105 func buildSlots(black: [Bool]) -> [Slot] {
    106     var slots: [Slot] = []
    107 
    108     for row in 0..<gridSize {
    109         var column = 0
    110         while column < gridSize {
    111             let cell = row * gridSize + column
    112             if black[cell] {
    113                 column += 1
    114                 continue
    115             }
    116             var cells: [Int] = []
    117             while column < gridSize && !black[row * gridSize + column] {
    118                 cells.append(row * gridSize + column)
    119                 column += 1
    120             }
    121             if cells.count > 1 {
    122                 slots.append(Slot(id: slots.count, cells: cells, direction: .across))
    123             }
    124         }
    125     }
    126 
    127     for column in 0..<gridSize {
    128         var row = 0
    129         while row < gridSize {
    130             let cell = row * gridSize + column
    131             if black[cell] {
    132                 row += 1
    133                 continue
    134             }
    135             var cells: [Int] = []
    136             while row < gridSize && !black[row * gridSize + column] {
    137                 cells.append(row * gridSize + column)
    138                 row += 1
    139             }
    140             if cells.count > 1 {
    141                 slots.append(Slot(id: slots.count, cells: cells, direction: .down))
    142             }
    143         }
    144     }
    145 
    146     return slots
    147 }
    148 
    149 func maxShortCluster(in slots: [Slot]) -> Int {
    150     let shortSlotIDs = Set(slots.filter { $0.cells.count <= 4 }.map(\.id))
    151     guard !shortSlotIDs.isEmpty else {
    152         return 0
    153     }
    154 
    155     var cellToShortSlots: [Int: [Int]] = [:]
    156     for slot in slots where shortSlotIDs.contains(slot.id) {
    157         for cell in slot.cells {
    158             cellToShortSlots[cell, default: []].append(slot.id)
    159         }
    160     }
    161 
    162     var adjacency: [Int: Set<Int>] = [:]
    163     for connectedSlots in cellToShortSlots.values where connectedSlots.count > 1 {
    164         for left in connectedSlots {
    165             for right in connectedSlots where right != left {
    166                 adjacency[left, default: []].insert(right)
    167             }
    168         }
    169     }
    170 
    171     var visited = Set<Int>()
    172     var largest = 1
    173 
    174     for slotID in shortSlotIDs where !visited.contains(slotID) {
    175         var stack = [slotID]
    176         visited.insert(slotID)
    177         var size = 0
    178 
    179         while let current = stack.popLast() {
    180             size += 1
    181             for next in adjacency[current, default: []] where !visited.contains(next) {
    182                 visited.insert(next)
    183                 stack.append(next)
    184             }
    185         }
    186 
    187         largest = max(largest, size)
    188     }
    189 
    190     return largest
    191 }
    192 
    193 func stats(for entry: GridEntry, index: Int) throws -> GridStats {
    194     guard entry.grid.count == gridSize * gridSize else {
    195         throw GridmakeError(description: "Grid \(entry.date) has \(entry.grid.count) cells, expected \(gridSize * gridSize)")
    196     }
    197 
    198     let black = entry.grid.map { $0 == "." }
    199     let slots = buildSlots(black: black)
    200     let lengths = slots.map { $0.cells.count }
    201     let slotCount = slots.count
    202     let openCellCount = black.filter { !$0 }.count
    203     let threeLetterSlots = lengths.filter { $0 == 3 }.count
    204     let fourLetterSlots = lengths.filter { $0 == 4 }.count
    205     let fiveSixLetterSlots = lengths.filter { (5...6).contains($0) }.count
    206     let sevenPlusLetterSlots = lengths.filter { $0 >= 7 }.count
    207     let shortCluster = maxShortCluster(in: slots)
    208     let averageSlotLength = lengths.isEmpty ? 0 : Double(lengths.reduce(0, +)) / Double(lengths.count)
    209 
    210     let score = sevenPlusLetterSlots * 20
    211         + fiveSixLetterSlots * 8
    212         - threeLetterSlots * 35
    213         - fourLetterSlots * 15
    214         - shortCluster * 50
    215         + Int(averageSlotLength * 6)
    216         - max(0, slotCount - 78) * 6
    217 
    218     return GridStats(
    219         index: index,
    220         date: entry.date,
    221         score: score,
    222         slotCount: slotCount,
    223         openCellCount: openCellCount,
    224         threeLetterSlots: threeLetterSlots,
    225         fourLetterSlots: fourLetterSlots,
    226         fiveSixLetterSlots: fiveSixLetterSlots,
    227         sevenPlusLetterSlots: sevenPlusLetterSlots,
    228         maxShortCluster: shortCluster,
    229         averageSlotLength: averageSlotLength
    230     )
    231 }
    232 
    233 func printTSV(_ rankedStats: [GridStats]) {
    234     print("rank\tindex\tdate\tscore\tslots\topen_cells\tlen3\tlen4\tlen5_6\tlen7_plus\tmax_short_cluster\tavg_slot_length")
    235     for (rank, stats) in rankedStats.enumerated() {
    236         let average = String(format: "%.2f", stats.averageSlotLength)
    237         print("\(rank + 1)\t\(stats.index)\t\(stats.date)\t\(stats.score)\t\(stats.slotCount)\t\(stats.openCellCount)\t\(stats.threeLetterSlots)\t\(stats.fourLetterSlots)\t\(stats.fiveSixLetterSlots)\t\(stats.sevenPlusLetterSlots)\t\(stats.maxShortCluster)\t\(average)")
    238     }
    239 }
    240 
    241 func jsonString(_ value: String) -> String {
    242     let data = try! JSONEncoder().encode(value)
    243     return String(data: data, encoding: .utf8) ?? "\"\""
    244 }
    245 
    246 func printJSON(_ rankedStats: [GridStats]) {
    247     print("[")
    248     for (offset, stats) in rankedStats.enumerated() {
    249         let comma = offset == rankedStats.count - 1 ? "" : ","
    250         let average = String(format: "%.4f", stats.averageSlotLength)
    251         print("""
    252           {"rank":\(offset + 1),"index":\(stats.index),"date":\(jsonString(stats.date)),"score":\(stats.score),"slots":\(stats.slotCount),"open_cells":\(stats.openCellCount),"len3":\(stats.threeLetterSlots),"len4":\(stats.fourLetterSlots),"len5_6":\(stats.fiveSixLetterSlots),"len7_plus":\(stats.sevenPlusLetterSlots),"max_short_cluster":\(stats.maxShortCluster),"avg_slot_length":\(average)}\(comma)
    253         """)
    254     }
    255     print("]")
    256 }
    257 
    258 do {
    259     let options = try parseOptions()
    260     let grids = try loadJSON([GridEntry].self, from: options.gridsPath)
    261     let rankedStats = try grids.enumerated()
    262         .map { try stats(for: $0.element, index: $0.offset) }
    263         .sorted {
    264             if $0.score != $1.score {
    265                 return $0.score > $1.score
    266             }
    267             return $0.index < $1.index
    268         }
    269         .prefix(options.limit ?? grids.count)
    270 
    271     if options.format == "json" {
    272         printJSON(Array(rankedStats))
    273     } else {
    274         printTSV(Array(rankedStats))
    275     }
    276 } catch {
    277     fputs("Gridmake: \(error)\n", stderr)
    278     exit(1)
    279 }