crossmate

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

main.swift (12137B)


      1 import Foundation
      2 
      3 let gridSize = 15
      4 let letterWeights: [UInt8: Int] = [
      5     65: 9, 66: 2, 67: 2, 68: 4, 69: 12, 70: 2, 71: 3, 72: 2, 73: 9,
      6     74: 1, 75: 1, 76: 4, 77: 2, 78: 6, 79: 8, 80: 2, 81: 1, 82: 6,
      7     83: 4, 84: 6, 85: 4, 86: 2, 87: 2, 88: 1, 89: 2, 90: 1
      8 ]
      9 
     10 struct Options {
     11     var countsPath = "Generated/answer_counts.json"
     12     var qualityPath = "Generated/word_quality.json"
     13     var count = 10
     14     var maxAnswerUsages = 2
     15     var disallowRepeatedSelectedAnswers = false
     16     var unusualScoreThreshold = 110
     17     var inputs: [String] = ["Generated"]
     18     var verbose = false
     19 }
     20 
     21 struct WordQuality: Decodable {
     22     let count: Int?
     23     let badClueCount: Int?
     24     let obscureNameClueCount: Int?
     25     let fillBlankCount: Int?
     26     let rejectionReason: String?
     27 
     28     enum CodingKeys: String, CodingKey {
     29         case count
     30         case badClueCount = "bad_clue_count"
     31         case obscureNameClueCount = "obscure_name_clue_count"
     32         case fillBlankCount = "fill_blank_count"
     33         case rejectionReason = "rejection_reason"
     34     }
     35 }
     36 
     37 struct Candidate {
     38     let path: String
     39     let answers: [String]
     40     let counts: [String: Int]
     41     let unusualAnswers: Set<String>
     42     let score: Int
     43 }
     44 
     45 struct PickmakeError: Error, CustomStringConvertible {
     46     let description: String
     47 }
     48 
     49 func parseOptions(_ arguments: [String]) throws -> Options {
     50     var options = Options()
     51     var index = 1
     52     var inputs: [String] = []
     53 
     54     func requireValue(_ name: String) throws -> String {
     55         guard index + 1 < arguments.count else {
     56             throw PickmakeError(description: "Missing value for \(name)")
     57         }
     58         index += 1
     59         return arguments[index]
     60     }
     61 
     62     while index < arguments.count {
     63         let arg = arguments[index]
     64         switch arg {
     65         case "--counts":
     66             options.countsPath = try requireValue(arg)
     67         case "--quality":
     68             options.qualityPath = try requireValue(arg)
     69         case "--count":
     70             let value = try requireValue(arg)
     71             guard let parsed = Int(value), parsed > 0 else {
     72                 throw PickmakeError(description: "--count must be a positive integer")
     73             }
     74             options.count = parsed
     75         case "--max-answer-usages":
     76             let value = try requireValue(arg)
     77             guard let parsed = Int(value), parsed > 0 else {
     78                 throw PickmakeError(description: "--max-answer-usages must be a positive integer")
     79             }
     80             options.maxAnswerUsages = parsed
     81         case "--disallow-all-repeats":
     82             options.disallowRepeatedSelectedAnswers = true
     83         case "--unusual-score-threshold":
     84             let value = try requireValue(arg)
     85             guard let parsed = Int(value) else {
     86                 throw PickmakeError(description: "--unusual-score-threshold must be an integer")
     87             }
     88             options.unusualScoreThreshold = parsed
     89         case "--verbose":
     90             options.verbose = true
     91         case "--help", "-h":
     92             printUsage()
     93             exit(0)
     94         default:
     95             inputs.append(arg)
     96         }
     97         index += 1
     98     }
     99 
    100     if !inputs.isEmpty {
    101         options.inputs = inputs
    102     }
    103     return options
    104 }
    105 
    106 func printUsage() {
    107     print("""
    108     Usage: Pickmake [options] [XD files or directories...]
    109 
    110     Options:
    111       --count N                       Number of puzzles to select. Default: 10
    112       --counts PATH                   Answer frequency JSON path. Default: Generated/answer_counts.json
    113       --quality PATH                  Word quality metadata JSON path. Default: Generated/word_quality.json
    114       --max-answer-usages N           Reject puzzles using one answer more than N times. Default: 2
    115       --disallow-all-repeats
    116                                       Disallow any repeated answers across selected puzzles. Default: only unusual answers cannot repeat.
    117       --unusual-score-threshold N     Answers scoring below N cannot repeat across selected puzzles. Default: 110
    118       --verbose                       Print rejected puzzle details to stderr.
    119       -h, --help                      Show this help.
    120     """)
    121 }
    122 
    123 func loadJSON<T: Decodable>(_ type: T.Type, from path: String) throws -> T {
    124     let data = try Data(contentsOf: URL(fileURLWithPath: path))
    125     return try JSONDecoder().decode(T.self, from: data)
    126 }
    127 
    128 func vowelCount(_ bytes: [UInt8]) -> Int {
    129     bytes.filter { $0 == 65 || $0 == 69 || $0 == 73 || $0 == 79 || $0 == 85 }.count
    130 }
    131 
    132 func hasRunOfThree(_ bytes: [UInt8]) -> Bool {
    133     guard bytes.count >= 3 else {
    134         return false
    135     }
    136     for index in 2..<bytes.count where bytes[index] == bytes[index - 1] && bytes[index] == bytes[index - 2] {
    137         return true
    138     }
    139     return false
    140 }
    141 
    142 func qualityScore(text: String, count: Int?, quality: WordQuality?) -> Int {
    143     let bytes = Array(text.utf8)
    144     let length = bytes.count
    145     let frequency = count ?? 0
    146     var score = length * 12
    147 
    148     if frequency > 0 {
    149         score += min(80, frequency * 4)
    150     } else {
    151         score -= length >= 8 ? 30 : 80
    152     }
    153 
    154     if length >= 8 {
    155         score += min(80, (length - 7) * 14)
    156     }
    157     if (5...7).contains(length) && frequency <= 2 {
    158         score -= 70
    159     }
    160     if length == 4 && frequency < 10 {
    161         score -= 60
    162     }
    163     if length <= 3 && frequency < 25 {
    164         score -= 80
    165     }
    166 
    167     let vowels = vowelCount(bytes)
    168     if vowels == 0 {
    169         score -= 90
    170     } else if length >= 5 && Double(vowels) / Double(length) < 0.22 {
    171         score -= 35
    172     }
    173     if hasRunOfThree(bytes) {
    174         score -= 110
    175     }
    176 
    177     if let quality {
    178         let evidenceCount = max(quality.count ?? frequency, 1)
    179         let badRatio = Double(quality.badClueCount ?? 0) / Double(evidenceCount)
    180         let obscureRatio = Double(quality.obscureNameClueCount ?? 0) / Double(evidenceCount)
    181         let blankRatio = Double(quality.fillBlankCount ?? 0) / Double(evidenceCount)
    182 
    183         if quality.rejectionReason != nil {
    184             score -= length >= 8 ? 80 : 140
    185         }
    186         if length <= 4 {
    187             score -= Int(badRatio * 100)
    188             score -= Int(obscureRatio * 70)
    189             score -= Int(blankRatio * 45)
    190         } else if length <= 7 {
    191             score -= Int(badRatio * 45)
    192             score -= Int(obscureRatio * 35)
    193             score -= Int(blankRatio * 20)
    194         } else {
    195             score -= Int(badRatio * 20)
    196             score -= Int(obscureRatio * 15)
    197         }
    198     }
    199 
    200     return score + bytes.reduce(0) { $0 + (letterWeights[$1] ?? 0) }
    201 }
    202 
    203 func discoverXDPaths(inputs: [String]) throws -> [String] {
    204     let fileManager = FileManager.default
    205     var paths: [String] = []
    206 
    207     for input in inputs {
    208         var isDirectory: ObjCBool = false
    209         guard fileManager.fileExists(atPath: input, isDirectory: &isDirectory) else {
    210             throw PickmakeError(description: "Input not found: \(input)")
    211         }
    212 
    213         if isDirectory.boolValue {
    214             let url = URL(fileURLWithPath: input)
    215             guard let enumerator = fileManager.enumerator(at: url, includingPropertiesForKeys: [.isRegularFileKey]) else {
    216                 continue
    217             }
    218             for case let fileURL as URL in enumerator where fileURL.pathExtension.lowercased() == "xd" {
    219                 paths.append(fileURL.path)
    220             }
    221         } else if input.lowercased().hasSuffix(".xd") {
    222             paths.append(URL(fileURLWithPath: input).path)
    223         }
    224     }
    225 
    226     return Array(Set(paths)).sorted()
    227 }
    228 
    229 func answers(in path: String) throws -> [String] {
    230     let contents = try String(contentsOfFile: path, encoding: .utf8)
    231     return contents.split(separator: "\n").compactMap { line in
    232         guard let separator = line.range(of: " ~ ") else {
    233             return nil
    234         }
    235         let answer = line[separator.upperBound...].trimmingCharacters(in: .whitespaces)
    236         guard !answer.isEmpty, answer.allSatisfy({ $0 >= "A" && $0 <= "Z" }) else {
    237             return nil
    238         }
    239         return answer
    240     }
    241 }
    242 
    243 func storedFillScore(in path: String) throws -> Int? {
    244     let contents = try String(contentsOfFile: path, encoding: .utf8)
    245     for line in contents.split(separator: "\n") {
    246         guard line.hasPrefix("Fill Score:") else {
    247             continue
    248         }
    249         let value = line.dropFirst("Fill Score:".count).trimmingCharacters(in: .whitespaces)
    250         return Int(value)
    251     }
    252     return nil
    253 }
    254 
    255 func makeCandidate(path: String, counts: [String: Int], quality: [String: WordQuality], unusualScoreThreshold: Int) throws -> Candidate {
    256     let words = try answers(in: path)
    257     let answerCounts = Dictionary(grouping: words, by: { $0 }).mapValues(\.count)
    258     var unusual = Set<String>()
    259     var score = 0
    260 
    261     for word in words {
    262         let key = word.lowercased()
    263         let wordScore = qualityScore(text: word, count: counts[key], quality: quality[key])
    264         score += wordScore
    265         if wordScore < unusualScoreThreshold {
    266             unusual.insert(word)
    267         }
    268     }
    269 
    270     return Candidate(path: path, answers: words, counts: answerCounts, unusualAnswers: unusual, score: try storedFillScore(in: path) ?? score)
    271 }
    272 
    273 func run() throws {
    274     let options = try parseOptions(CommandLine.arguments)
    275     let counts = try loadJSON([String: Int].self, from: options.countsPath)
    276     let quality = try loadJSON([String: WordQuality].self, from: options.qualityPath)
    277     let paths = try discoverXDPaths(inputs: options.inputs)
    278     let candidates = try paths.map {
    279         try makeCandidate(path: $0, counts: counts, quality: quality, unusualScoreThreshold: options.unusualScoreThreshold)
    280     }.sorted {
    281         if $0.score != $1.score {
    282             return $0.score > $1.score
    283         }
    284         return $0.path < $1.path
    285     }
    286 
    287     var selected: [Candidate] = []
    288     var selectedUnusual = Set<String>()
    289     var selectedAnswers = Set<String>()
    290     var rejectedForInternalRepeats = 0
    291     var rejectedForAnswerRepeats = 0
    292     var rejectedForUnusualRepeats = 0
    293 
    294     for candidate in candidates {
    295         let overused = candidate.counts
    296             .filter { $0.value > options.maxAnswerUsages }
    297             .sorted { $0.key < $1.key }
    298         if !overused.isEmpty {
    299             rejectedForInternalRepeats += 1
    300             if options.verbose {
    301                 let details = overused.map { "\($0.key)=\($0.value)" }.joined(separator: ", ")
    302                 fputs("Rejected \(candidate.path): answer usage > \(options.maxAnswerUsages) (\(details))\n", stderr)
    303             }
    304             continue
    305         }
    306         let repeatedAnswers = Set(candidate.answers).intersection(selectedAnswers).sorted()
    307         if options.disallowRepeatedSelectedAnswers && !repeatedAnswers.isEmpty {
    308             rejectedForAnswerRepeats += 1
    309             if options.verbose {
    310                 fputs("Rejected \(candidate.path): repeated selected answers (\(repeatedAnswers.joined(separator: ", ")))\n", stderr)
    311             }
    312             continue
    313         }
    314         if !options.disallowRepeatedSelectedAnswers {
    315             let repeatedUnusual = candidate.unusualAnswers.intersection(selectedUnusual).sorted()
    316             if !repeatedUnusual.isEmpty {
    317                 rejectedForUnusualRepeats += 1
    318                 if options.verbose {
    319                     fputs("Rejected \(candidate.path): repeated unusual answers (\(repeatedUnusual.joined(separator: ", ")))\n", stderr)
    320                 }
    321                 continue
    322             }
    323         }
    324 
    325         selected.append(candidate)
    326         selectedAnswers.formUnion(candidate.answers)
    327         selectedUnusual.formUnion(candidate.unusualAnswers)
    328         if selected.count == options.count {
    329             break
    330         }
    331     }
    332 
    333     for candidate in selected {
    334         print(candidate.path)
    335     }
    336 
    337     fputs("Selected \(selected.count) of \(options.count) requested puzzles from \(candidates.count) candidates.\n", stderr)
    338     fputs("Rejected \(rejectedForInternalRepeats) for answer usage > \(options.maxAnswerUsages); \(rejectedForAnswerRepeats) for repeated selected answers; \(rejectedForUnusualRepeats) for repeated unusual answers below score \(options.unusualScoreThreshold).\n", stderr)
    339 }
    340 
    341 do {
    342     try run()
    343 } catch {
    344     fputs("Pickmake: \(error)\n", stderr)
    345     exit(1)
    346 }