From d4277d114b79645878c4bda53b9b922566c83494 Mon Sep 17 00:00:00 2001 From: hided62 Date: Mon, 24 Aug 2026 22:16:08 +0000 Subject: [PATCH] =?UTF-8?q?test:=20full=20precision=20=EB=AA=85=EB=A0=B9?= =?UTF-8?q?=EC=9D=98=20Ref=20=ED=88=AC=EC=98=81=20=EA=B2=BD=EA=B3=84?= =?UTF-8?q?=EB=A5=BC=20=EA=B3=A0=EC=A0=95=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../turn-state-differential-testing.md | 6 + .../legacyNumericProjection.ts | 51 ++++++ ...rnCommandCoreReference.integration.test.ts | 125 ++++++++++++-- ...rnCommandGeneralMatrix.integration.test.ts | 154 ++++++++++++++---- .../test/turnSnapshotComparator.test.ts | 21 +++ 5 files changed, 310 insertions(+), 47 deletions(-) create mode 100644 tools/integration-tests/src/turn-differential/legacyNumericProjection.ts diff --git a/docs/architecture/turn-state-differential-testing.md b/docs/architecture/turn-state-differential-testing.md index d46e50d3..48be9459 100644 --- a/docs/architecture/turn-state-differential-testing.md +++ b/docs/architecture/turn-state-differential-testing.md @@ -104,6 +104,12 @@ DB auto ID, 생성 시각처럼 의미 없는 차이는 comparator에서 이름 명시합니다. 의미 field를 ignore하거나 숫자 허용 범위를 넓혀 mismatch를 숨기지 않습니다. +Core가 의도적으로 full precision을 유지하는 `nation.tech`와 `city.trust`를 Ref의 +MariaDB `FLOAT` snapshot과 비교할 때는 raw 차이 경로를 fixture별로 먼저 고정합니다. +그 뒤 test-only binary32 저장·6자리 읽기 projection을 Core의 절대 before/after 값에 +적용해 Ref delta와 정확히 일치하는지 다시 검사합니다. 제품 상태를 양자화하거나 +전역 tolerance·전역 ignore로 다른 차이를 숨기지 않습니다. + JSON missing·`{}`·`[]`는 snapshot 원형에서 구분합니다. 일반 message option 부재의 Ref `[]`와 Core `{}`만 의미상 같게 보며, actionable diplomacy의 `option=null` sentinel은 부재로 합치지 않습니다. Ref log prefix와 Core format은 독립적으로 해석하고, 서로 다른 diff --git a/tools/integration-tests/src/turn-differential/legacyNumericProjection.ts b/tools/integration-tests/src/turn-differential/legacyNumericProjection.ts new file mode 100644 index 00000000..7b542a40 --- /dev/null +++ b/tools/integration-tests/src/turn-differential/legacyNumericProjection.ts @@ -0,0 +1,51 @@ +import type { CanonicalTurnSnapshot } from './canonical.js'; + +const roundHalfEven = (value: number): number => { + const lower = Math.floor(value); + const fraction = value - lower; + const tolerance = Number.EPSILON * Math.max(1, Math.abs(value)) * 4; + if (Math.abs(fraction - 0.5) <= tolerance) { + return lower % 2 === 0 ? lower : lower + 1; + } + return Math.round(value); +}; + +/** + * Test-only projection for a MariaDB FLOAT read through the Ref PHP service. + * Core product state intentionally keeps JavaScript/PostgreSQL precision; this + * oracle is only used to prove that an explicitly enumerated raw difference is + * caused by Ref's binary32 write and six-significant-digit read boundary. + */ +export const projectRefFloatRead = (value: number): number => { + const stored = Math.fround(value); + if (!Number.isFinite(stored) || stored === 0) { + return stored; + } + const sign = stored < 0 ? -1 : 1; + const absolute = Math.abs(stored); + const exponent = Math.floor(Math.log10(absolute)); + const scale = 10 ** (5 - exponent); + return sign * (roundHalfEven(absolute * scale) / scale); +}; + +export interface RefFloatSnapshotProjection { + cityTrust?: boolean; + nationTech?: boolean; +} + +const projectField = (row: Record, field: string): Record => { + const value = row[field]; + if (typeof value !== 'number' || !Number.isFinite(value)) { + return row; + } + return { ...row, [field]: projectRefFloatRead(value) }; +}; + +export const projectSnapshotThroughRefFloatRead = ( + snapshot: CanonicalTurnSnapshot, + projection: RefFloatSnapshotProjection +): CanonicalTurnSnapshot => ({ + ...snapshot, + cities: projection.cityTrust ? snapshot.cities.map((city) => projectField(city, 'trust')) : snapshot.cities, + nations: projection.nationTech ? snapshot.nations.map((nation) => projectField(nation, 'tech')) : snapshot.nations, +}); diff --git a/tools/integration-tests/test/turnCommandCoreReference.integration.test.ts b/tools/integration-tests/test/turnCommandCoreReference.integration.test.ts index dda7323e..0ff4a489 100644 --- a/tools/integration-tests/test/turnCommandCoreReference.integration.test.ts +++ b/tools/integration-tests/test/turnCommandCoreReference.integration.test.ts @@ -3,8 +3,10 @@ import path from 'node:path'; import { describe, expect, it } from 'vitest'; -import { compareTurnSnapshotDeltas } from '../src/turn-differential/compare.js'; +import type { CanonicalTurnSnapshot } from '../src/turn-differential/canonical.js'; +import { compareTurnSnapshotDeltas, type SnapshotDifference } from '../src/turn-differential/compare.js'; import { runCoreTurnCommandTrace, type TurnCommandFixtureRequest } from '../src/turn-differential/coreCommandTrace.js'; +import { projectSnapshotThroughRefFloatRead } from '../src/turn-differential/legacyNumericProjection.js'; import { orderedSemanticLogStreams } from '../src/turn-differential/logProjection.js'; import { projectSemanticTurnMessages, @@ -53,6 +55,89 @@ const timestampMillis = (value: unknown): number => { const semanticLogSignatures = (logs: Array>): string[] => orderedSemanticLogStreams(logs); +const conquestTechDifferences: SnapshotDifference[] = [ + { + path: 'nations[1].tech', + reference: 0.009999999999990905, + core: 0.006600000000048567, + }, +]; +const defenderTechDifferences: SnapshotDifference[] = [ + { + path: 'nations[1].tech', + reference: 3.6200000000000045, + core: 3.623399999999947, + }, + { + path: 'nations[2].tech', + reference: 5.190000000000055, + core: 5.19056999999998, + }, +]; +const multipleDefendersTechDifferences: SnapshotDifference[] = [ + { + path: 'nations[1].tech', + reference: 3.2100000000000364, + core: 3.210239999999999, + }, + { + path: 'nations[2].tech', + reference: 5.110000000000014, + core: 5.1083999999999605, + }, +]; +const twoNationConquestTechDifferences: SnapshotDifference[] = [ + ...conquestTechDifferences, + { + path: 'nations[2].tech', + reference: 0.009999999999990905, + core: 0.009900000000016007, + }, +]; + +const expectedSortieRawDifferences: Record = { + 'live sortie conquest': conquestTechDifferences, + 'live sortie collapsed nation conflict cleanup': conquestTechDifferences, + 'live sortie against a defending general': defenderTechDifferences, + 'live sortie against multiple defending generals': multipleDefendersTechDifferences, + 'live sortie supply retreat': [], + 'live sortie noncapital conquest': twoNationConquestTechDifferences, + 'live sortie emergency capital': twoNationConquestTechDifferences, + 'live sortie conflict arbitration': twoNationConquestTechDifferences, + 'live sortie tied conflict': twoNationConquestTechDifferences, + 'live sortie outer lifecycle: conquest': conquestTechDifferences, + 'live sortie outer lifecycle: collapsed nation conflict cleanup': conquestTechDifferences, + 'live sortie outer lifecycle: defender': defenderTechDifferences, + 'live sortie outer lifecycle: multiple defenders': multipleDefendersTechDifferences, + 'live sortie outer lifecycle: supply retreat': [], + 'live sortie outer lifecycle: noncapital conquest': twoNationConquestTechDifferences, + 'live sortie outer lifecycle: emergency capital': twoNationConquestTechDifferences, + 'live sortie outer lifecycle: conflict arbitration': twoNationConquestTechDifferences, + 'live sortie outer lifecycle: tied conflict': twoNationConquestTechDifferences, + 'collapse scout positive': conquestTechDifferences, +}; + +const expectSortieDeltaParity = ( + reference: { before: CanonicalTurnSnapshot; after: CanonicalTurnSnapshot }, + core: { before: CanonicalTurnSnapshot; after: CanonicalTurnSnapshot }, + ignoredPathPatterns: RegExp[], + expectedRawDifferences: SnapshotDifference[] +): void => { + const rawDifferences = compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { + ignoredPathPatterns, + }); + expect(rawDifferences).toEqual(expectedRawDifferences); + expect( + compareTurnSnapshotDeltas( + reference.before, + reference.after, + projectSnapshotThroughRefFloatRead(core.before, { nationTech: true }), + projectSnapshotThroughRefFloatRead(core.after, { nationTech: true }), + { ignoredPathPatterns } + ) + ).toEqual([]); +}; + const readFixture = (relativePath: string): TurnCommandFixtureRequest => { const stackRoot = path.join(workspaceRoot!, 'docker_compose_files/reference'); const fixture = JSON.parse( @@ -281,16 +366,23 @@ integration('core ↔ legacy command-boundary differential', () => { ); expect(semanticLogSignatures(core.after.logs)).toEqual(semanticLogSignatures(referenceAddedLogs)); } - expect( - compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { - ignoredPathPatterns: - request.action === 'che_출병' - ? request.includeLifecycle - ? comparedLifecycleIgnoredPaths - : ignoredLifecyclePaths - : [...ignoredLifecyclePaths, /^generals\[[^\]]+\]\.killTurn(?:\.|$)/], - }) - ).toEqual([]); + const ignoredPathPatterns = + request.action === 'che_출병' + ? request.includeLifecycle + ? comparedLifecycleIgnoredPaths + : ignoredLifecyclePaths + : [...ignoredLifecyclePaths, /^generals\[[^\]]+\]\.killTurn(?:\.|$)/]; + if (request.action === 'che_출병') { + const expectedRawDifferences = expectedSortieRawDifferences[label]; + expect(expectedRawDifferences, `missing raw numeric contract for ${label}`).toBeDefined(); + expectSortieDeltaParity(reference, core, ignoredPathPatterns, expectedRawDifferences!); + } else { + expect( + compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { + ignoredPathPatterns, + }) + ).toEqual([]); + } }, 120_000 ); @@ -345,10 +437,11 @@ integration('core ↔ legacy command-boundary differential', () => { unreadPrivateDelta: 1, hasUnreadMessage: true, }); - expect( - compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { - ignoredPathPatterns: ignoredLifecyclePaths, - }) - ).toEqual([]); + expectSortieDeltaParity( + reference, + core, + ignoredLifecyclePaths, + expectedSortieRawDifferences['collapse scout positive']! + ); }, 120_000); }); diff --git a/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts b/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts index 41820b50..1c77729b 100644 --- a/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts +++ b/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts @@ -2,12 +2,17 @@ import { describe, expect, it } from 'vitest'; import { asRecord, GAME_TICKS_PER_TURN, LEGACY_RANK_DATA_TYPES } from '@sammo-ts/common'; import { GENERAL_TURN_COMMAND_KEYS } from '@sammo-ts/logic'; -import { compareTurnSnapshotDeltas } from '../src/turn-differential/compare.js'; +import type { CanonicalTurnSnapshot } from '../src/turn-differential/canonical.js'; +import { compareTurnSnapshotDeltas, type SnapshotDifference } from '../src/turn-differential/compare.js'; import { runCoreTurnCommandTrace, type TurnCommandFixtureRequest } from '../src/turn-differential/coreCommandTrace.js'; import { normalizeStoredTurnLogText as normalizeStoredLogText, orderedSemanticLogStreams, } from '../src/turn-differential/logProjection.js'; +import { + projectSnapshotThroughRefFloatRead, + type RefFloatSnapshotProjection, +} from '../src/turn-differential/legacyNumericProjection.js'; import { projectSemanticTurnMessages, projectSemanticUnreadMessageDeltas, @@ -64,6 +69,28 @@ const successfulLifecycleIgnoredPaths = [ /^nations\[[^\]]+\]\.meta(?:\.|$)/, ]; +const expectRefFloatProjectedDeltaParity = ( + reference: { before: CanonicalTurnSnapshot; after: CanonicalTurnSnapshot }, + core: { before: CanonicalTurnSnapshot; after: CanonicalTurnSnapshot }, + ignoredPathPatterns: RegExp[], + expectedRawDifferences: SnapshotDifference[], + projection: RefFloatSnapshotProjection +): void => { + const rawDifferences = compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { + ignoredPathPatterns, + }); + expect(rawDifferences).toEqual(expectedRawDifferences); + expect( + compareTurnSnapshotDeltas( + reference.before, + reference.after, + projectSnapshotThroughRefFloatRead(core.before, projection), + projectSnapshotThroughRefFloatRead(core.after, projection), + { ignoredPathPatterns } + ) + ).toEqual([]); +}; + const general = (id: number, nationId: number, cityId: number, officerLevel: number): Record => ({ id, nationId, @@ -508,11 +535,26 @@ integration('general command success matrix', () => { expect(actorTurnAt(reference.after.generalTurns, 0)).toBeUndefined(); expect(actorTurnAt(core.after.generalTurns, 0)).toBeUndefined(); } - expect( - compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { - ignoredPathPatterns: successfulLifecycleIgnoredPaths, - }) - ).toEqual([]); + expectRefFloatProjectedDeltaParity( + reference, + core, + successfulLifecycleIgnoredPaths, + action === 'che_출병' + ? [ + { + path: 'nations[1].tech', + reference: { $snapshotState: 'missing' }, + core: 0.004800000000045657, + }, + { + path: 'nations[2].tech', + reference: 0.009999999999990905, + core: 0.009000000000014552, + }, + ] + : [], + { nationTech: action === 'che_출병' } + ); // Logs and messages live outside the generic state-delta graph. // Assert both for every registered success case so a command cannot @@ -2647,11 +2689,21 @@ integration('general command in-action failure matrix', () => { failureLogTexts(reference.after.logs, failureText).map(legacyActionLogBody) ); expect(core.rng).toEqual(reference.rng); - expect( - compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { - ignoredPathPatterns: ignoredLifecyclePaths, - }) - ).toEqual([]); + expectRefFloatProjectedDeltaParity( + reference, + core, + ignoredLifecyclePaths, + action === 'che_주민선정' + ? [ + { + path: 'cities[3].trust', + reference: 2.9643999999999977, + core: 2.9644093559690674, + }, + ] + : [], + { cityTrust: action === 'che_주민선정' } + ); }, 120_000 ); @@ -3047,11 +3099,21 @@ integration('general sabotage successful effect matrix', () => { if ('actor' in expected) { expect(findById(reference.after.generals, 1)).toMatchObject(expected.actor); } - expect( - compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { - ignoredPathPatterns: ignoredLifecyclePaths, - }) - ).toEqual([]); + expectRefFloatProjectedDeltaParity( + reference, + core, + ignoredLifecyclePaths, + action === 'che_선동' + ? [ + { + path: 'cities[70].trust', + reference: -9.8934, + core: -9.893404080791214, + }, + ] + : [], + { cityTrust: action === 'che_선동' } + ); if (process.env.TURN_DIFFERENTIAL_SABOTAGE_EVIDENCE === '1') { process.stderr.write( @@ -3109,11 +3171,21 @@ integration('general sabotage stat progression matrix', () => { expect(core.rng).toEqual(reference.rng); expect(reference.after.generals.find((entry) => entry.id === 1)?.[stat]).toBe(101); expect(core.after.generals.find((entry) => entry.id === 1)?.[stat]).toBe(101); - expect( - compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { - ignoredPathPatterns: ignoredLifecyclePaths, - }) - ).toEqual([]); + expectRefFloatProjectedDeltaParity( + reference, + core, + ignoredLifecyclePaths, + action === 'che_선동' + ? [ + { + path: 'cities[70].trust', + reference: -9.8934, + core: -9.893404080791214, + }, + ] + : [], + { cityTrust: action === 'che_선동' } + ); }, 120_000 ); @@ -3171,11 +3243,21 @@ integration('general sabotage probability clamp matrix', () => { : { operation: 'nextBits', arguments: { bits: 1 } } ); expect(core.rng).toEqual(reference.rng); - expect( - compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { - ignoredPathPatterns: ignoredLifecyclePaths, - }) - ).toEqual([]); + expectRefFloatProjectedDeltaParity( + reference, + core, + ignoredLifecyclePaths, + action === 'che_선동' && boundary === 'max' + ? [ + { + path: 'cities[70].trust', + reference: -11.155500000000004, + core: -11.15547143003728, + }, + ] + : [], + { cityTrust: action === 'che_선동' && boundary === 'max' } + ); }, 120_000 ); @@ -3334,11 +3416,21 @@ integration('general sabotage injury boundary matrix', () => { injuryLogTexts(reference.after.logs).map(legacyInjuryLogBody) ); expect(core.rng).toEqual(reference.rng); - expect( - compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { - ignoredPathPatterns: ignoredLifecyclePaths, - }) - ).toEqual([]); + expectRefFloatProjectedDeltaParity( + reference, + core, + ignoredLifecyclePaths, + action === 'che_선동' + ? [ + { + path: 'cities[70].trust', + reference: -4.810900000000004, + core: -4.810929741150531, + }, + ] + : [], + { cityTrust: action === 'che_선동' } + ); }, 120_000 ); diff --git a/tools/integration-tests/test/turnSnapshotComparator.test.ts b/tools/integration-tests/test/turnSnapshotComparator.test.ts index 6affcda0..d7a49ada 100644 --- a/tools/integration-tests/test/turnSnapshotComparator.test.ts +++ b/tools/integration-tests/test/turnSnapshotComparator.test.ts @@ -6,6 +6,10 @@ import { compareTurnSnapshotDeltas, compareTurnSnapshots, } from '../src/turn-differential/compare.js'; +import { + projectRefFloatRead, + projectSnapshotThroughRefFloatRead, +} from '../src/turn-differential/legacyNumericProjection.js'; const snapshot = ( engine: 'ref' | 'core2026', @@ -29,6 +33,23 @@ const snapshot = ( }); describe('turn snapshot differential comparator', () => { + it('projects only explicitly selected Core numeric state through the Ref FLOAT boundary', () => { + expect(projectRefFloatRead(1_000.0048)).toBe(1_000); + expect(projectRefFloatRead(1_000.009)).toBe(1_000.01); + expect(projectRefFloatRead(70.10659591920879)).toBe(70.1066); + + const core = snapshot('core2026', { + cities: [{ id: 1, trust: 70.10659591920879, agriculture: 123.456789 }], + nations: [{ id: 1, tech: 1_000.009, gold: 123.456789 }], + }); + const projected = projectSnapshotThroughRefFloatRead(core, { cityTrust: true, nationTech: true }); + + expect(projected.cities[0]).toEqual({ id: 1, trust: 70.1066, agriculture: 123.456789 }); + expect(projected.nations[0]).toEqual({ id: 1, tech: 1_000.01, gold: 123.456789 }); + expect(core.cities[0]?.trust).toBe(70.10659591920879); + expect(core.nations[0]?.tech).toBe(1_000.009); + }); + it('compares entity arrays by semantic identity instead of database row order', () => { const reference = snapshot('ref', { cities: [