import { validateDocumentStructure } from "./schema-validator.js"; import { describeFrequencyAliasing, MAX_PRECISE_ANALOG_FREQUENCY_HZ, observeFrequencyBand, } from "../acoustics-core/sampling.js"; import { DEFAULT_MAXIMUM_SYNTHESIS_BUFFER_BYTES, estimateSynthesisWorkingSet, } from "../acoustics-core/propagation.js"; import { DEFAULT_MAX_COMPENSATION_DB } from "../acoustics-core/frequency-response.js"; import { DEFAULT_MAXIMUM_MUSIC_JACOBI_PAIR_UPDATES, } from "../acoustics-core/music.js"; export const INPUT_SCHEMA_VERSION = "1.0.0"; export const RESULT_SCHEMA_VERSION = "1.1.0"; const MAX_MICROPHONES = 1024; const MAX_SOURCES = 32; const MAX_GRID_SIDE = 512; const MAX_FREQUENCY_RESPONSE_POINTS = 64; const MAX_FREQUENCY_RESPONSE_HZ = 1_000_000; const LOCALIZATION_ALGORITHMS = ["das", "das-phat", "gcc-phat", "music"]; /** @typedef {{ path: string, message: string }} ValidationIssue */ function isRecord(value) { return value !== null && typeof value === "object" && !Array.isArray(value); } function isFiniteNumber(value) { return typeof value === "number" && Number.isFinite(value); } function samplingMetadataEqual(actual, expected) { if (typeof actual === "number" && typeof expected === "number") { const tolerance = 1e-9 + 1e-12 * Math.max(Math.abs(actual), Math.abs(expected)); return Number.isFinite(actual) && Number.isFinite(expected) && Math.abs(actual - expected) <= tolerance; } if (Array.isArray(actual) || Array.isArray(expected)) { return Array.isArray(actual) && Array.isArray(expected) && actual.length === expected.length && actual.every((value, index) => samplingMetadataEqual(value, expected[index])); } if (isRecord(actual) || isRecord(expected)) { if (!isRecord(actual) || !isRecord(expected)) return false; const expectedKeys = Object.keys(expected); return expectedKeys.every((key) => samplingMetadataEqual(actual[key], expected[key])); } return actual === expected; } function addIssue(issues, path, message) { issues.push({ path, message }); } function requireRecord(value, path, issues) { if (!isRecord(value)) { addIssue(issues, path, "객체여야 합니다."); return false; } return true; } function requireString(value, path, issues) { if (typeof value !== "string" || value.trim() === "") { addIssue(issues, path, "비어 있지 않은 문자열이어야 합니다."); return false; } return true; } function requireFinite(value, path, issues, options = {}) { const { min = -Infinity, max = Infinity, exclusiveMin = false } = options; if (!isFiniteNumber(value)) { addIssue(issues, path, "유한한 숫자여야 합니다."); return false; } if ((exclusiveMin && value <= min) || (!exclusiveMin && value < min) || value > max) { addIssue(issues, path, `${exclusiveMin ? ">" : "≥"} ${min}, ≤ ${max} 범위여야 합니다.`); return false; } return true; } function requireInteger(value, path, issues, options = {}) { if (!Number.isInteger(value)) { addIssue(issues, path, "정수여야 합니다."); return false; } return requireFinite(value, path, issues, options); } function requirePosition(value, path, issues) { if (!Array.isArray(value) || value.length !== 3) { addIssue(issues, path, "[x, y, z] 숫자 3개여야 합니다."); return false; } value.forEach((coordinate, index) => requireFinite(coordinate, `${path}[${index}]`, issues)); return true; } function requireBounds(value, path, issues) { if (!requireRecord(value, path, issues)) return false; const minimumValid = requirePosition(value.min, path + ".min", issues); const maximumValid = requirePosition(value.max, path + ".max", issues); if (!minimumValid || !maximumValid) return false; let ordered = true; for (let axis = 0; axis < 3; axis += 1) { if (value.min[axis] >= value.max[axis]) { addIssue(issues, path, "min 좌표는 모든 축에서 max 좌표보다 작아야 합니다."); ordered = false; break; } } return ordered; } function boundsContain(bounds, position) { return position.every( (coordinate, axis) => coordinate >= bounds.min[axis] && coordinate <= bounds.max[axis], ); } function validateHeader(value, expectedKind, issues) { if (!requireRecord(value, "$", issues)) return false; if (value.schemaVersion !== INPUT_SCHEMA_VERSION) { addIssue(issues, "$.schemaVersion", `지원 버전은 ${INPUT_SCHEMA_VERSION}입니다.`); } if (value.kind !== expectedKind) addIssue(issues, "$.kind", `${expectedKind}이어야 합니다.`); requireString(value.id, "$.id", issues); requireString(value.name, "$.name", issues); return true; } function findDuplicates(values) { const seen = new Set(); const duplicates = new Set(); for (const value of values) { if (seen.has(value)) duplicates.add(value); seen.add(value); } return [...duplicates]; } function isSafeRelativeAssetPath(value) { if (typeof value !== "string" || value.trim() === "") return false; const normalized = value.split(String.fromCharCode(92)).join("/"); return !normalized.startsWith("/") && !/^[a-z][a-z0-9+.-]*:/i.test(normalized) && !normalized.split("/").includes("..") && !normalized.includes("\0"); } function validateArrayRelationships(value) { /** @type {ValidationIssue[]} */ const issues = []; if (!validateHeader(value, "micarrsim.array", issues)) return issues; if (value.units !== "m") addIssue(issues, "$.units", "m이어야 합니다."); if (requireRecord(value.coordinateSystem, "$.coordinateSystem", issues)) { const expected = { handedness: "right", xAxis: "right", yAxis: "up", zAxis: "toward-scene" }; for (const [key, expectedValue] of Object.entries(expected)) { if (value.coordinateSystem[key] !== expectedValue) { addIssue(issues, `$.coordinateSystem.${key}`, `${expectedValue}이어야 합니다.`); } } } if (requireRecord(value.aperture, "$.aperture", issues)) { requireFinite(value.aperture.width, "$.aperture.width", issues, { min: 0, max: 20, exclusiveMin: true }); requireFinite(value.aperture.height, "$.aperture.height", issues, { min: 0, max: 20, exclusiveMin: true }); } if (!Array.isArray(value.microphones) || value.microphones.length === 0) { addIssue(issues, "$.microphones", "마이크가 하나 이상 필요합니다."); return issues; } if (value.microphones.length > MAX_MICROPHONES) { addIssue(issues, "$.microphones", `마이크는 최대 ${MAX_MICROPHONES}개까지 지원합니다.`); } const ids = []; value.microphones.forEach((microphone, index) => { const path = `$.microphones[${index}]`; if (!requireRecord(microphone, path, issues)) return; if (requireString(microphone.id, `${path}.id`, issues)) ids.push(microphone.id); requirePosition(microphone.position, `${path}.position`, issues); if (microphone.sensitivityDb !== undefined) { requireFinite(microphone.sensitivityDb, `${path}.sensitivityDb`, issues, { min: -120, max: 120 }); } if (microphone.frequencyResponse !== undefined) { const responsePath = `${path}.frequencyResponse`; if (!Array.isArray(microphone.frequencyResponse)) { addIssue(issues, responsePath, "주파수 응답점 배열이어야 합니다."); } else { if (microphone.frequencyResponse.length < 2 || microphone.frequencyResponse.length > MAX_FREQUENCY_RESPONSE_POINTS) { addIssue(issues, responsePath, `주파수 응답점은 2~${MAX_FREQUENCY_RESPONSE_POINTS}개여야 합니다.`); } let previousFrequency = -Infinity; microphone.frequencyResponse.forEach((point, pointIndex) => { const pointPath = `${responsePath}[${pointIndex}]`; if (!requireRecord(point, pointPath, issues)) return; const frequencyValid = requireFinite(point.frequencyHz, `${pointPath}.frequencyHz`, issues, { min: 0, max: MAX_FREQUENCY_RESPONSE_HZ, }); requireFinite(point.gainDb, `${pointPath}.gainDb`, issues, { min: -120, max: 120 }); if (point.phaseDeg !== undefined) { requireFinite(point.phaseDeg, `${pointPath}.phaseDeg`, issues, { min: -180, max: 180 }); } if (frequencyValid && point.frequencyHz <= previousFrequency) { addIssue( issues, `${pointPath}.frequencyHz`, "주파수 응답점은 frequencyHz가 중복 없이 엄격히 증가하도록 정렬하세요.", ); } if (frequencyValid) previousFrequency = point.frequencyHz; }); } } }); for (const duplicate of findDuplicates(ids)) { addIssue(issues, "$.microphones", `중복 마이크 id: ${duplicate}`); } return issues; } function validateSignal(signal, path, issues, assetsById) { if (!requireRecord(signal, path, issues)) return; if (signal.type === "tone") { requireFinite(signal.frequency, `${path}.frequency`, issues, { min: 0, max: MAX_PRECISE_ANALOG_FREQUENCY_HZ, exclusiveMin: true, }); if (signal.phaseDeg !== undefined) { requireFinite(signal.phaseDeg, `${path}.phaseDeg`, issues, { min: -360, max: 360 }); } } else if (signal.type === "noise") { if (!["white", "pink"].includes(signal.color)) { addIssue(issues, `${path}.color`, "white 또는 pink여야 합니다."); } } else if (signal.type === "asset") { if (requireString(signal.assetId, `${path}.assetId`, issues)) { const asset = assetsById.get(signal.assetId); if (!asset) addIssue(issues, `${path}.assetId`, `정의되지 않은 asset: ${signal.assetId}`); else if (asset.kind !== "audio") addIssue(issues, `${path}.assetId`, "audio asset을 참조해야 합니다."); } } else { addIssue(issues, `${path}.type`, "tone, noise 또는 asset이어야 합니다."); } } function validateSceneRelationships(value) { /** @type {ValidationIssue[]} */ const issues = []; if (!validateHeader(value, "micarrsim.scene", issues)) return issues; if (value.units !== "m") addIssue(issues, "$.units", "m이어야 합니다."); const sampleRateValid = requireInteger(value.sampleRate, "$.sampleRate", issues, { min: 8000, max: 192000 }); const sceneDurationValid = requireFinite(value.duration, "$.duration", issues, { min: 0, max: 300, exclusiveMin: true, }); requireFinite(value.speedOfSound, "$.speedOfSound", issues, { min: 250, max: 400 }); requireInteger(value.seed, "$.seed", issues, { min: 0, max: 0xffffffff }); let acousticRoom = null; if (value.acoustics !== undefined && requireRecord(value.acoustics, "$.acoustics", issues)) { const acoustics = value.acoustics; if (acoustics.model === "free-field") { const unusedFields = ["room", "reflections", "diffuseReverb", "occluders"] .filter((key) => acoustics[key] !== undefined); if (unusedFields.length > 0) { addIssue( issues, "$.acoustics", "free-field 모델에서는 사용되지 않는 설정을 제거하세요: " + unusedFields.join(", "), ); } } else if (acoustics.model === "shoebox") { if (acoustics.room === undefined) { addIssue(issues, "$.acoustics.room", "shoebox 모델에는 room 경계가 필요합니다."); } else if (requireBounds(acoustics.room, "$.acoustics.room", issues)) { acousticRoom = acoustics.room; } } if (Array.isArray(acoustics.occluders)) { const occluderIds = []; acoustics.occluders.forEach((occluder, index) => { const path = "$.acoustics.occluders[" + index + "]"; if (!requireRecord(occluder, path, issues)) return; if (requireString(occluder.id, path + ".id", issues)) occluderIds.push(occluder.id); if (requireBounds(occluder, path, issues) && acousticRoom) { if (!boundsContain(acousticRoom, occluder.min) || !boundsContain(acousticRoom, occluder.max)) { addIssue(issues, path, "occluder는 room 경계 안에 있어야 합니다."); } } }); for (const duplicate of findDuplicates(occluderIds)) { addIssue(issues, "$.acoustics.occluders", "중복 occluder id: " + duplicate); } } } const assets = Array.isArray(value.assets) ? value.assets : []; const assetsById = new Map(); const allIds = []; assets.forEach((asset, index) => { const path = `$.assets[${index}]`; if (!requireRecord(asset, path, issues)) return; if (requireString(asset.id, `${path}.id`, issues)) { assetsById.set(asset.id, asset); allIds.push(asset.id); } if (requireString(asset.fileName, `${path}.fileName`, issues) && !isSafeRelativeAssetPath(asset.fileName)) { addIssue(issues, `${path}.fileName`, "manifest 기준의 안전한 상대 경로여야 합니다."); } requireString(asset.mimeType, `${path}.mimeType`, issues); if (!["audio", "image"].includes(asset.kind)) addIssue(issues, `${path}.kind`, "audio 또는 image여야 합니다."); }); for (const duplicate of findDuplicates(allIds)) addIssue(issues, "$.assets", `중복 asset id: ${duplicate}`); if (value.image !== undefined && requireRecord(value.image, "$.image", issues)) { if (requireString(value.image.assetId, "$.image.assetId", issues)) { const asset = assetsById.get(value.image.assetId); if (!asset) addIssue(issues, "$.image.assetId", `정의되지 않은 asset: ${value.image.assetId}`); else if (asset.kind !== "image") addIssue(issues, "$.image.assetId", "image asset을 참조해야 합니다."); } if (requireRecord(value.image.plane, "$.image.plane", issues)) { const plane = value.image.plane; requireFinite(plane.z, "$.image.plane.z", issues, { min: 0, exclusiveMin: true }); for (const key of ["xMin", "xMax", "yMin", "yMax"]) { requireFinite(plane[key], `$.image.plane.${key}`, issues); } if (isFiniteNumber(plane.xMin) && isFiniteNumber(plane.xMax) && plane.xMin >= plane.xMax) { addIssue(issues, "$.image.plane", "xMin은 xMax보다 작아야 합니다."); } if (isFiniteNumber(plane.yMin) && isFiniteNumber(plane.yMax) && plane.yMin >= plane.yMax) { addIssue(issues, "$.image.plane", "yMin은 yMax보다 작아야 합니다."); } } } if (!Array.isArray(value.sources) || value.sources.length === 0) { addIssue(issues, "$.sources", "음원이 하나 이상 필요합니다."); return issues; } if (value.sources.length > MAX_SOURCES) { addIssue(issues, "$.sources", `음원은 최대 ${MAX_SOURCES}개까지 지원합니다.`); } const sourceIds = []; value.sources.forEach((source, index) => { const path = `$.sources[${index}]`; if (!requireRecord(source, path, issues)) return; if (requireString(source.id, `${path}.id`, issues)) sourceIds.push(source.id); requireString(source.name, `${path}.name`, issues); const sourcePositionValid = requirePosition(source.position, `${path}.position`, issues); if (sourcePositionValid && acousticRoom && !boundsContain(acousticRoom, source.position)) { addIssue(issues, `${path}.position`, "shoebox room 경계 안에 있어야 합니다."); } if (Array.isArray(source.trajectory)) { let previousTime = -Infinity; source.trajectory.forEach((keyframe, keyframeIndex) => { const keyframePath = `${path}.trajectory[${keyframeIndex}]`; if (!requireRecord(keyframe, keyframePath, issues)) return; const timeValid = requireFinite(keyframe.time, `${keyframePath}.time`, issues, { min: 0, max: sceneDurationValid ? value.duration : 300, }); const keyframePositionValid = requirePosition(keyframe.position, `${keyframePath}.position`, issues); if (keyframePositionValid && acousticRoom && !boundsContain(acousticRoom, keyframe.position)) { addIssue(issues, `${keyframePath}.position`, "shoebox room 경계 안에 있어야 합니다."); } if (timeValid && keyframe.time <= previousTime) { addIssue(issues, `${keyframePath}.time`, "trajectory time은 앞 keyframe보다 커야 합니다."); } if (timeValid) previousTime = keyframe.time; }); } requireFinite(source.gainDb, `${path}.gainDb`, issues, { min: -120, max: 24 }); const startValid = requireFinite(source.startTime, `${path}.startTime`, issues, { min: 0 }); const durationValid = requireFinite(source.duration, `${path}.duration`, issues, { min: 0, max: 300, exclusiveMin: true, }); if (sceneDurationValid && startValid && durationValid && source.startTime + source.duration > value.duration + 1e-9) { addIssue(issues, path, "음원 구간이 장면 duration을 벗어납니다."); } validateSignal(source.signal, `${path}.signal`, issues, assetsById); }); for (const duplicate of findDuplicates(sourceIds)) addIssue(issues, "$.sources", `중복 음원 id: ${duplicate}`); if (Array.isArray(value.ambient)) { const ambientIds = []; value.ambient.forEach((ambient, index) => { const path = `$.ambient[${index}]`; if (!requireRecord(ambient, path, issues)) return; if (requireString(ambient.id, `${path}.id`, issues)) ambientIds.push(ambient.id); requireFinite(ambient.gainDb, `${path}.gainDb`, issues, { min: -120, max: 24 }); validateSignal(ambient.signal, `${path}.signal`, issues, assetsById); }); for (const duplicate of findDuplicates(ambientIds)) addIssue(issues, "$.ambient", `중복 ambient id: ${duplicate}`); } return issues; } function validateExperimentRelationships(value, context = {}) { /** @type {ValidationIssue[]} */ const issues = []; if (!validateHeader(value, "micarrsim.experiment", issues)) return issues; requireString(value.arrayId, "$.arrayId", issues); requireString(value.sceneId, "$.sceneId", issues); if (requireRecord(value.scan, "$.scan", issues)) { requireFinite(value.scan.z, "$.scan.z", issues, { min: 0, exclusiveMin: true }); for (const key of ["xMin", "xMax", "yMin", "yMax"]) { requireFinite(value.scan[key], `$.scan.${key}`, issues); } requireInteger(value.scan.columns, "$.scan.columns", issues, { min: 8, max: MAX_GRID_SIDE }); requireInteger(value.scan.rows, "$.scan.rows", issues, { min: 8, max: MAX_GRID_SIDE }); if (isFiniteNumber(value.scan.xMin) && isFiniteNumber(value.scan.xMax) && value.scan.xMin >= value.scan.xMax) { addIssue(issues, "$.scan", "xMin은 xMax보다 작아야 합니다."); } if (isFiniteNumber(value.scan.yMin) && isFiniteNumber(value.scan.yMax) && value.scan.yMin >= value.scan.yMax) { addIssue(issues, "$.scan", "yMin은 yMax보다 작아야 합니다."); } } if (!Array.isArray(value.frequencyBands) || value.frequencyBands.length === 0) { addIssue(issues, "$.frequencyBands", "주파수 대역이 하나 이상 필요합니다."); } else { const bandIds = []; value.frequencyBands.forEach((band, index) => { const path = `$.frequencyBands[${index}]`; if (!requireRecord(band, path, issues)) return; if (requireString(band.id, `${path}.id`, issues)) bandIds.push(band.id); const minValid = requireFinite(band.minHz, `${path}.minHz`, issues, { min: 0, max: MAX_PRECISE_ANALOG_FREQUENCY_HZ, }); const maxValid = requireFinite(band.maxHz, `${path}.maxHz`, issues, { min: 0, max: MAX_PRECISE_ANALOG_FREQUENCY_HZ, exclusiveMin: true, }); if (minValid && maxValid && band.minHz >= band.maxHz) { addIssue(issues, path, "minHz는 maxHz보다 작아야 합니다."); } if (typeof band.color !== "string" || !/^#[0-9a-f]{6}$/i.test(band.color)) { addIssue(issues, `${path}.color`, "#RRGGBB 색상이어야 합니다."); } }); for (const duplicate of findDuplicates(bandIds)) addIssue(issues, "$.frequencyBands", `중복 band id: ${duplicate}`); } if (requireRecord(value.stft, "$.stft", issues)) { if (![128, 256, 512, 1024, 2048, 4096].includes(value.stft.fftSize)) { addIssue(issues, "$.stft.fftSize", "지원되는 2의 거듭제곱 크기가 아닙니다."); } if (!Number.isInteger(value.stft.hopSize) || value.stft.hopSize < 1 || value.stft.hopSize > value.stft.fftSize) { addIssue(issues, "$.stft.hopSize", "1 이상 fftSize 이하의 정수여야 합니다."); } if (value.stft.window !== "hann") addIssue(issues, "$.stft.window", "hann이어야 합니다."); } if ( context.scene && Number.isFinite(context.scene.sampleRate) && Number.isInteger(value.stft?.fftSize) && Array.isArray(value.frequencyBands) ) { value.frequencyBands.forEach((band, index) => { try { const observation = observeFrequencyBand( band, context.scene.sampleRate, value.stft.fftSize, ); if ( value.beamforming?.algorithm === "music" && observation.bins.length !== 1 ) { addIssue( issues, `$.frequencyBands[${index}]`, "표준 narrowband MUSIC band는 관측 ADC FFT bin center를 정확히 하나만 선택해야 합니다.", ); } if (value.beamforming?.algorithm === "music") { const binWidthHz = context.scene.sampleRate / value.stft.fftSize; const firstAnalogBin = Math.ceil( band.minHz / binWidthHz, ); const lastAnalogBin = Math.floor( band.maxHz / binWidthHz, ); if (firstAnalogBin !== lastAnalogBin) { addIssue( issues, `$.frequencyBands[${index}]`, "Standard narrowband MUSIC band must contain exactly one configured analog FFT-grid hypothesis. Narrow the band or create separate bands; no alias-order selection or map fusion is applied.", ); } } if ( value.beamforming?.algorithm === "music" && observation.bins.length === 1 && ( observation.bins[0] === 0 || observation.bins[0] === value.stft.fftSize / 2 ) ) { addIssue( issues, `$.frequencyBands[${index}]`, "DC와 Nyquist FFT bin은 실수 샘플의 quadrature 위상이 퇴화하므로 narrowband MUSIC에 사용할 수 없습니다.", ); } } catch (error) { addIssue( issues, `$.frequencyBands[${index}]`, `선택한 FFT grid의 bin center가 없는 대역입니다: ${error.message}`, ); } }); } if (requireRecord(value.beamforming, "$.beamforming", issues)) { if (!LOCALIZATION_ALGORITHMS.includes(value.beamforming.algorithm)) { addIssue( issues, "$.beamforming.algorithm", "das, das-phat, gcc-phat 또는 music이어야 합니다.", ); } if (!["auto", "cpu", "webgpu"].includes(value.beamforming.backend)) { addIssue(issues, "$.beamforming.backend", "auto, cpu 또는 webgpu여야 합니다."); } if ( ["gcc-phat", "music"].includes(value.beamforming.algorithm) && value.beamforming.backend === "webgpu" ) { addIssue( issues, "$.beamforming.backend", "선택한 위치 추정 알고리즘은 현재 CPU backend만 지원합니다. auto 또는 cpu를 선택하세요.", ); } requireFinite(value.beamforming.minimumDistance, "$.beamforming.minimumDistance", issues, { min: 0, max: 10, exclusiveMin: true, }); if ( value.beamforming.compensateFrequencyResponse !== undefined && typeof value.beamforming.compensateFrequencyResponse !== "boolean" ) { addIssue( issues, "$.beamforming.compensateFrequencyResponse", "주파수 응답 보정 사용 여부는 true 또는 false여야 합니다.", ); } if (value.beamforming.maxCompensationDb !== undefined) { requireFinite( value.beamforming.maxCompensationDb, "$.beamforming.maxCompensationDb", issues, { min: 0, max: 120 }, ); } if (value.beamforming.algorithm === "music") { const method = value.beamforming.musicSourceCountMethod ?? "fixed"; if (value.beamforming.normalization !== "none") { addIssue( issues, "$.beamforming.normalization", "MUSIC raw noise-projection은 정규화하거나 뒤집지 않으므로 normalization은 none이어야 합니다.", ); } if (!["fixed", "mdl", "aic"].includes(method)) { addIssue( issues, "$.beamforming.musicSourceCountMethod", "fixed, mdl 또는 aic여야 합니다.", ); } if ( value.beamforming.musicSourceCount !== undefined || method === "fixed" ) { requireInteger( value.beamforming.musicSourceCount, "$.beamforming.musicSourceCount", issues, { min: 1, max: MAX_MICROPHONES - 1 }, ); } if ( (method === "mdl" || method === "aic") && value.stft?.hopSize !== value.stft?.fftSize ) { addIssue( issues, "$.stft.hopSize", "Wax–Kailath MDL/AIC의 IID snapshot 모델을 적용하려면 최소한 frame이 겹치지 않아야 하므로 hopSize가 fftSize와 같아야 합니다. 비중첩 자체가 통계적 독립성을 증명하지는 않습니다.", ); } } } if (value.evaluation !== undefined && requireRecord(value.evaluation, "$.evaluation", issues)) { const threshold = value.evaluation.detectionThresholdRatio; if (!isFiniteNumber(threshold) || threshold <= 0 || threshold > 1) { addIssue( issues, "$.evaluation.detectionThresholdRatio", "피크 검출 임계값 비율은 0보다 크고 1 이하여야 합니다. 더 약한 피크를 찾으려면 값을 낮추세요.", ); } } if (context.arrayDefinition && context.scene) { if ( value.beamforming?.algorithm === "gcc-phat" && context.arrayDefinition.microphones.length < 2 ) { addIssue( issues, "$.beamforming.algorithm", "gcc-phat에는 서로 다른 두 개 이상의 마이크가 필요합니다.", ); } if (value.beamforming?.algorithm === "music") { const microphoneCount = context.arrayDefinition.microphones.length; const method = value.beamforming.musicSourceCountMethod ?? "fixed"; const distinctPositions = new Set( context.arrayDefinition.microphones.map((microphone) => microphone.position?.join(",")), ); if (microphoneCount < 2 || distinctPositions.size < 2) { addIssue( issues, "$.beamforming.algorithm", "MUSIC에는 서로 다른 위치의 마이크가 두 개 이상 필요합니다.", ); } if ( method === "fixed" && Number.isInteger(value.beamforming.musicSourceCount) && value.beamforming.musicSourceCount >= microphoneCount ) { addIssue( issues, "$.beamforming.musicSourceCount", "고정 MUSIC source count는 마이크 수보다 작아야 합니다.", ); } if ( Number.isInteger(value.stft?.fftSize) && Array.isArray(value.frequencyBands) ) { const binWidthHz = context.scene.sampleRate / value.stft.fftSize; const hypothesisKeys = new Set(); for (const band of value.frequencyBands) { try { const observation = observeFrequencyBand( band, context.scene.sampleRate, value.stft.fftSize, ); if (observation.bins.length === 1) { hypothesisKeys.add( `${Math.ceil(band.minHz / binWidthHz)}|${observation.bins[0]}`, ); } } catch { // The band-specific validation above reports this. } } const worstCasePairUpdates = hypothesisKeys.size * 32 * ( microphoneCount * (microphoneCount - 1) / 2 ) * (2 * microphoneCount - 2); if ( worstCasePairUpdates > DEFAULT_MAXIMUM_MUSIC_JACOBI_PAIR_UPDATES ) { addIssue( issues, "$.beamforming.algorithm", `MUSIC Jacobi worst-case work ${worstCasePairUpdates.toLocaleString()} pair updates exceeds the explicit CPU limit ${DEFAULT_MAXIMUM_MUSIC_JACOBI_PAIR_UPDATES.toLocaleString()}. Reduce microphones or configured hypotheses; no partial eigensystem is returned.`, ); } } if ( (method === "mdl" || method === "aic") && Number.isFinite(context.scene.duration) && Number.isFinite(context.scene.sampleRate) && Number.isInteger(value.stft?.fftSize) && Number.isInteger(value.stft?.hopSize) ) { const sampleCount = Math.ceil( context.scene.duration * context.scene.sampleRate, ); const frameCount = sampleCount < value.stft.fftSize ? 0 : 1 + Math.floor( (sampleCount - value.stft.fftSize) / value.stft.hopSize, ); if (frameCount <= microphoneCount) { addIssue( issues, "$.beamforming.musicSourceCountMethod", `Wax–Kailath ${method.toUpperCase()}의 IID snapshot 가정 아래에서도 microphone 수보다 많은 frame이 필요합니다 (현재 ${frameCount} frames / ${microphoneCount} microphones).`, ); } } } try { const workingSet = estimateSynthesisWorkingSet({ scene: context.scene, arrayDefinition: context.arrayDefinition, minimumDistance: value.beamforming?.minimumDistance ?? 0.01, }); if ( workingSet.estimatedBufferBytes > DEFAULT_MAXIMUM_SYNTHESIS_BUFFER_BYTES ) { addIssue( issues, "$", `source-streaming signal buffer 예상 크기 ${(workingSet.estimatedBufferBytes / 1024 ** 2).toFixed(1)} MiB가 512 MiB 한도를 넘습니다.`, ); } } catch (error) { addIssue( issues, "$", `signal synthesis preflight failed: ${error.message}`, ); } } return issues; } function validateResultRelationships(value, context = {}) { /** @type {ValidationIssue[]} */ const issues = []; if (!isRecord(value)) return issues; if (context.experiment) { if (value.experimentId !== context.experiment.id) addIssue(issues, "$.experimentId", "불러온 실험 id와 다릅니다."); if ( value.algorithm !== undefined && value.algorithm !== context.experiment.beamforming?.algorithm ) { addIssue(issues, "$.algorithm", "실험의 위치 추정 알고리즘과 다릅니다."); } } if ( context.arrayDefinition && value.arrayId !== context.arrayDefinition.id ) { addIssue( issues, "$.arrayId", "Result arrayId must identify the array actually used for this run.", ); } if (context.scene && value.sceneId !== context.scene.id) { addIssue( issues, "$.sceneId", "Result sceneId must identify the Scene actually used for this run.", ); } const sourceIds = new Set(context.scene?.sources?.map((source) => source.id) ?? []); const expectedBandIds = new Set(context.experiment?.frequencyBands?.map((band) => band.id) ?? []); const resultAlgorithm = value.algorithm ?? context.experiment?.beamforming?.algorithm; if ( (resultAlgorithm === "gcc-phat" || resultAlgorithm === "music") && value.backend === "webgpu" ) { addIssue( issues, "$.backend", "The selected Result algorithm must use the CPU backend.", ); } const expectedRawScale = resultAlgorithm === "das" ? "power" : resultAlgorithm === "das-phat" || resultAlgorithm === "gcc-phat" ? "score" : resultAlgorithm === "music" ? "noise-projection" : undefined; const expectedBeamSpreadMethod = expectedRawScale === "power" ? "connected-half-power-power-weighted-covariance" : expectedRawScale === "score" ? "connected-half-maximum-score-weighted-covariance" : undefined; if (resultAlgorithm === "music" && !isRecord(value.musicModel)) { addIssue( issues, "$.musicModel", "MUSIC Result에는 사용한 manifold·loading·fusion 모델 설명이 필요합니다.", ); } if (resultAlgorithm !== "music" && value.musicModel !== undefined) { addIssue( issues, "$.musicModel", "musicModel은 MUSIC Result에만 사용할 수 있습니다.", ); } if ( resultAlgorithm === "music" && isRecord(value.musicModel) && isRecord(context.experiment?.beamforming) ) { const beamforming = context.experiment.beamforming; for (const [field, expected] of [ ["requestedNormalization", beamforming.normalization], ["minimumDistance", beamforming.minimumDistance], [ "microphoneCalibration.compensateFrequencyResponse", beamforming.compensateFrequencyResponse ?? true, ], [ "microphoneCalibration.maxCompensationDb", beamforming.maxCompensationDb ?? DEFAULT_MAX_COMPENSATION_DB, ], ]) { const actual = field.split(".").reduce( (current, key) => current?.[key], value.musicModel, ); if (!samplingMetadataEqual(actual, expected)) { addIssue( issues, `$.musicModel.${field}`, `${field} does not match the Experiment MUSIC configuration.`, ); } } } if ( Number.isFinite(value.timingMs?.localization) && Number.isFinite(value.timingMs?.beamforming) && !samplingMetadataEqual( value.timingMs.localization, value.timingMs.beamforming, ) ) { addIssue( issues, "$.timingMs.localization", "localization must equal the compatibility beamforming timing alias.", ); } const expectedObservations = new Map(); const expectedEquivalentGroups = new Map(); if ( context.scene && context.experiment && Number.isFinite(context.scene.sampleRate) && Number.isInteger(context.experiment.stft?.fftSize) ) { const definitions = Array.isArray(context.experiment.frequencyBands) ? context.experiment.frequencyBands : []; for (const definition of definitions) { try { const observation = observeFrequencyBand( definition, context.scene.sampleRate, context.experiment.stft.fftSize, ); expectedObservations.set(definition.id, observation); const group = expectedEquivalentGroups.get(observation.adcBinSetKey) ?? []; group.push(definition.id); expectedEquivalentGroups.set(observation.adcBinSetKey, group); } catch { // Experiment validation reports an unusable band separately. } } } const sampling = value.signalStats?.sampling; const contextSampleRate = Number.isFinite(context.scene?.sampleRate) && context.scene.sampleRate > 0 ? context.scene.sampleRate : null; const toneDefinitions = new Map(); if (context.scene) { const sources = Array.isArray(context.scene.sources) ? context.scene.sources : []; const ambientEntries = Array.isArray(context.scene.ambient) ? context.scene.ambient : []; for (const source of sources) { if (source.signal?.type === "tone") { toneDefinitions.set(`source:${source.id}`, source.signal.frequency); } } for (const ambient of ambientEntries) { if (ambient.signal?.type === "tone") { toneDefinitions.set(`ambient:${ambient.id}`, ambient.signal.frequency); } } } if (isRecord(sampling)) { const samplingRateValid = Number.isFinite(sampling.sampleRate) && sampling.sampleRate > 0; if ( samplingRateValid && !samplingMetadataEqual(sampling.nyquistHz, sampling.sampleRate / 2) ) { addIssue( issues, "$.signalStats.sampling.nyquistHz", "sampling Nyquist must equal sampling sampleRate / 2.", ); } if ( contextSampleRate !== null && !samplingMetadataEqual(sampling.sampleRate, contextSampleRate) ) { addIssue(issues, "$.signalStats.sampling.sampleRate", "sampling sampleRate must match Scene."); } if ( contextSampleRate !== null && !samplingMetadataEqual(sampling.nyquistHz, contextSampleRate / 2) ) { addIssue(issues, "$.signalStats.sampling.nyquistHz", "sampling Nyquist must equal sampleRate / 2."); } const observedToneKeys = new Set(); const toneObservations = Array.isArray(sampling.toneObservations) ? sampling.toneObservations : []; for (const [observationIndex, observation] of toneObservations.entries()) { if (!isRecord(observation)) continue; const path = `$.signalStats.sampling.toneObservations[${observationIndex}]`; const observationKey = `${observation.scope}:${observation.id}`; if (observedToneKeys.has(observationKey)) { addIssue(issues, path, `duplicate tone observation: ${observationKey}`); } observedToneKeys.add(observationKey); if (samplingRateValid && Number.isFinite(observation.analogFrequencyHz)) { try { const expected = describeFrequencyAliasing( observation.analogFrequencyHz, sampling.sampleRate, ); for (const key of [ "analogFrequencyHz", "adcFrequencyHz", "signedAdcFrequencyHz", "aliased", "spectralInversion", "dcEdge", "nyquistEdge", "phaseDegenerate", ]) { if (!samplingMetadataEqual(observation[key], expected[key])) { addIssue( issues, `${path}.${key}`, `${key} does not match ideal ADC sampling.`, ); } } } catch (error) { addIssue(issues, `${path}.analogFrequencyHz`, error.message); } } if (context.scene) { const analogFrequencyHz = toneDefinitions.get(observationKey); if (analogFrequencyHz === undefined) { addIssue(issues, path, "tone observation must reference a Scene tone."); } else if ( !samplingMetadataEqual(observation.analogFrequencyHz, analogFrequencyHz) ) { addIssue( issues, `${path}.analogFrequencyHz`, "analogFrequencyHz must match the Scene tone.", ); } } } if (context.scene) { for (const toneKey of toneDefinitions.keys()) { if (!observedToneKeys.has(toneKey)) { addIssue( issues, "$.signalStats.sampling.toneObservations", `missing Scene tone observation: ${toneKey}`, ); } } } } else if (contextSampleRate !== null) { addIssue(issues, "$.signalStats.sampling", "sampling metadata is required."); } const standaloneObservations = new Map(); const standaloneEquivalentGroups = new Map(); if (Array.isArray(value.bands)) { value.bands.forEach((band, bandIndex) => { const observation = band?.frequencyObservation; if (!isRecord(observation)) return; if ( !Number.isFinite(observation.sampleRate) || !Number.isInteger(observation.fftSize) || !Number.isFinite(observation.requestedMinHz) || !Number.isFinite(observation.requestedMaxHz) ) { return; } try { const expected = observeFrequencyBand( { minHz: observation.requestedMinHz, maxHz: observation.requestedMaxHz, }, observation.sampleRate, observation.fftSize, ); standaloneObservations.set(bandIndex, expected); if (typeof band.id === "string") { const group = standaloneEquivalentGroups.get(expected.adcBinSetKey) ?? []; group.push(band.id); standaloneEquivalentGroups.set(expected.adcBinSetKey, group); } } catch (error) { addIssue( issues, `$.bands[${bandIndex}].frequencyObservation`, `invalid self-described frequency observation: ${error.message}`, ); } }); } const bandIds = []; if (Array.isArray(value.bands)) { value.bands.forEach((band, bandIndex) => { if (!isRecord(band)) return; const path = `$.bands[${bandIndex}]`; if (typeof band.id === "string") { bandIds.push(band.id); if (expectedBandIds.size > 0 && !expectedBandIds.has(band.id)) addIssue(issues, `${path}.id`, "실험에 없는 band id입니다."); } const standaloneObservation = standaloneObservations.get(bandIndex); if (standaloneObservation && isRecord(band.frequencyObservation)) { const observation = band.frequencyObservation; const musicAnalogBin = Math.ceil( standaloneObservation.requestedMinHz / standaloneObservation.binWidthHz, ); const musicAnalogFrequencyHz = musicAnalogBin * standaloneObservation.binWidthHz; const musicAlias = resultAlgorithm === "music" ? describeFrequencyAliasing( musicAnalogFrequencyHz, standaloneObservation.sampleRate, ) : null; const expectedEquivalentBandIds = (standaloneEquivalentGroups .get(standaloneObservation.adcBinSetKey) ?? []) .filter((candidateId) => candidateId !== band.id) .sort(); const expectedFields = { model: standaloneObservation.model, sampleRate: standaloneObservation.sampleRate, nyquistHz: standaloneObservation.nyquistHz, fftSize: standaloneObservation.fftSize, binWidthHz: standaloneObservation.binWidthHz, requestedMinHz: standaloneObservation.requestedMinHz, requestedMaxHz: standaloneObservation.requestedMaxHz, aliased: standaloneObservation.aliased, crossesFirstNyquist: standaloneObservation.crossesFirstNyquist, localizationInputModel: standaloneObservation.localizationInputModel, selectionModel: standaloneObservation.selectionModel, localizationModel: resultAlgorithm === "gcc-phat" ? "band-limited-gcc-phat-geometric-lag" : resultAlgorithm === "music" ? "near-field-music-at-configured-analog-frequency" : "spherical-steering-at-positive-adc-bin-frequency", originalCarrierUsed: resultAlgorithm === "music", aliasOrderResolution: resultAlgorithm === "music" && standaloneObservation.aliased ? "configured-single-hypothesis" : standaloneObservation.aliased ? "not-attempted" : "not-applicable", ...(resultAlgorithm === "music" ? { configuredAnalogFrequencyHz: musicAnalogFrequencyHz, signedAdcFrequencyHz: musicAlias.signedAdcFrequencyHz, spectralInversion: musicAlias.spectralInversion, frequencyHypothesisCount: 1, frequencyHypothesisSource: "experiment-band-not-scene-truth", } : {}), foldBoundaryCount: standaloneObservation.foldBoundaryCount, continuousManyToOne: standaloneObservation.continuousManyToOne, gridManyToOne: standaloneObservation.gridManyToOne, adcBinSetKey: standaloneObservation.adcBinSetKey, }; for (const [key, expected] of Object.entries(expectedFields)) { if (!samplingMetadataEqual(observation[key], expected)) { addIssue( issues, `${path}.frequencyObservation.${key}`, `${key} is not self-consistent with the Result sampling inputs.`, ); } } for (const [key, expected] of [ ["adcBins", standaloneObservation.bins], ["adcBinCenterRanges", standaloneObservation.adcBinCenterRanges], ["foldSegments", standaloneObservation.foldSegments], ["equivalentBandIds", expectedEquivalentBandIds], ]) { if (!samplingMetadataEqual(observation[key], expected)) { addIssue( issues, `${path}.frequencyObservation.${key}`, `${key} does not match the deterministic ADC fold.`, ); } } if ( isRecord(sampling) && Number.isFinite(sampling.sampleRate) && !samplingMetadataEqual(observation.sampleRate, sampling.sampleRate) ) { addIssue( issues, `${path}.frequencyObservation.sampleRate`, "band sampleRate must match signalStats.sampling.sampleRate.", ); } } else if (!isRecord(band.frequencyObservation)) { addIssue( issues, `${path}.frequencyObservation`, "frequency observation metadata is required for every Result band.", ); } const contextObservation = expectedObservations.get(band.id); if (contextObservation && isRecord(band.frequencyObservation)) { const observation = band.frequencyObservation; for (const [key, expected] of Object.entries({ sampleRate: contextObservation.sampleRate, fftSize: contextObservation.fftSize, requestedMinHz: contextObservation.requestedMinHz, requestedMaxHz: contextObservation.requestedMaxHz, })) { if (!samplingMetadataEqual(observation[key], expected)) { addIssue( issues, `${path}.frequencyObservation.${key}`, `${key} does not match Scene/Experiment.`, ); } } const expectedEquivalentBandIds = expectedEquivalentGroups .get(contextObservation.adcBinSetKey) .filter((candidateId) => candidateId !== band.id) .sort(); if ( !samplingMetadataEqual( observation.equivalentBandIds, expectedEquivalentBandIds, ) ) { addIssue( issues, `${path}.frequencyObservation.equivalentBandIds`, "equivalentBandIds does not match the Experiment band set.", ); } } const processedMaps = Array.isArray(band.processedMaps) ? band.processedMaps.map((item) => item?.map) : []; const maps = [band.rawMap, band.displayMap, ...processedMaps].filter(Boolean); maps.forEach((map, mapIndex) => { if (!isRecord(map) || !Array.isArray(map.values)) return; if (Number.isInteger(map.rows) && Number.isInteger(map.columns) && map.values.length !== map.rows * map.columns) { addIssue(issues, `${path}.maps[${mapIndex}].values`, "values 길이는 rows × columns와 같아야 합니다."); } if (context.experiment && (map.rows !== context.experiment.scan.rows || map.columns !== context.experiment.scan.columns)) { addIssue(issues, `${path}.maps[${mapIndex}]`, "map shape이 실험 scan grid와 다릅니다."); } }); if ( isRecord(band.rawMap) && !["power", "score", "noise-projection"].includes(band.rawMap.scale) ) { addIssue( issues, `${path}.rawMap.scale`, "rawMap은 power, score 또는 noise-projection scale이어야 합니다.", ); } else if ( isRecord(band.rawMap) && expectedRawScale && band.rawMap.scale !== expectedRawScale ) { addIssue( issues, `${path}.rawMap.scale`, `${resultAlgorithm} 결과의 rawMap scale은 ${expectedRawScale}여야 합니다.`, ); } if ( isRecord(band.rawMap) && band.rawMap.scale === "noise-projection" && band.rawMap.objectiveDirection !== "minimum" ) { addIssue( issues, `${path}.rawMap.objectiveDirection`, "MUSIC noise-projection map의 optimum은 minimum이어야 합니다.", ); } if ( isRecord(band.rawMap) && band.rawMap.scale !== "noise-projection" && band.rawMap.objectiveDirection !== undefined && band.rawMap.objectiveDirection !== "maximum" ) { addIssue( issues, `${path}.rawMap.objectiveDirection`, "power와 score map의 optimum은 maximum이어야 합니다.", ); } if (resultAlgorithm === "music") { const diagnostics = band.musicDiagnostics; if (!isRecord(diagnostics)) { addIssue( issues, `${path}.musicDiagnostics`, "MUSIC band에는 covariance/EVD/source-count 수치 진단이 필요합니다.", ); } else { const frequencyHypothesis = diagnostics.frequencyHypothesis; const frequencyObservation = band.frequencyObservation; if ( isRecord(frequencyHypothesis) && isRecord(frequencyObservation) ) { const adcBin = Array.isArray(frequencyObservation.adcBins) && frequencyObservation.adcBins.length === 1 ? frequencyObservation.adcBins[0] : null; const analogFrequencyHz = frequencyObservation.configuredAnalogFrequencyHz; const signedAdcFrequencyHz = frequencyObservation.signedAdcFrequencyHz; const sampleRate = frequencyObservation.sampleRate; const expectedHypothesis = { model: "configured-single-analog-fft-grid-hypothesis", hypothesisCount: frequencyObservation.frequencyHypothesisCount, analogFrequencyHz, observedFrequencyHz: Number.isInteger(adcBin) && Number.isFinite(frequencyObservation.binWidthHz) ? adcBin * frequencyObservation.binWidthHz : null, signedAdcFrequencyHz, spectralInversion: frequencyObservation.spectralInversion, aliasOrder: Number.isFinite(analogFrequencyHz) && Number.isFinite(signedAdcFrequencyHz) && Number.isFinite(sampleRate) && sampleRate > 0 ? Math.round( (analogFrequencyHz - signedAdcFrequencyHz) / sampleRate, ) : null, sharedObservationKey: Number.isInteger(adcBin) && Number.isFinite(sampleRate) && Number.isInteger(frequencyObservation.fftSize) ? `fs:${sampleRate}|fft:${frequencyObservation.fftSize}|bin:${adcBin}` : null, }; for (const [field, expected] of Object.entries( expectedHypothesis, )) { if ( expected !== null && !samplingMetadataEqual( frequencyHypothesis[field], expected, ) ) { addIssue( issues, `${path}.musicDiagnostics.frequencyHypothesis.${field}`, `${field} must match the configured analog frequency hypothesis.`, ); } } } const microphoneCount = context.arrayDefinition?.microphones?.length ?? value.environment?.microphoneCount; if ( Number.isInteger(microphoneCount) && ( diagnostics.eigenvalues?.length !== microphoneCount || diagnostics.auditedEigenvalues?.length !== microphoneCount ) ) { addIssue( issues, `${path}.musicDiagnostics.eigenvalues`, "MUSIC eigenvalue 수는 microphone 수와 같아야 합니다.", ); } const sourceCount = diagnostics.sourceCount?.value; const expectedSourceCountMethod = context.experiment?.beamforming?.musicSourceCountMethod ?? "fixed"; if ( diagnostics.sourceCount?.method !== expectedSourceCountMethod || diagnostics.sourceCount?.criterion !== (expectedSourceCountMethod === "fixed" ? null : expectedSourceCountMethod) ) { addIssue( issues, `${path}.musicDiagnostics.sourceCount.method`, "source-count method/criterion은 Experiment 설정과 같아야 합니다.", ); } if ( expectedSourceCountMethod !== "fixed" && !isRecord( diagnostics.sourceCount?.assumptions?.statisticalDataModel, ) ) { addIssue( issues, `${path}.musicDiagnostics.sourceCount.assumptions.statisticalDataModel`, "MDL/AIC 진단에는 white-noise/Gaussian/stationarity 모델 가정과 비확률 해석이 필요합니다.", ); } if ( Number.isInteger(value.environment?.stftFrameCount) && diagnostics.snapshotCount !== value.environment.stftFrameCount ) { addIssue( issues, `${path}.musicDiagnostics.snapshotCount`, "MUSIC snapshotCount는 실행 STFT frame 수와 같아야 합니다.", ); } if ( Number.isInteger(microphoneCount) && Number.isInteger(sourceCount) && sourceCount >= microphoneCount ) { addIssue( issues, `${path}.musicDiagnostics.sourceCount.value`, "MUSIC source count는 microphone 수보다 작아야 합니다.", ); } if ( context.experiment?.beamforming?.musicSourceCountMethod === "fixed" && Number.isInteger(context.experiment.beamforming.musicSourceCount) && sourceCount !== context.experiment.beamforming.musicSourceCount ) { addIssue( issues, `${path}.musicDiagnostics.sourceCount.value`, "고정 source count 진단은 Experiment K와 같아야 합니다.", ); } } } else if (band.musicDiagnostics !== undefined) { addIssue( issues, `${path}.musicDiagnostics`, "musicDiagnostics는 MUSIC band에만 사용할 수 있습니다.", ); } if (Array.isArray(band.peaks)) { band.peaks.forEach((peak, peakIndex) => { if (peak?.sourceId && sourceIds.size > 0 && !sourceIds.has(peak.sourceId)) { addIssue(issues, `${path}.peaks[${peakIndex}].sourceId`, "장면에 없는 source id입니다."); } if ( expectedBeamSpreadMethod && isRecord(peak?.beamSpread) && peak.beamSpread.method !== expectedBeamSpreadMethod ) { addIssue( issues, `${path}.peaks[${peakIndex}].beamSpread.method`, `${expectedRawScale} map과 맞는 beamSpread method여야 합니다.`, ); } if ( resultAlgorithm === "music" && isRecord(peak?.beamSpread) ) { addIssue( issues, `${path}.peaks[${peakIndex}].beamSpread`, "MUSIC noise-projection에는 DAS/PHAT half-power beamSpread를 적용할 수 없습니다.", ); } }); } }); } for (const expectedBandId of expectedBandIds) { if (!bandIds.includes(expectedBandId)) { addIssue(issues, "$.bands", `missing Experiment band result: ${expectedBandId}`); } } for (const duplicate of findDuplicates(bandIds)) addIssue(issues, "$.bands", `중복 result band id: ${duplicate}`); return issues; } async function combineValidation(name, value, relationshipIssues) { return [...(await validateDocumentStructure(name, value)), ...relationshipIssues]; } export async function validateArrayDefinition(value) { return combineValidation("array", value, validateArrayRelationships(value)); } export async function validateScene(value) { return combineValidation("scene", value, validateSceneRelationships(value)); } export async function validateExperiment(value, context = {}) { return combineValidation("experiment", value, validateExperimentRelationships(value, context)); } export async function validateResult(value, context = {}) { return combineValidation("result", value, validateResultRelationships(value, context)); } export async function assertValid(value, validator, label = "데이터") { const issues = await validator(value); if (issues.length > 0) { const details = issues.map(({ path, message }) => `${path}: ${message}`).join("\n"); throw new Error(`${label} 검증 실패\n${details}`); } return value; }