test: full precision 명령의 Ref 투영 경계를 고정한다

This commit is contained in:
2026-08-24 22:16:08 +00:00
parent f08e154745
commit d4277d114b
5 changed files with 310 additions and 47 deletions
@@ -104,6 +104,12 @@ DB auto ID, 생성 시각처럼 의미 없는 차이는 comparator에서 이름
명시합니다. 의미 field를 ignore하거나 숫자 허용 범위를 넓혀 mismatch를 명시합니다. 의미 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 부재의 JSON missing·`{}`·`[]`는 snapshot 원형에서 구분합니다. 일반 message option 부재의
Ref `[]`와 Core `{}`만 의미상 같게 보며, actionable diplomacy의 `option=null` sentinel은 Ref `[]`와 Core `{}`만 의미상 같게 보며, actionable diplomacy의 `option=null` sentinel은
부재로 합치지 않습니다. Ref log prefix와 Core format은 독립적으로 해석하고, 서로 다른 부재로 합치지 않습니다. Ref log prefix와 Core format은 독립적으로 해석하고, 서로 다른
@@ -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<string, unknown>, field: string): Record<string, unknown> => {
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,
});
@@ -3,8 +3,10 @@ import path from 'node:path';
import { describe, expect, it } from 'vitest'; 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 { 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 { orderedSemanticLogStreams } from '../src/turn-differential/logProjection.js';
import { import {
projectSemanticTurnMessages, projectSemanticTurnMessages,
@@ -53,6 +55,89 @@ const timestampMillis = (value: unknown): number => {
const semanticLogSignatures = (logs: Array<Record<string, unknown>>): string[] => orderedSemanticLogStreams(logs); const semanticLogSignatures = (logs: Array<Record<string, unknown>>): 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<string, SnapshotDifference[]> = {
'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 readFixture = (relativePath: string): TurnCommandFixtureRequest => {
const stackRoot = path.join(workspaceRoot!, 'docker_compose_files/reference'); const stackRoot = path.join(workspaceRoot!, 'docker_compose_files/reference');
const fixture = JSON.parse( const fixture = JSON.parse(
@@ -281,16 +366,23 @@ integration('core ↔ legacy command-boundary differential', () => {
); );
expect(semanticLogSignatures(core.after.logs)).toEqual(semanticLogSignatures(referenceAddedLogs)); expect(semanticLogSignatures(core.after.logs)).toEqual(semanticLogSignatures(referenceAddedLogs));
} }
expect( const ignoredPathPatterns =
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { request.action === 'che_출병'
ignoredPathPatterns: ? request.includeLifecycle
request.action === 'che_출병' ? comparedLifecycleIgnoredPaths
? request.includeLifecycle : ignoredLifecyclePaths
? comparedLifecycleIgnoredPaths : [...ignoredLifecyclePaths, /^generals\[[^\]]+\]\.killTurn(?:\.|$)/];
: ignoredLifecyclePaths if (request.action === 'che_출병') {
: [...ignoredLifecyclePaths, /^generals\[[^\]]+\]\.killTurn(?:\.|$)/], const expectedRawDifferences = expectedSortieRawDifferences[label];
}) expect(expectedRawDifferences, `missing raw numeric contract for ${label}`).toBeDefined();
).toEqual([]); expectSortieDeltaParity(reference, core, ignoredPathPatterns, expectedRawDifferences!);
} else {
expect(
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
ignoredPathPatterns,
})
).toEqual([]);
}
}, },
120_000 120_000
); );
@@ -345,10 +437,11 @@ integration('core ↔ legacy command-boundary differential', () => {
unreadPrivateDelta: 1, unreadPrivateDelta: 1,
hasUnreadMessage: true, hasUnreadMessage: true,
}); });
expect( expectSortieDeltaParity(
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { reference,
ignoredPathPatterns: ignoredLifecyclePaths, core,
}) ignoredLifecyclePaths,
).toEqual([]); expectedSortieRawDifferences['collapse scout positive']!
);
}, 120_000); }, 120_000);
}); });
@@ -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 { asRecord, GAME_TICKS_PER_TURN, LEGACY_RANK_DATA_TYPES } from '@sammo-ts/common';
import { GENERAL_TURN_COMMAND_KEYS } from '@sammo-ts/logic'; 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 { runCoreTurnCommandTrace, type TurnCommandFixtureRequest } from '../src/turn-differential/coreCommandTrace.js';
import { import {
normalizeStoredTurnLogText as normalizeStoredLogText, normalizeStoredTurnLogText as normalizeStoredLogText,
orderedSemanticLogStreams, orderedSemanticLogStreams,
} from '../src/turn-differential/logProjection.js'; } from '../src/turn-differential/logProjection.js';
import {
projectSnapshotThroughRefFloatRead,
type RefFloatSnapshotProjection,
} from '../src/turn-differential/legacyNumericProjection.js';
import { import {
projectSemanticTurnMessages, projectSemanticTurnMessages,
projectSemanticUnreadMessageDeltas, projectSemanticUnreadMessageDeltas,
@@ -64,6 +69,28 @@ const successfulLifecycleIgnoredPaths = [
/^nations\[[^\]]+\]\.meta(?:\.|$)/, /^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<string, unknown> => ({ const general = (id: number, nationId: number, cityId: number, officerLevel: number): Record<string, unknown> => ({
id, id,
nationId, nationId,
@@ -508,11 +535,26 @@ integration('general command success matrix', () => {
expect(actorTurnAt(reference.after.generalTurns, 0)).toBeUndefined(); expect(actorTurnAt(reference.after.generalTurns, 0)).toBeUndefined();
expect(actorTurnAt(core.after.generalTurns, 0)).toBeUndefined(); expect(actorTurnAt(core.after.generalTurns, 0)).toBeUndefined();
} }
expect( expectRefFloatProjectedDeltaParity(
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { reference,
ignoredPathPatterns: successfulLifecycleIgnoredPaths, core,
}) successfulLifecycleIgnoredPaths,
).toEqual([]); 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. // Logs and messages live outside the generic state-delta graph.
// Assert both for every registered success case so a command cannot // 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) failureLogTexts(reference.after.logs, failureText).map(legacyActionLogBody)
); );
expect(core.rng).toEqual(reference.rng); expect(core.rng).toEqual(reference.rng);
expect( expectRefFloatProjectedDeltaParity(
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { reference,
ignoredPathPatterns: ignoredLifecyclePaths, core,
}) ignoredLifecyclePaths,
).toEqual([]); action === 'che_주민선정'
? [
{
path: 'cities[3].trust',
reference: 2.9643999999999977,
core: 2.9644093559690674,
},
]
: [],
{ cityTrust: action === 'che_주민선정' }
);
}, },
120_000 120_000
); );
@@ -3047,11 +3099,21 @@ integration('general sabotage successful effect matrix', () => {
if ('actor' in expected) { if ('actor' in expected) {
expect(findById(reference.after.generals, 1)).toMatchObject(expected.actor); expect(findById(reference.after.generals, 1)).toMatchObject(expected.actor);
} }
expect( expectRefFloatProjectedDeltaParity(
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { reference,
ignoredPathPatterns: ignoredLifecyclePaths, core,
}) ignoredLifecyclePaths,
).toEqual([]); action === 'che_선동'
? [
{
path: 'cities[70].trust',
reference: -9.8934,
core: -9.893404080791214,
},
]
: [],
{ cityTrust: action === 'che_선동' }
);
if (process.env.TURN_DIFFERENTIAL_SABOTAGE_EVIDENCE === '1') { if (process.env.TURN_DIFFERENTIAL_SABOTAGE_EVIDENCE === '1') {
process.stderr.write( process.stderr.write(
@@ -3109,11 +3171,21 @@ integration('general sabotage stat progression matrix', () => {
expect(core.rng).toEqual(reference.rng); expect(core.rng).toEqual(reference.rng);
expect(reference.after.generals.find((entry) => entry.id === 1)?.[stat]).toBe(101); 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(core.after.generals.find((entry) => entry.id === 1)?.[stat]).toBe(101);
expect( expectRefFloatProjectedDeltaParity(
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { reference,
ignoredPathPatterns: ignoredLifecyclePaths, core,
}) ignoredLifecyclePaths,
).toEqual([]); action === 'che_선동'
? [
{
path: 'cities[70].trust',
reference: -9.8934,
core: -9.893404080791214,
},
]
: [],
{ cityTrust: action === 'che_선동' }
);
}, },
120_000 120_000
); );
@@ -3171,11 +3243,21 @@ integration('general sabotage probability clamp matrix', () => {
: { operation: 'nextBits', arguments: { bits: 1 } } : { operation: 'nextBits', arguments: { bits: 1 } }
); );
expect(core.rng).toEqual(reference.rng); expect(core.rng).toEqual(reference.rng);
expect( expectRefFloatProjectedDeltaParity(
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { reference,
ignoredPathPatterns: ignoredLifecyclePaths, core,
}) ignoredLifecyclePaths,
).toEqual([]); action === 'che_선동' && boundary === 'max'
? [
{
path: 'cities[70].trust',
reference: -11.155500000000004,
core: -11.15547143003728,
},
]
: [],
{ cityTrust: action === 'che_선동' && boundary === 'max' }
);
}, },
120_000 120_000
); );
@@ -3334,11 +3416,21 @@ integration('general sabotage injury boundary matrix', () => {
injuryLogTexts(reference.after.logs).map(legacyInjuryLogBody) injuryLogTexts(reference.after.logs).map(legacyInjuryLogBody)
); );
expect(core.rng).toEqual(reference.rng); expect(core.rng).toEqual(reference.rng);
expect( expectRefFloatProjectedDeltaParity(
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { reference,
ignoredPathPatterns: ignoredLifecyclePaths, core,
}) ignoredLifecyclePaths,
).toEqual([]); action === 'che_선동'
? [
{
path: 'cities[70].trust',
reference: -4.810900000000004,
core: -4.810929741150531,
},
]
: [],
{ cityTrust: action === 'che_선동' }
);
}, },
120_000 120_000
); );
@@ -6,6 +6,10 @@ import {
compareTurnSnapshotDeltas, compareTurnSnapshotDeltas,
compareTurnSnapshots, compareTurnSnapshots,
} from '../src/turn-differential/compare.js'; } from '../src/turn-differential/compare.js';
import {
projectRefFloatRead,
projectSnapshotThroughRefFloatRead,
} from '../src/turn-differential/legacyNumericProjection.js';
const snapshot = ( const snapshot = (
engine: 'ref' | 'core2026', engine: 'ref' | 'core2026',
@@ -29,6 +33,23 @@ const snapshot = (
}); });
describe('turn snapshot differential comparator', () => { 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', () => { it('compares entity arrays by semantic identity instead of database row order', () => {
const reference = snapshot('ref', { const reference = snapshot('ref', {
cities: [ cities: [