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 }