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를
숨기지 않습니다.
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은 독립적으로 해석하고, 서로 다른
@@ -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 { 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<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 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);
});
@@ -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<string, unknown> => ({
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
);
@@ -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: [