diff --git a/packages/fingerprint/src/benchmark-corpus.ts b/packages/fingerprint/src/benchmark-corpus.ts index c1bf3cf..9a0e6b7 100644 --- a/packages/fingerprint/src/benchmark-corpus.ts +++ b/packages/fingerprint/src/benchmark-corpus.ts @@ -32,6 +32,11 @@ function normalized(value: string): string { return value.trim().toLocaleLowerCase("en-US"); } +function compareObservationId(left: TwinBenchmarkObservationV1, right: TwinBenchmarkObservationV1): number { + return left.observationId.trim().localeCompare(right.observationId.trim(), "en-US") + || normalized(left.specimenId).localeCompare(normalized(right.specimenId), "en-US"); +} + export function validateTwinBenchmarkCorpus(corpus: TwinBenchmarkCorpusV1): TwinBenchmarkCorpusValidationV1 { const reasons: string[] = []; if (corpus.schemaVersion !== 1) reasons.push("schemaVersion must be 1"); @@ -96,7 +101,7 @@ export interface TwinVerificationPairV1 { } function pairId(left: TwinBenchmarkObservationV1, right: TwinBenchmarkObservationV1, kind: string): string { - return `${kind}:${left.observationId}::${right.observationId}`; + return `${kind}:${left.observationId.trim()}::${right.observationId.trim()}`; } export function buildTwinVerificationPairs( @@ -110,7 +115,9 @@ export function buildTwinVerificationPairs( } const pairs: TwinVerificationPairV1[] = []; for (const split of ["train", "validation", "test"] as const) { - const observations = corpus.observations.filter((observation) => specimenDisjointSplit(observation.specimenId) === split); + const observations = corpus.observations + .filter((observation) => specimenDisjointSplit(observation.specimenId) === split) + .sort(compareObservationId); const bySpecimen = new Map(); for (const observation of observations) { const key = normalized(observation.specimenId); @@ -118,18 +125,19 @@ export function buildTwinVerificationPairs( group.push(observation); bySpecimen.set(key, group); } - for (const group of bySpecimen.values()) { - for (let leftIndex = 0; leftIndex < group.length; leftIndex += 1) { - for (let rightIndex = leftIndex + 1; rightIndex < group.length; rightIndex += 1) { - const left = group[leftIndex]!; - const right = group[rightIndex]!; + for (const group of [...bySpecimen.values()].sort((left, right) => normalized(left[0]!.specimenId).localeCompare(normalized(right[0]!.specimenId), "en-US"))) { + const orderedGroup = [...group].sort(compareObservationId); + for (let leftIndex = 0; leftIndex < orderedGroup.length; leftIndex += 1) { + for (let rightIndex = leftIndex + 1; rightIndex < orderedGroup.length; rightIndex += 1) { + const left = orderedGroup[leftIndex]!; + const right = orderedGroup[rightIndex]!; pairs.push({ pairId: pairId(left, right, "same"), samePhysicalSpecimen: true, - leftObservationId: left.observationId, - rightObservationId: right.observationId, - leftSpecimenId: left.specimenId, - rightSpecimenId: right.specimenId, + leftObservationId: left.observationId.trim(), + rightObservationId: right.observationId.trim(), + leftSpecimenId: left.specimenId.trim(), + rightSpecimenId: right.specimenId.trim(), split, reference: left.fingerprint, candidate: right.fingerprint, @@ -140,7 +148,7 @@ export function buildTwinVerificationPairs( } const hard = selectHardNegativePairs(observations, Math.min(hardNegativeLimitPerSplit, Math.max(1, observations.length * observations.length))); - const byObservation = new Map(observations.map((observation) => [observation.observationId, observation] as const)); + const byObservation = new Map(observations.map((observation) => [observation.observationId.trim(), observation] as const)); for (const negative of hard) { const left = byObservation.get(negative.leftObservationId); const right = byObservation.get(negative.rightObservationId); @@ -148,10 +156,10 @@ export function buildTwinVerificationPairs( pairs.push({ pairId: pairId(left, right, "hard-negative"), samePhysicalSpecimen: false, - leftObservationId: left.observationId, - rightObservationId: right.observationId, - leftSpecimenId: left.specimenId, - rightSpecimenId: right.specimenId, + leftObservationId: left.observationId.trim(), + rightObservationId: right.observationId.trim(), + leftSpecimenId: left.specimenId.trim(), + rightSpecimenId: right.specimenId.trim(), split, reference: left.fingerprint, candidate: right.fingerprint, @@ -159,5 +167,5 @@ export function buildTwinVerificationPairs( }); } } - return pairs.sort((left, right) => left.split.localeCompare(right.split) || left.pairId.localeCompare(right.pairId)); + return pairs.sort((left, right) => left.split.localeCompare(right.split, "en-US") || left.pairId.localeCompare(right.pairId, "en-US")); } diff --git a/packages/fingerprint/src/nuisance.ts b/packages/fingerprint/src/nuisance.ts index 7391105..6862851 100644 --- a/packages/fingerprint/src/nuisance.ts +++ b/packages/fingerprint/src/nuisance.ts @@ -77,12 +77,35 @@ function differingFactors(left: NuisanceMetadataV1, right: NuisanceMetadataV1): return NUISANCE_FACTORS.filter((factor) => left[factor] !== right[factor]); } +function normalized(value: string): string { + return value.trim().toLocaleLowerCase("en-US"); +} + +function canonicalObservationOrder( + left: LabeledFingerprintObservationV1, + right: LabeledFingerprintObservationV1, +): number { + return left.observationId.trim().localeCompare(right.observationId.trim(), "en-US") + || normalized(left.specimenId).localeCompare(normalized(right.specimenId), "en-US"); +} + +function validateObservationIds(observations: readonly LabeledFingerprintObservationV1[]): void { + const seen = new Set(); + for (const observation of observations) { + const id = observation.observationId.trim(); + if (id.length === 0) throw new Error("nuisance observations require observationId"); + if (seen.has(id)) throw new Error(`duplicate nuisance observationId ${id}`); + seen.add(id); + } +} + export function characterizeNuisance( observations: readonly LabeledFingerprintObservationV1[], ): NuisanceCharacterizationV1 { + validateObservationIds(observations); const bySpecimen = new Map(); for (const observation of observations) { - const key = observation.specimenId.trim().toLocaleLowerCase("en-US"); + const key = normalized(observation.specimenId); if (key.length === 0) throw new Error("nuisance observations require specimenId"); const current = bySpecimen.get(key) ?? []; current.push(observation); @@ -90,16 +113,18 @@ export function characterizeNuisance( } const pairs: WithinSpecimenPairMetricV1[] = []; - for (const specimenObservations of bySpecimen.values()) { + const specimenGroups = [...bySpecimen.entries()].sort(([left], [right]) => left.localeCompare(right, "en-US")); + for (const [, unsortedObservations] of specimenGroups) { + const specimenObservations = [...unsortedObservations].sort(canonicalObservationOrder); for (let leftIndex = 0; leftIndex < specimenObservations.length; leftIndex += 1) { for (let rightIndex = leftIndex + 1; rightIndex < specimenObservations.length; rightIndex += 1) { const left = specimenObservations[leftIndex]!; const right = specimenObservations[rightIndex]!; const recurrence = fingerprintRecurrence(left.fingerprint, right.fingerprint); pairs.push({ - leftObservationId: left.observationId, - rightObservationId: right.observationId, - specimenId: left.specimenId, + leftObservationId: left.observationId.trim(), + rightObservationId: right.observationId.trim(), + specimenId: left.specimenId.trim(), differingFactors: differingFactors(left.nuisance, right.nuisance), matchedModes: recurrence.matchedCount, medianFrequencyDriftCents: recurrence.medianCents, @@ -146,25 +171,30 @@ export function selectHardNegativePairs( limit: number, ): readonly HardNegativePairV1[] { if (!Number.isInteger(limit) || limit <= 0) throw new Error("hard-negative limit must be a positive integer"); + validateObservationIds(observations); + const orderedObservations = [...observations].sort(canonicalObservationOrder); const pairs: HardNegativePairV1[] = []; - for (let leftIndex = 0; leftIndex < observations.length; leftIndex += 1) { - for (let rightIndex = leftIndex + 1; rightIndex < observations.length; rightIndex += 1) { - const left = observations[leftIndex]!; - const right = observations[rightIndex]!; - if (left.specimenId.trim().toLocaleLowerCase("en-US") === right.specimenId.trim().toLocaleLowerCase("en-US")) continue; + for (let leftIndex = 0; leftIndex < orderedObservations.length; leftIndex += 1) { + for (let rightIndex = leftIndex + 1; rightIndex < orderedObservations.length; rightIndex += 1) { + const left = orderedObservations[leftIndex]!; + const right = orderedObservations[rightIndex]!; + if (normalized(left.specimenId) === normalized(right.specimenId)) continue; const recurrence = fingerprintRecurrence(left.fingerprint, right.fingerprint); pairs.push({ - leftObservationId: left.observationId, - rightObservationId: right.observationId, - leftSpecimenId: left.specimenId, - rightSpecimenId: right.specimenId, + leftObservationId: left.observationId.trim(), + rightObservationId: right.observationId.trim(), + leftSpecimenId: left.specimenId.trim(), + rightSpecimenId: right.specimenId.trim(), medianFrequencyDriftCents: recurrence.medianCents, matchedModes: recurrence.matchedCount, }); } } return pairs - .sort((left, right) => left.medianFrequencyDriftCents - right.medianFrequencyDriftCents || right.matchedModes - left.matchedModes) + .sort((left, right) => left.medianFrequencyDriftCents - right.medianFrequencyDriftCents + || right.matchedModes - left.matchedModes + || left.leftObservationId.localeCompare(right.leftObservationId, "en-US") + || left.rightObservationId.localeCompare(right.rightObservationId, "en-US")) .slice(0, limit); } diff --git a/packages/fingerprint/src/object-model.ts b/packages/fingerprint/src/object-model.ts index fe52e74..1b5327a 100644 --- a/packages/fingerprint/src/object-model.ts +++ b/packages/fingerprint/src/object-model.ts @@ -85,18 +85,26 @@ function validateConfig(config: AcousticObjectModelConfigV1): void { } } +function compareObservationOrder(left: AcousticObjectObservationV1, right: AcousticObjectObservationV1): number { + return left.observationId.trim().localeCompare(right.observationId.trim(), "en-US") + || left.specimenId.trim().localeCompare(right.specimenId.trim(), "en-US"); +} + export function buildAcousticObjectModel( observations: readonly AcousticObjectObservationV1[], config: AcousticObjectModelConfigV1 = DEFAULT_ACOUSTIC_OBJECT_MODEL_CONFIG, ): AcousticObjectModelV1 { validateConfig(config); if (observations.length === 0) throw new Error("object model requires at least one observation"); - const specimenId = observations[0]!.specimenId.trim(); - if (specimenId.length === 0) throw new Error("object model specimenId is required"); - const normalized = specimenId.toLocaleLowerCase("en-US"); - if (observations.some((observation) => observation.specimenId.trim().toLocaleLowerCase("en-US") !== normalized)) { + + const trimmedSpecimenIds = observations.map((observation) => observation.specimenId.trim()); + if (trimmedSpecimenIds.some((value) => value.length === 0)) throw new Error("object model specimenId is required"); + const normalized = trimmedSpecimenIds[0]!.toLocaleLowerCase("en-US"); + if (trimmedSpecimenIds.some((value) => value.toLocaleLowerCase("en-US") !== normalized)) { throw new Error("object model cannot mix different specimen IDs"); } + const specimenId = [...new Set(trimmedSpecimenIds)].sort((left, right) => left.localeCompare(right, "en-US"))[0]!; + const observationIds = new Set(); for (const observation of observations) { const id = observation.observationId.trim(); @@ -105,8 +113,13 @@ export function buildAcousticObjectModel( observationIds.add(id); } + // Clustering is incremental because one observation may contribute at most + // one mode to each cluster. Canonicalizing observation order makes the + // resulting model a deterministic function of the observation set rather + // than of caller iteration order. + const orderedObservations = [...observations].sort(compareObservationOrder); const clusters: MutableCluster[] = []; - for (const observation of observations) { + for (const observation of orderedObservations) { const usedClusters = new Set(); const modes = [...observation.fingerprint.modes].sort((left, right) => left.frequencyHz - right.frequencyHz); for (const mode of modes) { @@ -121,7 +134,7 @@ export function buildAcousticObjectModel( } } const sample: ModeSample = { - observationId: observation.observationId, + observationId: observation.observationId.trim(), frequencyHz: mode.frequencyHz, decaySeconds: mode.decaySeconds, relativeAmplitude: mode.relativeAmplitude, @@ -150,7 +163,7 @@ export function buildAcousticObjectModel( const decayLogs = decays.map((value) => Math.log(Math.max(value, 1e-9))); const amplitudes = cluster.samples.map((sample) => sample.relativeAmplitude); const amplitudeDb = amplitudes.map(dbAmplitude); - const sourceObservationIds = [...new Set(cluster.samples.map((sample) => sample.observationId))].sort(); + const sourceObservationIds = [...new Set(cluster.samples.map((sample) => sample.observationId))].sort((left, right) => left.localeCompare(right, "en-US")); return { frequencyHzMedian: center, frequencyMadCents: medianAbsoluteDeviation(frequencyOffsets), diff --git a/packages/fingerprint/src/retrieval.ts b/packages/fingerprint/src/retrieval.ts index b51c941..8951346 100644 --- a/packages/fingerprint/src/retrieval.ts +++ b/packages/fingerprint/src/retrieval.ts @@ -29,8 +29,11 @@ export function buildSonicTwinIndex( grouped.set(normalized, current); } const entries = [...grouped.values()] - .map((group) => ({ specimenId: group[0]!.specimenId.trim(), model: buildAcousticObjectModel(group) })) - .sort((left, right) => left.specimenId.localeCompare(right.specimenId)); + .map((group) => { + const model = buildAcousticObjectModel(group); + return { specimenId: model.specimenId, model }; + }) + .sort((left, right) => left.specimenId.localeCompare(right.specimenId, "en-US")); return { schemaVersion: 1, indexVersion: "sonic-twin-index-1", entries }; } @@ -60,7 +63,7 @@ export function retrieveSonicTwin( modelModeCount: comparison.modelModeCount, }; }) - .sort((left, right) => right.evidenceScore - left.evidenceScore || left.specimenId.localeCompare(right.specimenId)) + .sort((left, right) => right.evidenceScore - left.evidenceScore || left.specimenId.localeCompare(right.specimenId, "en-US")) .slice(0, Math.min(limit, index.entries.length)) .map((result, indexValue) => ({ rank: indexValue + 1, ...result })); } diff --git a/packages/fingerprint/test/reproducibility.test.ts b/packages/fingerprint/test/reproducibility.test.ts new file mode 100644 index 0000000..33612bb --- /dev/null +++ b/packages/fingerprint/test/reproducibility.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from "vitest"; +import type { AcousticFingerprintV1 } from "@everything-rings/dsp"; +import { + buildAcousticObjectModel, + buildSonicTwinIndex, + buildTwinVerificationPairs, + characterizeNuisance, + selectHardNegativePairs, + type AcousticObjectObservationV1, + type NuisanceMetadataV1, + type TwinBenchmarkCorpusV1, +} from "../src"; + +function fingerprint(frequencies: readonly number[]): AcousticFingerprintV1 { + return { + version: 1, + algorithmVersion: "er-dsp-2", + sampleRate: 48_000, + durationSeconds: 2, + modes: frequencies.map((frequencyHz, index) => ({ + frequencyHz, + relativeAmplitude: 1 / (index + 1), + decaySeconds: 0.9 / (index + 1), + q: 100, + confidence: 0.9, + diagnostics: { + prominenceDb: 20, + persistenceSeconds: 0.2, + frequencyStdCents: 2, + decayFitScore: 0.95, + observationCount: 12, + }, + })), + }; +} + +const nuisance: NuisanceMetadataV1 = { + strikeLocation: "A", + striker: "wood", + support: "felt", + microphoneDistanceClass: "20cm", + room: "room-1", + stationId: "station-1", + operatorId: "operator-1", + dayId: "day-1", +}; + +const observations: readonly AcousticObjectObservationV1[] = [ + { observationId: "a-3", specimenId: "Specimen-A", fingerprint: fingerprint([442, 884, 1326]) }, + { observationId: "a-1", specimenId: "specimen-a", fingerprint: fingerprint([440, 880, 1320]) }, + { observationId: "a-2", specimenId: "SPECIMEN-A", fingerprint: fingerprint([441, 882, 1323]) }, + { observationId: "b-2", specimenId: "specimen-b", fingerprint: fingerprint([611, 1222, 1833]) }, + { observationId: "b-1", specimenId: "specimen-b", fingerprint: fingerprint([610, 1220, 1830]) }, + { observationId: "c-2", specimenId: "specimen-c", fingerprint: fingerprint([443, 886, 1329]) }, + { observationId: "c-1", specimenId: "specimen-c", fingerprint: fingerprint([442.5, 885, 1328]) }, +]; + +function reversed(values: readonly T[]): T[] { + return [...values].reverse(); +} + +describe("research reproducibility contracts", () => { + it("builds byte-equivalent object models from any observation order", () => { + const specimen = observations.filter((observation) => observation.specimenId.toLocaleLowerCase("en-US") === "specimen-a"); + const forward = buildAcousticObjectModel(specimen); + const reverse = buildAcousticObjectModel(reversed(specimen)); + expect(reverse).toEqual(forward); + expect(forward.specimenId).toBe("specimen-a"); + expect(forward.modes.every((mode) => [...mode.sourceObservationIds].join(",") === [...mode.sourceObservationIds].sort().join(","))).toBe(true); + }); + + it("builds the same Sonic Twin index from a permuted observation set", () => { + expect(buildSonicTwinIndex(reversed(observations))).toEqual(buildSonicTwinIndex(observations)); + }); + + it("makes nuisance pairs and hard-negative selection invariant to input order", () => { + const labeled = observations.map((observation, index) => ({ + ...observation, + nuisance: { ...nuisance, strikeLocation: index % 2 === 0 ? "A" : "B" }, + })); + expect(characterizeNuisance(reversed(labeled))).toEqual(characterizeNuisance(labeled)); + expect(selectHardNegativePairs(reversed(labeled), 5)).toEqual(selectHardNegativePairs(labeled, 5)); + }); + + it("constructs identical benchmark verification pairs after corpus permutation", () => { + const benchmarkObservations = observations.map((observation, index) => ({ + ...observation, + objectFamily: observation.specimenId.toLocaleLowerCase("en-US").includes("b") ? "bowl" : "bell", + source: "digital-twin" as const, + nuisance: { ...nuisance, strikeLocation: index % 2 === 0 ? "A" : "B" }, + })); + const forward: TwinBenchmarkCorpusV1 = { + schemaVersion: 1, + corpusContractVersion: "sonic-twin-benchmark-corpus-1", + corpusId: "permutation-regression", + createdAt: "2026-08-25T00:00:00.000Z", + observations: benchmarkObservations, + }; + const reverse: TwinBenchmarkCorpusV1 = { ...forward, observations: reversed(benchmarkObservations) }; + expect(buildTwinVerificationPairs(reverse, 20)).toEqual(buildTwinVerificationPairs(forward, 20)); + }); + + it("rejects duplicate nuisance observation identifiers before pair selection", () => { + const duplicate = [ + { observationId: "duplicate", specimenId: "a", fingerprint: fingerprint([440, 880, 1320]), nuisance }, + { observationId: "duplicate", specimenId: "b", fingerprint: fingerprint([441, 882, 1323]), nuisance }, + ]; + expect(() => selectHardNegativePairs(duplicate, 1)).toThrow(/duplicate nuisance observationId/); + }); +});