commit 7f15010b999739dac7d70fb504ab03ee64404c79
parent 10fbe622f7edc421ff6aa49d9969f9e8268a1067
Author: Michael Camilleri <[email protected]>
Date: Wed, 8 Jul 2026 09:53:56 +0900
Exempt rebus fills from the upgrade divergence check
An owned rebus puzzle from an external provider stopped accepting its
own answers after a converter fix. The converter had been corrected to
keep the slash form, but the persisted game never adopted it because the
canonical rebus answer was longer in length. That returns .mismatched,
which keeps the old source, so the stranded game held onto its collapsed
fill and slash-stripped accepts.
A rebus fill is the converter's representation of a single square, not
grid geometry: it occupies one cell, so changing it — a slash
re-representation or a wholly different fill — never relocates a
player's move. This commit exempts any multi-character rebus fill from
the cell-by-cell comparison, while still treating a single-letter
difference as divergence so a genuinely different grid is still caught.
currentConverterVersion is raised so games an earlier build already
stamped as evaluated re-fetch and pick up the corrected upgrade rather
than staying on the old fill.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Diffstat:
3 files changed, 55 insertions(+), 4 deletions(-)
diff --git a/Crossmate/Models/XD.swift b/Crossmate/Models/XD.swift
@@ -10,7 +10,7 @@ struct XD: Sendable {
/// owned NYT game should be re-fetched and re-converted. Only bumped when a
/// converter changes; puzzles generated in-house (Crossmake, bundled) carry
/// no converter version at all.
- static let currentConverterVersion = 8
+ static let currentConverterVersion = 9
/// Version of the XD→Puzzle/Core Data processing (`XD.parse`, `Puzzle(xd:)`,
/// and the cached summary fields). Held on the game entity, never
diff --git a/Crossmate/Services/NYTPuzzleUpgrader.swift b/Crossmate/Services/NYTPuzzleUpgrader.swift
@@ -115,13 +115,26 @@ enum NYTPuzzleUpgrader {
}
/// Two puzzles are structurally equivalent when their grids have the same
- /// dimensions, the same block layout, and the same solution at every open
- /// cell. Special markers, accepted variants, clues, and headers may all
- /// differ.
+ /// dimensions and block layout, and the same solution at every open cell
+ /// that isn't a rebus. A rebus (multi-character) fill is the converter's
+ /// representational choice for one square — 'HITMISS' vs 'HIT/MISS', or any
+ /// re-representation of the same fill — and lives in that one cell, so a
+ /// change there never relocates a move; it's exempt from the comparison.
+ /// Single-letter cells are the puzzle's answer grid, so a change there is
+ /// treated as divergence. Special markers, accepted variants, clues, and
+ /// headers may all differ.
static func structurallyEquivalent(_ a: XD, _ b: XD) -> Bool {
structuralDivergence(a, b) == nil
}
+ /// A rebus fill occupies a single square with a multi-character solution.
+ /// Such a fill is presentational — the converter's choice of how to spell an
+ /// either/or or multi-letter answer in one cell — so a change to it doesn't
+ /// move any square and isn't a structural divergence.
+ private static func isRebusFill(_ solution: String?) -> Bool {
+ (solution?.count ?? 0) > 1
+ }
+
/// Walks the grid in row-major order and returns a short description of
/// the first cell that disagrees, or nil when the two are equivalent.
/// Used to populate `.mismatched(reason:)` for diagnostic logging.
@@ -135,6 +148,9 @@ enum NYTPuzzleUpgrader {
case (.block, .block):
continue
case let (.open(left, _, _), .open(right, _, _)):
+ // A rebus fill on either side is representational, not
+ // geometry — exempt it. Compare only single-letter cells.
+ if isRebusFill(left) || isRebusFill(right) { continue }
if left != right {
return "cell(r=\(row),c=\(col)) old=\(left ?? "nil") new=\(right ?? "nil")"
}
diff --git a/Tests/Unit/NYTPuzzleUpgraderTests.swift b/Tests/Unit/NYTPuzzleUpgraderTests.swift
@@ -81,6 +81,22 @@ struct NYTPuzzleUpgraderTests {
return [metadata, grid, clues].joined(separator: "\n\n\n")
}
+ /// 1×1 grid whose single cell is a rebus fill, used to check that a solution
+ /// differing only in Schrödinger slash placement upgrades in place.
+ private func rebusSource(fill: String) -> String {
+ let metadata = """
+ Title: Rebus
+ Publisher: New York Times
+ Date: 2025-01-26
+ Rebus: 1=\(fill)
+ """
+ let clues = """
+ A1. Clue ~ \(fill)
+ D1. Clue ~ \(fill)
+ """
+ return [metadata, "1", clues].joined(separator: "\n\n\n")
+ }
+
// MARK: - Outcomes
@Test("Clue-only diff is .upgraded — grid + solutions match")
@@ -112,6 +128,25 @@ struct NYTPuzzleUpgraderTests {
Issue.record("Expected .mismatched but got \(outcome)")
}
+ @Test("Any rebus fill change is .upgraded — slash re-representation or otherwise")
+ func rebusFillChangeIsUpgraded() async {
+ // A rebus fill lives in one square, so changing it never relocates a
+ // move — whether it's a slash re-representation or a wholly different
+ // fill. Neither blocks the upgrade.
+ for (old, new) in [("HITMISS", "HIT/MISS"), ("LOVE", "HATE")] {
+ let outcome = await NYTPuzzleUpgrader.upgrade(
+ date: Date(),
+ currentSource: rebusSource(fill: old),
+ fetch: { _ in rebusSource(fill: new) }
+ )
+ guard case .upgraded(let returned) = outcome else {
+ Issue.record("Expected .upgraded for \(old)→\(new) but got \(outcome)")
+ return
+ }
+ #expect(returned == rebusSource(fill: new))
+ }
+ }
+
@Test("Block-pattern diff is .mismatched")
func blockPatternDiffIsMismatched() async {
let old = openGridSource()