test: full precision 명령의 Ref 투영 경계를 고정한다
This commit is contained in:
@@ -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: [
|
||||
|
||||
Reference in New Issue
Block a user