Skip to content

Commit fc7d850

Browse files
ogsaianoek
authored andcommitted
Improve joseki detection
1 parent 46f2166 commit fc7d850

File tree

1 file changed

+253
-28
lines changed

1 file changed

+253
-28
lines changed

src/engine/ai/categorize.ts

Lines changed: 253 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
import { JGOFAIReview, JGOFNumericPlayerColor } from "../formats/JGOF";
1818
import { GobanEngine } from "../GobanEngine";
19+
import { MoveTree } from "../MoveTree";
1920

2021
export const DEFAULT_SCORE_DIFF_THRESHOLDS: ScoreDiffThresholds = {
2122
Excellent: 0.2,
@@ -44,7 +45,7 @@ export type ScoreDiffThresholds = {
4445

4546
// Joseki detection constants
4647
const STRONG_MOVE_SCORE_LOSS_THRESHOLD = 1.2;
47-
const SINGLE_MOVE_LOSS_THRESHOLD = STRONG_MOVE_SCORE_LOSS_THRESHOLD * 2;
48+
const SINGLE_MOVE_LOSS_THRESHOLD = 1.2;
4849

4950
export interface AiReviewCategorization {
5051
uuid: string;
@@ -117,38 +118,205 @@ function getMoveCutoff(size: number): number {
117118
return 20;
118119
}
119120

120-
function getZonesQuadrant(x: number, y: number, width: number, height: number): number[] {
121-
// 4 quadrants with overlap on center lines
121+
/**
122+
* Get the half-width of the center band based on board size.
123+
* Returns -1 for 9x9 (no middle zones), 0 for 13x13 (single line), 1 for 19x19 (3 lines).
124+
*/
125+
function getCenterHalfWidth(size: number): number {
126+
if (size <= 9) {
127+
return -1; // No middle zones for small boards
128+
} else if (size <= 13) {
129+
return 0; // Single center line
130+
}
131+
return 1; // 3 center lines
132+
}
133+
134+
/**
135+
* Zone adjacency map for propagation.
136+
* When a zone exits joseki, these adjacent zones also exit.
137+
*
138+
* Zone layout for 13x13+:
139+
* 0 | 4 | 1
140+
* ----+-----+----
141+
* 7 | * | 5
142+
* ----+-----+----
143+
* 2 | 6 | 3
144+
*/
145+
const ZONE_ADJACENCY: { [key: number]: number[] } = {
146+
0: [4, 7], // top-left corner → top middle, left middle
147+
1: [4, 5], // top-right corner → top middle, right middle
148+
2: [6, 7], // bottom-left corner → bottom middle, left middle
149+
3: [5, 6], // bottom-right corner → bottom middle, right middle
150+
4: [0, 1], // top middle → top-left corner, top-right corner
151+
5: [1, 3], // right middle → top-right corner, bottom-right corner
152+
6: [2, 3], // bottom middle → bottom-left corner, bottom-right corner
153+
7: [0, 2], // left middle → top-left corner, bottom-left corner
154+
};
155+
156+
/**
157+
* Propagate joseki exit from a zone to its adjacent zones.
158+
*/
159+
function propagateJosekiExit(zone: number, stillJoseki: boolean[]): void {
160+
for (const adjacentZone of ZONE_ADJACENCY[zone] ?? []) {
161+
stillJoseki[adjacentZone] = false;
162+
}
163+
}
164+
165+
// Distance from zone boundary to be considered "on the edge"
166+
const EDGE_DISTANCE = 2;
167+
168+
// Zone regions relative to center band: -1 = left/top, 0 = in center, 1 = right/bottom
169+
const ZONE_X_REGION: readonly number[] = [-1, 1, -1, 1, 0, 1, 0, -1];
170+
const ZONE_Y_REGION: readonly number[] = [-1, -1, 1, 1, -1, 0, 1, 0];
171+
172+
/**
173+
* Get adjacent zones that this position is near (within EDGE_DISTANCE of the boundary).
174+
*
175+
* Computes the shared boundary between adjacent zones geometrically based on
176+
* their relative positions, rather than enumerating cases per zone.
177+
*/
178+
function getNearbyAdjacentZones(
179+
x: number,
180+
y: number,
181+
width: number,
182+
height: number,
183+
zone: number,
184+
): number[] {
185+
const maxSize = Math.max(width, height);
186+
const halfWidth = getCenterHalfWidth(maxSize);
187+
188+
if (halfWidth < 0) {
189+
return []; // No edge detection for 9x9
190+
}
191+
122192
const centerX = Math.floor((width - 1) / 2);
123193
const centerY = Math.floor((height - 1) / 2);
124-
const zones: number[] = [];
125194

126-
if (x <= centerX) {
127-
if (y <= centerY) {
128-
zones.push(0);
195+
const left = centerX - halfWidth;
196+
const right = centerX + halfWidth;
197+
const top = centerY - halfWidth;
198+
const bottom = centerY + halfWidth;
199+
200+
const nearby: number[] = [];
201+
const zx = ZONE_X_REGION[zone];
202+
const zy = ZONE_Y_REGION[zone];
203+
204+
for (const adj of ZONE_ADJACENCY[zone] ?? []) {
205+
const ax = ZONE_X_REGION[adj];
206+
const ay = ZONE_Y_REGION[adj];
207+
208+
let near: boolean;
209+
if (zx !== ax) {
210+
// Zones differ in X - check distance to vertical boundary
211+
const boundary = zx < 0 || ax < 0 ? left : right;
212+
const approachFromLow = zx < 0 || (zx === 0 && ax > 0);
213+
near = approachFromLow ? x >= boundary - EDGE_DISTANCE : x <= boundary + EDGE_DISTANCE;
214+
} else {
215+
// Zones differ in Y - check distance to horizontal boundary
216+
const boundary = zy < 0 || ay < 0 ? top : bottom;
217+
const approachFromLow = zy < 0 || (zy === 0 && ay > 0);
218+
near = approachFromLow ? y >= boundary - EDGE_DISTANCE : y <= boundary + EDGE_DISTANCE;
129219
}
130-
if (y >= centerY) {
131-
zones.push(2);
220+
221+
if (near) {
222+
nearby.push(adj);
132223
}
133224
}
134-
if (x >= centerX) {
135-
if (y <= centerY) {
136-
zones.push(1);
225+
226+
return nearby;
227+
}
228+
229+
/**
230+
* Get the zone indices that contain a given position.
231+
*
232+
* The board is divided into 8 zones: 4 corner zones (0-3) and 4 middle zones (4-7).
233+
* Center handling varies by size:
234+
* - 9x9: No middle zones, center included in corner zones with overlap
235+
* - 13x13+: Middle zones are the center bands, center intersection is ignored
236+
*
237+
* Zone layout for 13x13+:
238+
* 0 | 4 | 1 (corners 0-3, middles 4-7)
239+
* ----+-----+----
240+
* 7 | * | 5 (* = center intersection, ignored)
241+
* ----+-----+----
242+
* 2 | 6 | 3
243+
*
244+
* For 9x9, only corner zones (0-3) are used with overlap at center.
245+
*/
246+
function getZones(x: number, y: number, width: number, height: number): number[] {
247+
const maxSize = Math.max(width, height);
248+
const halfWidth = getCenterHalfWidth(maxSize);
249+
const centerX = Math.floor((width - 1) / 2);
250+
const centerY = Math.floor((height - 1) / 2);
251+
252+
// For 9x9, only corner zones with overlap at center
253+
if (halfWidth < 0) {
254+
const zones: number[] = [];
255+
if (x <= centerX) {
256+
if (y <= centerY) {
257+
zones.push(0); // top-left
258+
}
259+
if (y >= centerY) {
260+
zones.push(2); // bottom-left
261+
}
137262
}
138-
if (y >= centerY) {
139-
zones.push(3);
263+
if (x >= centerX) {
264+
if (y <= centerY) {
265+
zones.push(1); // top-right
266+
}
267+
if (y >= centerY) {
268+
zones.push(3); // bottom-right
269+
}
140270
}
271+
return zones;
141272
}
142273

143-
return zones;
144-
}
274+
// For larger boards, check center bands for middle zones
275+
const inVerticalBand = Math.abs(x - centerX) <= halfWidth;
276+
const inHorizontalBand = Math.abs(y - centerY) <= halfWidth;
145277

146-
function getZones(x: number, y: number, width: number, height: number): number[] {
147-
return getZonesQuadrant(x, y, width, height);
278+
// Center intersection is ignored (no zones)
279+
if (inVerticalBand && inHorizontalBand) {
280+
return [];
281+
}
282+
283+
// Vertical band (top middle or bottom middle)
284+
if (inVerticalBand) {
285+
if (y < centerY - halfWidth) {
286+
return [4]; // top middle
287+
} else {
288+
return [6]; // bottom middle
289+
}
290+
}
291+
292+
// Horizontal band (left middle or right middle)
293+
if (inHorizontalBand) {
294+
if (x < centerX - halfWidth) {
295+
return [7]; // left middle
296+
} else {
297+
return [5]; // right middle
298+
}
299+
}
300+
301+
// Corner zones (outside center bands)
302+
if (x < centerX - halfWidth) {
303+
if (y < centerY - halfWidth) {
304+
return [0]; // top-left corner
305+
} else {
306+
return [2]; // bottom-left corner
307+
}
308+
} else {
309+
if (y < centerY - halfWidth) {
310+
return [1]; // top-right corner
311+
} else {
312+
return [3]; // bottom-right corner
313+
}
314+
}
148315
}
149316

150317
function getNumZones(size: number): number {
151-
return size === 19 ? 8 : 4;
318+
const halfWidth = getCenterHalfWidth(size);
319+
return halfWidth < 0 ? 4 : 8; // 4 zones for 9x9, 8 zones for larger boards
152320
}
153321

154322
interface MoveCoordinate {
@@ -180,6 +348,22 @@ interface JosekiMoves {
180348
white: Set<number>;
181349
}
182350

351+
/**
352+
* Detect joseki moves using zone-based heuristics.
353+
*
354+
* This algorithm tracks 8 zones (4 corners + 4 middles for larger boards)
355+
* and determines which moves are part of joseki (opening patterns).
356+
* A zone remains "joseki" until:
357+
* - Accumulated score loss in the zone exceeds threshold
358+
* - A single move has very high score loss (> 2.4)
359+
* - Too many moves have been played in the zone
360+
*
361+
* When a zone exits joseki, it propagates to adjacent zones:
362+
* - Corner zones propagate to their two adjacent middle zones
363+
* - Middle zones propagate to their two adjacent corner zones
364+
*
365+
* For 9x9, only corner zones (0-3) are used with overlap at center.
366+
*/
183367
function detectJosekiMoves(engine: GobanEngine, score_loss_list: ScoreLossList): JosekiMoves {
184368
const width = engine.width;
185369
const height = engine.height;
@@ -223,15 +407,42 @@ function detectJosekiMoves(engine: GobanEngine, score_loss_list: ScoreLossList):
223407
continue;
224408
}
225409

410+
// Check if move is on the edge near an adjacent zone that's not in joseki
411+
if (num_zones === 8) {
412+
const nearbyAdjacent = getNearbyAdjacentZones(x, y, width, height, zone);
413+
const adjacentNotJoseki = nearbyAdjacent.some(
414+
(adj) => !zoneState.still_joseki[adj],
415+
);
416+
if (adjacentNotJoseki) {
417+
// Bust this zone out of joseki and propagate
418+
zoneState.still_joseki[zone] = false;
419+
propagateJosekiExit(zone, zoneState.still_joseki);
420+
continue;
421+
}
422+
}
423+
226424
zoneState.moves_in_zone[zone] += 1;
227425
zoneState.zone_loss[zone] += move_loss;
228426

427+
// First move in a zone gets 2x threshold tolerance
428+
const effectiveSingleMoveThreshold =
429+
zoneState.moves_in_zone[zone] === 1
430+
? SINGLE_MOVE_LOSS_THRESHOLD * 2
431+
: SINGLE_MOVE_LOSS_THRESHOLD;
432+
433+
// Middle zones (4-7) only allow 2 joseki moves
434+
const zoneLimit = zone >= 4 ? 2 : move_cutoff;
435+
229436
if (
230437
zoneState.zone_loss[zone] > accumulated_loss_threshold ||
231-
move_loss > SINGLE_MOVE_LOSS_THRESHOLD ||
232-
zoneState.moves_in_zone[zone] > move_cutoff
438+
move_loss > effectiveSingleMoveThreshold ||
439+
zoneState.moves_in_zone[zone] > zoneLimit
233440
) {
234441
zoneState.still_joseki[zone] = false;
442+
// Propagate to adjacent zones (only for 8-zone boards)
443+
if (num_zones === 8) {
444+
propagateJosekiExit(zone, zoneState.still_joseki);
445+
}
235446
} else {
236447
is_joseki = true;
237448
}
@@ -413,10 +624,23 @@ function categorizeMoves(
413624
return { move_counters, categorized_moves };
414625
}
415626

627+
/**
628+
* Gets the number of moves in the main line (trunk) of the move tree.
629+
* This excludes variations/branches but includes pass moves.
630+
*/
631+
function getTrunkLength(moveTree: MoveTree): number {
632+
let count = 0;
633+
let current: MoveTree | undefined = moveTree.trunk_next; // Start from first move, not root
634+
while (current) {
635+
count++;
636+
current = current.trunk_next;
637+
}
638+
return count;
639+
}
640+
416641
function validateReviewData(
417642
ai_review: JGOFAIReview,
418643
engine: GobanEngine,
419-
b_player: number,
420644
): { isValid: boolean; shouldShowTable: boolean } {
421645
const is_uploaded = engine.config.original_sgf !== undefined;
422646
const scores = ai_review.scores;
@@ -425,10 +649,13 @@ function validateReviewData(
425649
return { isValid: false, shouldShowTable: true };
426650
}
427651

652+
// For uploaded SGFs, use the trunk length (main line only, excluding variations)
653+
// For regular games, use the moves array length
654+
// Both should satisfy: moves_count === scores.length - 1
655+
// (scores includes the initial position, so there's one more score than moves)
656+
const trunk_length = is_uploaded ? getTrunkLength(engine.move_tree) : 0;
428657
const check1 = !is_uploaded && engine.config.moves?.length !== scores.length - 1;
429-
const check2 =
430-
is_uploaded &&
431-
(engine.config as any)["all_moves"]?.split("!").length - b_player !== scores.length;
658+
const check2 = is_uploaded && trunk_length !== scores.length - 1;
432659

433660
if (check1 || check2) {
434661
return { isValid: false, shouldShowTable: true };
@@ -467,13 +694,11 @@ export function AIReviewData_categorize(
467694
return null;
468695
}
469696

470-
const handicap = engine.handicap;
471697
let handicap_offset = handicapOffset(engine);
472698
handicap_offset = handicap_offset === 1 ? 0 : handicap_offset;
473-
const b_player = handicap_offset > 0 || handicap > 1 ? 1 : 0;
474699
const move_player_list = getPlayerColorsMoveList(engine);
475700

476-
const { isValid } = validateReviewData(ai_review, engine, b_player);
701+
const { isValid } = validateReviewData(ai_review, engine);
477702
if (!isValid) {
478703
return null;
479704
}

0 commit comments

Comments
 (0)