Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 25 additions & 17 deletions packages/fingerprint/src/benchmark-corpus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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(
Expand All @@ -110,26 +115,29 @@ 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<string, TwinBenchmarkObservationV1[]>();
for (const observation of observations) {
const key = normalized(observation.specimenId);
const group = bySpecimen.get(key) ?? [];
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,
Expand All @@ -140,24 +148,24 @@ 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);
if (left === undefined || right === undefined) continue;
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,
pairKind: "hard-negative",
});
}
}
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"));
}
60 changes: 45 additions & 15 deletions packages/fingerprint/src/nuisance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,29 +77,54 @@ 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<string>();
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<string, LabeledFingerprintObservationV1[]>();
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);
bySpecimen.set(key, current);
}

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,
Expand Down Expand Up @@ -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);
}

Expand Down
27 changes: 20 additions & 7 deletions packages/fingerprint/src/object-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>();
for (const observation of observations) {
const id = observation.observationId.trim();
Expand All @@ -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<number>();
const modes = [...observation.fingerprint.modes].sort((left, right) => left.frequencyHz - right.frequencyHz);
for (const mode of modes) {
Expand All @@ -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,
Expand Down Expand Up @@ -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),
Expand Down
9 changes: 6 additions & 3 deletions packages/fingerprint/src/retrieval.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}

Expand Down Expand Up @@ -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 }));
}
Expand Down
110 changes: 110 additions & 0 deletions packages/fingerprint/test/reproducibility.test.ts
Original file line number Diff line number Diff line change
@@ -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<T>(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/);
});
});
Loading