commit daef73f69237b558cf60e8fd6e6d4b283d0d5a3a
parent 7783a5912a43962ce91c9e4b640d78354c1fe784
Author: Michael Camilleri <[email protected]>
Date: Fri, 1 May 2026 08:33:14 +0900
Detect more forms of cross-referenced clues
This commit broadens clue-text cross-reference detection to handle more forms
of cross references. Hyphenated references no longer need to be introduced by
"See" or "With", and lists can use "and", "or", slashes, or mixed Across and
Down segments.
Regression tests cover the forms found in historical analysis, including
revealer lists like '1-, 4- and 5-Across', single references like '5-Across',
mixed-direction references, and slash-separated pairs.
Co-Authored-By: Codex GPT 5.5 <[email protected]>
Diffstat:
2 files changed, 127 insertions(+), 11 deletions(-)
diff --git a/Crossmate/Models/Puzzle.swift b/Crossmate/Models/Puzzle.swift
@@ -168,14 +168,41 @@ struct Puzzle: Sendable {
return groups
}
- /// Pulls `(number, direction)` pairs out of `See …-Down` /
- /// `With X- and Y-Down` style prose. The trailing `Across`/`Down`
- /// applies to every number in the list, matching NYT's convention.
+ /// Pulls `(number, direction)` pairs out of `See …-Down`,
+ /// `With X- and Y-Down`, revealer-style `X-, Y- or Z-Across`,
+ /// and mixed-direction prose like `X-Across and Y-Down`.
+ /// A trailing `Across`/`Down` applies to every number in that list
+ /// segment, matching NYT's convention.
private static func parseCrossReferences(in text: String) -> [ClueRef]? {
- let pattern = /\b(?:See|With)\s+([\d\s,\-&]+?(?:and\s+[\d\s,\-&]+?)?)(Across|Down)\b/
- guard let match = text.firstMatch(of: pattern) else { return nil }
- let direction: Direction = String(match.2) == "Across" ? .across : .down
- let numbers = String(match.1).matches(of: /\d+/).compactMap { Int($0.0) }
+ var refs: [ClueRef] = []
+ var seen: Set<ClueRef> = []
+
+ func append(_ newRefs: [ClueRef]?) {
+ guard let newRefs else { return }
+ for ref in newRefs where seen.insert(ref).inserted {
+ refs.append(ref)
+ }
+ }
+
+ let listPattern = /([\d\s,\-&\/]+?(?:(?:and|or)\s+[\d\s,\-&\/]+?)?)(Across|Down)\b/
+ for match in text.matches(of: listPattern) {
+ guard String(match.1).contains(/\d+\s*-/) else { continue }
+ append(clueRefs(numbersText: String(match.1), directionText: String(match.2)))
+ }
+ if !refs.isEmpty {
+ return refs
+ }
+
+ let anchoredPattern = /\b(?:See|With)\s+([\d\s,\-&\/]+?(?:(?:and|or)\s+[\d\s,\-&\/]+?)?)(Across|Down)\b/
+ if let match = text.firstMatch(of: anchoredPattern) {
+ append(clueRefs(numbersText: String(match.1), directionText: String(match.2)))
+ }
+ return refs.isEmpty ? nil : refs
+ }
+
+ private static func clueRefs(numbersText: String, directionText: String) -> [ClueRef]? {
+ let direction: Direction = directionText == "Across" ? .across : .down
+ let numbers = numbersText.matches(of: /\d+/).compactMap { Int($0.0) }
guard !numbers.isEmpty else { return nil }
return numbers.map { ClueRef(number: $0, direction: direction) }
}
diff --git a/Tests/Unit/NYTToXDConverterTests.swift b/Tests/Unit/NYTToXDConverterTests.swift
@@ -258,6 +258,24 @@ struct NYTToXDConverterTests {
}
}
+ @Test("Revealer list without See or With drives Puzzle.relatedCells")
+ func unanchoredRevealerListDrivesPuzzle() throws {
+ let data = try puzzleJSON(
+ relatives: [nil, nil, nil, nil, nil, nil],
+ clueTexts: [
+ 0: "What can go after the respective halves of 1-, 4- and 5-Across"
+ ]
+ )
+ let xd = try XD.parse(try NYTToXDConverter.convert(jsonData: data))
+ let puzzle = Puzzle(xd: xd)
+ let related = puzzle.relatedCells(atRow: 0, col: 0, direction: .across)
+ for c in 0..<3 {
+ #expect(!related.contains(GridPosition(row: 0, col: c)))
+ #expect(related.contains(GridPosition(row: 1, col: c)))
+ #expect(related.contains(GridPosition(row: 2, col: c)))
+ }
+ }
+
@Test("Connected components: See / With chains form a single group")
func clueTextChainConnectedComponents() throws {
// 1D references 2D and 3D via "With"; 2D and 3D both point back via
@@ -298,13 +316,13 @@ struct NYTToXDConverterTests {
#expect(puzzle.relatedCells(atRow: 0, col: 0, direction: .down).isEmpty)
}
- @Test("Bare clue mentions without See/With anchor do not link clues")
- func bareClueMentionsDoNotLink() throws {
+ @Test("Bare clue mentions without hyphen do not link clues")
+ func bareClueMentionsWithoutHyphenDoNotLink() throws {
let data = try puzzleJSON(
relatives: [nil, nil, nil, nil, nil, nil],
clueTexts: [
- 0: "Compare with 5-Across, sort of",
- 2: "Reminiscent of 1-Across"
+ 0: "Compare with 5 Across, sort of",
+ 2: "Reminiscent of 1 Across"
]
)
let xd = try XD.parse(try NYTToXDConverter.convert(jsonData: data))
@@ -312,4 +330,75 @@ struct NYTToXDConverterTests {
let related = puzzle.relatedCells(atRow: 0, col: 0, direction: .across)
#expect(related.isEmpty)
}
+
+ @Test("Single unanchored clue mention with hyphen links clues")
+ func singleUnanchoredClueMentionWithHyphenLinks() throws {
+ let data = try puzzleJSON(
+ relatives: [nil, nil, nil, nil, nil, nil],
+ clueTexts: [
+ 0: "What might follow 5-Across in a phrase"
+ ]
+ )
+ let xd = try XD.parse(try NYTToXDConverter.convert(jsonData: data))
+ let puzzle = Puzzle(xd: xd)
+ let related = puzzle.relatedCells(atRow: 0, col: 0, direction: .across)
+ for c in 0..<3 {
+ #expect(!related.contains(GridPosition(row: 0, col: c)))
+ #expect(!related.contains(GridPosition(row: 1, col: c)))
+ #expect(related.contains(GridPosition(row: 2, col: c)))
+ }
+ }
+
+ @Test("Mixed Across and Down references link all mentioned clues")
+ func mixedAcrossAndDownReferencesLinkAllMentionedClues() throws {
+ let data = try puzzleJSON(
+ relatives: [nil, nil, nil, nil, nil, nil],
+ clueTexts: [
+ 0: "Bridge between 5-Across and 2-Down"
+ ]
+ )
+ let xd = try XD.parse(try NYTToXDConverter.convert(jsonData: data))
+ let puzzle = Puzzle(xd: xd)
+ let related = puzzle.relatedCells(atRow: 0, col: 0, direction: .across)
+ for i in 0..<3 {
+ #expect(related.contains(GridPosition(row: 2, col: i)))
+ #expect(related.contains(GridPosition(row: i, col: 1)))
+ }
+ }
+
+ @Test("Or-separated revealer lists link all mentioned clues")
+ func orSeparatedRevealerListsLinkAllMentionedClues() throws {
+ let data = try puzzleJSON(
+ relatives: [nil, nil, nil, nil, nil, nil],
+ clueTexts: [
+ 0: "What can precede 4- or 5-Across"
+ ]
+ )
+ let xd = try XD.parse(try NYTToXDConverter.convert(jsonData: data))
+ let puzzle = Puzzle(xd: xd)
+ let related = puzzle.relatedCells(atRow: 0, col: 0, direction: .across)
+ for c in 0..<3 {
+ #expect(!related.contains(GridPosition(row: 0, col: c)))
+ #expect(related.contains(GridPosition(row: 1, col: c)))
+ #expect(related.contains(GridPosition(row: 2, col: c)))
+ }
+ }
+
+ @Test("Slash-separated clue references link all mentioned clues")
+ func slashSeparatedClueReferencesLinkAllMentionedClues() throws {
+ let data = try puzzleJSON(
+ relatives: [nil, nil, nil, nil, nil, nil],
+ clueTexts: [
+ 0: "Phrase in 4-/5-Across"
+ ]
+ )
+ let xd = try XD.parse(try NYTToXDConverter.convert(jsonData: data))
+ let puzzle = Puzzle(xd: xd)
+ let related = puzzle.relatedCells(atRow: 0, col: 0, direction: .across)
+ for c in 0..<3 {
+ #expect(!related.contains(GridPosition(row: 0, col: c)))
+ #expect(related.contains(GridPosition(row: 1, col: c)))
+ #expect(related.contains(GridPosition(row: 2, col: c)))
+ }
+ }
}