commit c906cd45cbedd4309786b653c0b0c34e0e873141
parent 3ddfcd3cf7ffed79ce6de41a74cd7b51b4b26273
Author: Michael Camilleri <[email protected]>
Date: Fri, 29 May 2026 07:29:12 +0900
Load Schrödinger puzzles as rebuses
A 'Schrödinger grid' is a puzzle in which a square can take multiple
letters. The XD converter was failing to parse 'Schrödinger cells' that
were identified with a '/'. Crossmate should model this as a rebus with
the alternate solutions being the individual letters.
A cell whose answer contains '/' now has the slash stripped: the letters
together (e.g. 'AZ') become the canonical rebus fill so the marquee
across answer reveals correctly, and the single letters ride through
moreAnswers → ^Accept → per-cell acceptedSolutions. Cells whose answer
has no slash are untouched, so arbitrary accepted strings still
round-trip.
The placeholder collision is fixed at the source. rebusPlaceholders is
no longer a hand-picked grab-bag of punctuation but is derived by the
.xd spec's rule — 'digits, most symbols, and printable unicode
characters (if needed)' — as digits first, then every printable ASCII
symbol except the seven the grid/header parser reserves (#, _, ., @, *,
=, space). The assignment loop throws a clear error rather than silently
corrupting output if a puzzle ever exhausts the pool; real grids use a
handful of distinct fills, nowhere near the ceiling.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Diffstat:
2 files changed, 125 insertions(+), 8 deletions(-)
diff --git a/Crossmate/Services/NYTToXDConverter.swift b/Crossmate/Services/NYTToXDConverter.swift
@@ -7,6 +7,28 @@ enum NYTToXDConverter {
var errorDescription: String? { message }
}
+ /// Single-character grid placeholders for multi-letter (rebus) fills. The
+ /// `.xd` grid is one character per cell, so a cell whose fill is longer than
+ /// one letter shows one of these in the grid and is expanded via the
+ /// `Rebus:` header (the `1` in `1=LW`). These are *not* NYT data — they're
+ /// our `.xd` serialization. The `.xd` spec allows "digits, most symbols,
+ /// and printable unicode characters (if needed)" here; we take digits first
+ /// (the conventional, readable encoding) then the symbols, where "most"
+ /// means every printable ASCII symbol *except* the ones the grid/header
+ /// parser already reserves — letters (grid fill), `#`/`_`/`.` (block/empty),
+ /// `@`/`*` (special markers), and `=`/space (`Rebus:` `key=value` syntax).
+ /// We stop at ASCII rather than walking into unicode and throw past the
+ /// ceiling; real puzzles use a handful of distinct fills, nowhere near it.
+ /// (Walking ASCII naïvely from `'1'` overflows into `=` by the 13th key,
+ /// which produced an unparseable `==VALUE` entry and a rejected grid char.)
+ private static let rebusPlaceholders: [Character] = {
+ let reserved: Set<Character> = ["#", "_", ".", "@", "*", "=", " "]
+ let symbols = (UInt8(33)...UInt8(126))
+ .map { Character(UnicodeScalar($0)) }
+ .filter { !$0.isLetter && !$0.isNumber && !reserved.contains($0) }
+ return Array("123456789") + symbols
+ }()
+
/// Converts raw JSON data from the NYT puzzle endpoint to an `.xd` source string.
static func convert(jsonData: Data) throws -> String {
guard let root = try JSONSerialization.jsonObject(with: jsonData) as? [String: Any] else {
@@ -48,7 +70,6 @@ enum NYTToXDConverter {
// Each cell is either an empty dict (block) or a dict with "answer", "type", etc.
var answers: [String?] = [] // nil = block, String = answer
var acceptedAnswersByCellIndex: [Int: [String]] = [:]
- var nextRebusKey: Character = "1"
for (index, cell) in cells.enumerated() {
guard let dict = cell as? [String: Any], !dict.isEmpty else {
@@ -56,7 +77,18 @@ enum NYTToXDConverter {
continue
}
- let answer = dict["answer"] as? String ?? ""
+ let rawAnswer = dict["answer"] as? String ?? ""
+ // NYT encodes a "Schrödinger" square — one correct as either of two
+ // letters — as a slash-joined answer like "L/W", with every
+ // acceptable keystroke enumerated in moreAnswers.valid. The slash
+ // is NYT notation, not a grid character (and isn't on our
+ // keyboard), so collapse it: the letters together ("LW") become the
+ // canonical rebus fill, and each single letter rides along as an
+ // accepted alternate via the moreAnswers handling below.
+ let isSchrodinger = rawAnswer.contains("/")
+ let answer = isSchrodinger
+ ? rawAnswer.replacingOccurrences(of: "/", with: "")
+ : rawAnswer
if answer.isEmpty {
answers.append(nil)
} else {
@@ -65,27 +97,41 @@ enum NYTToXDConverter {
if let moreAnswers = dict["moreAnswers"] as? [String: Any],
let valid = moreAnswers["valid"] as? [String] {
- let cleaned = valid.filter { !$0.isEmpty && $0 != answer }
+ // For Schrödinger cells, strip the slash from each alternate too
+ // so only keyboard-enterable letter forms survive, then
+ // dedupe/sort for stable output. Other cells pass their
+ // alternates through unchanged so arbitrary accepted strings
+ // still round-trip.
+ let candidates = isSchrodinger
+ ? valid.map { $0.replacingOccurrences(of: "/", with: "") }
+ : valid
+ let cleaned = candidates.filter { !$0.isEmpty && $0 != answer }
if !cleaned.isEmpty {
- acceptedAnswersByCellIndex[index] = cleaned
+ acceptedAnswersByCellIndex[index] = isSchrodinger
+ ? Array(Set(cleaned)).sorted()
+ : cleaned
}
}
}
// -- Build rebus header if needed --
- // Check for multi-character answers.
+ // Check for multi-character answers. Each distinct multi-letter fill
+ // claims the next grid placeholder from `rebusPlaceholders`.
var rebusEntries: [(key: Character, value: String)] = []
var rebusLookup: [String: Character] = [:]
for answer in answers {
guard let answer, answer.count > 1 else { continue }
if rebusLookup[answer] == nil {
- let key = nextRebusKey
+ guard rebusLookup.count < rebusPlaceholders.count else {
+ throw ConversionError(
+ message: "Too many distinct rebus fills (\(rebusLookup.count + 1)); ran out of grid placeholders."
+ )
+ }
+ let key = rebusPlaceholders[rebusLookup.count]
rebusLookup[answer] = key
rebusEntries.append((key: key, value: answer))
- // Advance to next digit/letter for rebus key
- nextRebusKey = Character(UnicodeScalar(nextRebusKey.asciiValue! + 1))
}
}
diff --git a/Tests/Unit/NYTToXDConverterTests.swift b/Tests/Unit/NYTToXDConverterTests.swift
@@ -71,6 +71,29 @@ struct NYTToXDConverterTests {
return try JSONSerialization.data(withJSONObject: root)
}
+ /// Builds NYT v6-shaped JSON for a single open row of `answers.count`
+ /// cells — one Across clue, no Down words. Handy for exercising cell-level
+ /// behaviour (rebus placeholders, Schrödinger fills) at a chosen volume
+ /// without the 3×3 grid's six-clue topology.
+ private func singleRowPuzzleJSON(answers: [String]) throws -> Data {
+ let cells = answers.map { ["answer": $0] as [String: Any] }
+ let clue: [String: Any] = [
+ "label": "1",
+ "direction": "Across",
+ "cells": Array(0..<answers.count),
+ "text": [["plain": "across"]]
+ ]
+ let root: [String: Any] = [
+ "publicationDate": "2025-02-02",
+ "body": [[
+ "dimensions": ["width": answers.count, "height": 1],
+ "cells": cells,
+ "clues": [clue]
+ ]]
+ ]
+ return try JSONSerialization.data(withJSONObject: root)
+ }
+
/// Extracts the value after `Relatives: ` in an `.xd` source, or nil if
/// the header isn't present.
private func relativesHeader(in xd: String) -> String? {
@@ -129,6 +152,54 @@ struct NYTToXDConverterTests {
#expect(cell.accepts("BACK\\SLASH"))
}
+ @Test("Schrödinger slash cell becomes a letters-only rebus that round-trips")
+ func schrodingerCellStripsSlash() throws {
+ // Center cell is correct as L or W; the across answer crams both in.
+ let data = try puzzleJSON(
+ relatives: [nil, nil, nil, nil, nil, nil],
+ letters: ["A", "B", "C", "D", "L/W", "F", "G", "H", "I"],
+ moreAnswersByCell: [4: ["W/L", "LW", "WL", "L", "W"]]
+ )
+ let xd = try NYTToXDConverter.convert(jsonData: data)
+
+ // The canonical rebus fill is the letters with the slash removed, and
+ // the slash never reaches the grid, the header, or the Accept metadata.
+ #expect(header("Rebus", in: xd) == "1=LW")
+ #expect(!xd.contains("/"))
+
+ let puzzle = Puzzle(xd: try XD.parse(xd))
+ let cell = puzzle.cells[1][1]
+ #expect(cell.solution == "LW")
+ #expect(cell.accepts("LW")) // both letters (across)
+ #expect(cell.accepts("L")) // either single letter (down)
+ #expect(cell.accepts("W"))
+ #expect(!cell.accepts("L/W")) // the slash itself is not a valid entry
+ }
+
+ @Test("Many distinct rebus fills never collide with reserved grid syntax")
+ func manyRebusFillsAvoidReservedKeys() throws {
+ // The 13 Schrödinger fills from the 2 Feb 2025 Sunday puzzle. Walking
+ // ASCII from '1' reaches '=' (the Rebus header delimiter) on the 13th,
+ // which used to yield an unparseable "==HE" entry and a grid character
+ // the parser rejected with unknownGridCharacter.
+ let pairs = ["L/W", "Q/B", "Z/M", "C/V", "U/I", "D/N", "R/Y",
+ "G/T", "S/F", "X/K", "J/P", "O/A", "H/E"]
+ let data = try singleRowPuzzleJSON(answers: pairs)
+ let xd = try NYTToXDConverter.convert(jsonData: data)
+
+ let rebus = try #require(header("Rebus", in: xd))
+ #expect(!rebus.contains("==")) // no entry has '=' as its key
+ for entry in rebus.split(separator: " ") {
+ #expect(entry.first != "=")
+ }
+
+ // The whole puzzle must now parse rather than throwing, with every
+ // distinct fill landing as a multi-letter rebus solution.
+ let puzzle = Puzzle(xd: try XD.parse(xd))
+ #expect(puzzle.cells[0][0].solution == "LW")
+ #expect(puzzle.cells[0][12].solution == "HE")
+ }
+
@Test("NYT type 2 cells emit circled specials")
func typeTwoCellsEmitCircledSpecials() throws {
let data = try puzzleJSON(