diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000..ea434c03 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,11 @@ +# Default ownership until subsystem maintainers are added. +* @fraware + +# Empirical protocols and release authority require explicit owner review. +/docs/GATE_*.md @fraware +/docs/EMPIRICAL_CAMPAIGN.md @fraware +/packages/validation/ @fraware +/packages/dsp/ @fraware +/apps/web/src/ReleaseApp.tsx @fraware +/apps/web/src/CampaignAuthorApp.tsx @fraware +/apps/web/src/CampaignCollectionApp.tsx @fraware diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index adc11cca..93f53832 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,19 +3,24 @@ name: ci on: pull_request: push: - branches: [main] + branches: [main, post-freeze-development] permissions: contents: read +concurrency: + group: ci-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + jobs: validate: runs-on: ubuntu-latest + timeout-minutes: 30 env: - VITE_SOFTWARE_REVISION: ${{ github.sha }} + VITE_SOFTWARE_REVISION: ${{ github.event.pull_request.head.sha || github.sha }} steps: - - uses: actions/checkout@v7 - - uses: actions/setup-node@v6 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 with: node-version: 22 - name: Enable Corepack diff --git a/.github/workflows/digital-twin-qualification.yml b/.github/workflows/digital-twin-qualification.yml new file mode 100644 index 00000000..b0667b4b --- /dev/null +++ b/.github/workflows/digital-twin-qualification.yml @@ -0,0 +1,77 @@ +name: digital-twin-qualification + +on: + workflow_dispatch: + pull_request: + paths: + - ".github/workflows/digital-twin-qualification.yml" + - "packages/acquisition/**" + - "packages/dsp/**" + - "packages/fingerprint/**" + - "packages/instrument/**" + - "packages/synth/**" + - "packages/validation/**" + - "apps/web/**" + - "scripts/**" + - "docs/DIGITAL_TWIN_QUALIFICATION.md" + +permissions: + contents: read + +concurrency: + group: digital-twin-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + qualify: + runs-on: ubuntu-24.04 + timeout-minutes: 30 + env: + VITE_SOFTWARE_REVISION: ${{ github.event.pull_request.head.sha || github.sha }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 + with: + node-version: 22 + - name: Enable Corepack + run: corepack enable + - name: Install frozen dependencies + run: pnpm install --frozen-lockfile + - name: Typecheck + run: pnpm typecheck + - name: Test including digital twins + run: pnpm test + - name: Build + run: pnpm build + - name: Browser qualification + run: bash scripts/browser-smoke.sh + - name: Emit qualification report + run: | + cat > /tmp/everything-rings-digital-twin-qualification.json < qualification/full-vision-software.json <; if (params.get("campaign-author") === "1") return ; if (params.get("campaign") === "1") return ; + if (params.get("twin") === "1") return ; + if (params.get("atlas") === "1") return ; if (params.get("lab") === "1") return ; return ; } diff --git a/apps/web/src/AtlasApp.tsx b/apps/web/src/AtlasApp.tsx new file mode 100644 index 00000000..3b6d34d6 --- /dev/null +++ b/apps/web/src/AtlasApp.tsx @@ -0,0 +1,114 @@ +import type { AcousticFingerprintV1 } from "@everything-rings/dsp"; +import { + contentDigest, + createAtlasRecord, + createDerivationRecord, + createMeasurementRecord, + emptyResearchRepository, + ingestDerivation, + ingestMeasurement, + publishRepositoryAtlasRecord, + searchAtlas, + snapshotAtlasRegistry, + verifyResearchRepositoryIntegrity, + type ResearchRepositoryStateV1, +} from "@everything-rings/validation"; +import { useState } from "react"; + +const REVISION = "0123456789abcdef0123456789abcdef01234567"; + +function fingerprint(base: number): AcousticFingerprintV1 { + return { + version: 1, + algorithmVersion: "er-dsp-2", + sampleRate: 48_000, + durationSeconds: 2, + modes: [1, 2, 3, 4].map((multiplier, index) => ({ + frequencyHz: base * multiplier, + 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 }, + })), + }; +} + +async function buildDemoRepository(): Promise { + let repository = emptyResearchRepository(); + for (const [index, specimen] of [ + { id: "digital-atlas-bell", label: "Digital Atlas Bell", family: "bell", base: 440 }, + { id: "digital-atlas-plate", label: "Digital Atlas Plate", family: "plate", base: 620 }, + ].entries()) { + const fp = fingerprint(specimen.base); + const measurement = await createMeasurementRecord({ + createdAt: `2026-08-24T15:3${index}:00.000Z`, + specimenId: specimen.id, + sessionId: `atlas-session-${index + 1}`, + attemptId: 1, + material: "other", + acquisitionContractVersion: "digital-atlas-demo-1", + softwareRevision: REVISION, + fingerprintAlgorithmVersion: fp.algorithmVersion, + setup: { fixedSetup: true, microphoneDistanceCm: 20, striker: "digital impulse", strikeLocation: "digital-A", supportCondition: "digital fixed" }, + station: { stationId: "digital-atlas-station", deviceDescription: "software", operatingSystem: "browser", runtime: "local", microphoneDescription: null, captureSettings: { sampleRate: 48_000, channelCount: 1 } }, + fingerprint: fp, + }); + repository = await ingestMeasurement(repository, measurement); + const derivation = await createDerivationRecord({ + measurementId: measurement.measurementId, + kind: "visualization", + algorithmVersion: "atlas-demo-visualization-1", + configDigest: await contentDigest({ visualization: "modal-card" }), + artifactDigest: await contentDigest({ specimen: specimen.id, modes: fp.modes.map((mode) => mode.frequencyHz) }), + createdAt: `2026-08-24T15:4${index}:00.000Z`, + }); + repository = await ingestDerivation(repository, derivation); + const record = await createAtlasRecord({ + createdAt: `2026-08-24T15:5${index}:00.000Z`, + contributor: { contributorId: "digital-software", displayName: "Built-in software qualification" }, + specimen: { specimenId: specimen.id, label: specimen.label, objectFamily: specimen.family, material: "other", publicDescription: "Synthetic Atlas record for software qualification" }, + measurements: [{ measurementId: measurement.measurementId, derivationIds: [derivation.derivationId] }], + }); + repository = await publishRepositoryAtlasRecord(repository, record); + } + return repository; +} + +export function AtlasApp() { + const [repository, setRepository] = useState(); + const [integrity, setIntegrity] = useState("not run"); + const [snapshotId, setSnapshotId] = useState("—"); + + async function run(): Promise { + const next = await buildDemoRepository(); + const report = await verifyResearchRepositoryIntegrity(next); + const snapshot = await snapshotAtlasRegistry(next.atlas, "2026-08-24T16:00:00.000Z"); + setRepository(next); + setIntegrity(report.valid ? `PASS · ${report.measurementCount} measurements · ${report.derivationCount} derivations · ${report.atlasRecordCount} records` : `FAIL · ${report.reasons.join("; ")}`); + setSnapshotId(snapshot.snapshotId); + } + + const records = repository === undefined ? [] : searchAtlas(repository.atlas, {}); + return
+
+

EVERYTHING RINGS / RESONANCE ATLAS LAB

+

Content-addressed acoustic provenance

+

Builds a complete local measurement → derivation → public Atlas graph. The demo is synthetic and contains no microphone PCM.

+
+
Integrity: {integrity}
+
+

MEASUREMENTS

{repository?.measurements.length ?? 0}

+

DERIVATIONS

{repository?.derivations.length ?? 0}

+

ATLAS RECORDS

{repository?.atlas.records.length ?? 0}

+
+
+

PUBLIC INDEX

Specimens

+
+
specimenlabelfamilymaterialmeasurementscanonicalPCMstatus
+ {records.map((record) =>
{record.specimenId}{record.label}{record.objectFamily}{record.material}{record.measurementCount}{record.canonicalSpecimenId}noneverified
)} +
+

Snapshot: {snapshotId}

+
+
; +} diff --git a/apps/web/src/DigitalTwinEndToEnd.test.ts b/apps/web/src/DigitalTwinEndToEnd.test.ts new file mode 100644 index 00000000..be15e138 --- /dev/null +++ b/apps/web/src/DigitalTwinEndToEnd.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it } from "vitest"; +import { assessCaptureQuality, type AudioCapture } from "@everything-rings/acquisition"; +import { analyzeImpact, extractImpactRingdown } from "@everything-rings/dsp"; +import { compareSonicTwinBaseline } from "@everything-rings/fingerprint"; +import { renderPlayableNote } from "@everything-rings/instrument"; +import { renderAcousticFingerprint } from "@everything-rings/synth"; +import { + contentDigest, + createAtlasRecord, + createDerivationRecord, + createMeasurementRecord, + verifyAtlasRecord, + verifyDerivationRecord, + verifyMeasurementRecord, +} from "@everything-rings/validation"; + +const SAMPLE_RATE = 48_000; +const REVISION = "0123456789abcdef0123456789abcdef01234567"; + +function deterministicNoise(seed: number): () => number { + let state = seed >>> 0; + return () => { + state = (1664525 * state + 1013904223) >>> 0; + return state / 0x1_0000_0000; + }; +} + +function digitalTwinCapture(seed = 1): AudioCapture { + const triggerSample = Math.round(0.15 * SAMPLE_RATE); + const durationSeconds = 3.0; + const samples = new Float32Array(Math.round(durationSeconds * SAMPLE_RATE)); + const noise = deterministicNoise(seed); + const modes = [ + { f: 431, tau: 1.35, a: 0.22, p: 0.2 }, + { f: 907, tau: 1.05, a: 0.15, p: 0.8 }, + { f: 1499, tau: 0.82, a: 0.11, p: 1.4 }, + { f: 2281, tau: 0.65, a: 0.08, p: 2.1 }, + { f: 3359, tau: 0.52, a: 0.05, p: 2.7 }, + ]; + for (let index = 0; index < samples.length; index += 1) { + const background = 0.00025 * (2 * noise() - 1); + if (index < triggerSample) { + samples[index] = background; + continue; + } + const t = (index - triggerSample) / SAMPLE_RATE; + let value = background; + for (const mode of modes) value += mode.a * Math.exp(-t / mode.tau) * Math.sin(2 * Math.PI * mode.f * t + mode.p); + samples[index] = Math.max(-0.92, Math.min(0.92, value)); + } + return { samples, sampleRate: SAMPLE_RATE, triggerSample }; +} + +function finiteSignal(samples: Float32Array): boolean { + return samples.length > 0 && samples.every((value) => Number.isFinite(value)); +} + +function peakMagnitude(samples: Float32Array): number { + let peak = 0; + for (const value of samples) peak = Math.max(peak, Math.abs(value)); + return peak; +} + +describe("digital-twin full-stack qualification", () => { + it("exercises acquisition quality, DSP, reconstruction, instrument, provenance, Twin baseline, and Atlas without a physical object", async () => { + const capture = digitalTwinCapture(); + const quality = assessCaptureQuality(capture); + expect(quality.ok).toBe(true); + + const ringdown = extractImpactRingdown(capture.samples, capture.sampleRate, capture.triggerSample); + const analysis = analyzeImpact(ringdown.samples, capture.sampleRate); + expect(analysis.ok).toBe(true); + if (!analysis.ok) return; + expect(analysis.fingerprint.modes.length).toBeGreaterThanOrEqual(3); + expect(analysis.fingerprint.algorithmVersion).toBe("er-dsp-2"); + + const reconstructed = renderAcousticFingerprint(analysis.fingerprint, SAMPLE_RATE); + expect(finiteSignal(reconstructed)).toBe(true); + expect(peakMagnitude(reconstructed)).toBeLessThanOrEqual(0.90001); + + const reanalysis = analyzeImpact(reconstructed, SAMPLE_RATE); + expect(reanalysis.ok).toBe(true); + if (reanalysis.ok) { + const reconstructionComparison = compareSonicTwinBaseline(analysis.fingerprint, reanalysis.fingerprint); + expect(reconstructionComparison.decision).not.toBe("nonmatch"); + expect(reconstructionComparison.calibratedProbability).toBeNull(); + } + + for (const midiNote of [48, 60, 72, 84]) { + const note = renderPlayableNote(analysis.fingerprint, midiNote, SAMPLE_RATE); + expect(finiteSignal(note.samples)).toBe(true); + expect(note.frequencyScale).toBeGreaterThan(0); + expect(note.targetFrequencyHz).toBeGreaterThan(0); + } + + const measurement = await createMeasurementRecord({ + createdAt: "2026-08-24T15:00:00.000Z", + specimenId: "digital-twin-001", + sessionId: "digital-session-001", + attemptId: 5, + material: "other", + acquisitionContractVersion: "digital-twin-acquisition-1", + softwareRevision: REVISION, + fingerprintAlgorithmVersion: analysis.fingerprint.algorithmVersion, + setup: { + fixedSetup: true, + microphoneDistanceCm: 20, + striker: "digital impulse", + strikeLocation: "digital coordinate 0", + supportCondition: "digital fixed boundary", + }, + station: { + stationId: "digital-station-001", + deviceDescription: "deterministic synthetic acoustic twin", + operatingSystem: "ci", + runtime: "vitest", + microphoneDescription: null, + captureSettings: { sampleRate: SAMPLE_RATE, channelCount: 1 }, + }, + fingerprint: analysis.fingerprint, + }); + expect(await verifyMeasurementRecord(measurement)).toBe(true); + + const derivation = await createDerivationRecord({ + measurementId: measurement.measurementId, + kind: "renderer", + algorithmVersion: "modal-renderer-current", + configDigest: await contentDigest({ renderer: "default" }), + artifactDigest: await contentDigest(Array.from(reconstructed.slice(0, 4096))), + createdAt: "2026-08-24T15:01:00.000Z", + }); + expect(await verifyDerivationRecord(derivation)).toBe(true); + + const atlas = await createAtlasRecord({ + createdAt: "2026-08-24T15:02:00.000Z", + contributor: null, + specimen: { + specimenId: "digital-twin-001", + label: "CI digital twin", + objectFamily: "synthetic-reference", + material: "unknown", + publicDescription: "Non-physical qualification artifact", + }, + measurements: [{ measurementId: measurement.measurementId, derivationIds: [derivation.derivationId] }], + }); + expect(atlas.rawMicrophoneSamplesIncluded).toBe(false); + expect(await verifyAtlasRecord(atlas)).toBe(true); + }); +}); diff --git a/apps/web/src/FullVisionSystem.test.ts b/apps/web/src/FullVisionSystem.test.ts new file mode 100644 index 00000000..bc434f89 --- /dev/null +++ b/apps/web/src/FullVisionSystem.test.ts @@ -0,0 +1,218 @@ +import { describe, expect, it } from "vitest"; +import { assessCaptureQuality, type AudioCapture } from "@everything-rings/acquisition"; +import { analyzeImpact, extractImpactRingdown, type AcousticFingerprintV1 } from "@everything-rings/dsp"; +import { + benchmarkSonicTwinRetrieval, + buildAcousticObjectModel, + buildSonicTwinIndex, + compareFingerprintToObjectModel, + fitAndEvaluateHeldOutCalibration, + type AcousticObjectObservationV1, + type PartitionedSimilarityScoreV1, +} from "@everything-rings/fingerprint"; +import { renderPlayableNote } from "@everything-rings/instrument"; +import { + buildAtlasGraph, + contentDigest, + createAtlasCollection, + createAtlasRecord, + createDerivationRecord, + createMeasurementRecord, + createStationCalibrationProtocol, + emptyResearchRepository, + evaluateVerifiedStationCalibration, + ingestDerivation, + ingestMeasurement, + publishRepositoryAtlasRecord, + searchAtlas, + snapshotAtlasRegistry, + verifyResearchRepositoryIntegrity, +} from "@everything-rings/validation"; + +const SAMPLE_RATE = 48_000; +const REVISION = "0123456789abcdef0123456789abcdef01234567"; + +interface DigitalSpecimen { + readonly id: string; + readonly baseModes: readonly { f: number; tau: number; a: number }[]; +} + +const SPECIMENS: readonly DigitalSpecimen[] = [ + { id: "twin-bell-a", baseModes: [{ f: 431, tau: 1.1, a: 0.20 }, { f: 907, tau: 0.9, a: 0.14 }, { f: 1499, tau: 0.7, a: 0.10 }, { f: 2281, tau: 0.55, a: 0.07 }] }, + { id: "twin-bell-b", baseModes: [{ f: 472, tau: 1.0, a: 0.19 }, { f: 936, tau: 0.8, a: 0.13 }, { f: 1448, tau: 0.65, a: 0.09 }, { f: 2197, tau: 0.50, a: 0.06 }] }, + { id: "twin-plate-c", baseModes: [{ f: 613, tau: 0.8, a: 0.18 }, { f: 1033, tau: 0.65, a: 0.12 }, { f: 1717, tau: 0.5, a: 0.08 }, { f: 2811, tau: 0.4, a: 0.05 }] }, +]; + +function pseudoRandom(seed: number): () => number { + let state = seed >>> 0; + return () => { + state = Math.imul(1664525, state) + 1013904223 >>> 0; + return state / 0x1_0000_0000; + }; +} + +function captureFor(specimen: DigitalSpecimen, replicate: number): AudioCapture { + const triggerSample = Math.round(0.15 * SAMPLE_RATE); + const samples = new Float32Array(Math.round(2.4 * SAMPLE_RATE)); + const random = pseudoRandom(1000 + replicate * 31 + specimen.id.length * 17); + const frequencyScale = 1 + (replicate - 1) * 0.0007; + const decayScale = 1 + (replicate - 1) * 0.025; + const amplitudeScale = 1 - (replicate - 1) * 0.04; + for (let index = 0; index < samples.length; index += 1) { + const noise = (2 * random() - 1) * 0.00018; + if (index < triggerSample) { + samples[index] = noise; + continue; + } + const t = (index - triggerSample) / SAMPLE_RATE; + let value = noise; + specimen.baseModes.forEach((mode, modeIndex) => { + value += mode.a * amplitudeScale * Math.exp(-t / (mode.tau * decayScale)) + * Math.sin(2 * Math.PI * mode.f * frequencyScale * t + 0.35 * modeIndex + replicate * 0.07); + }); + samples[index] = Math.max(-0.95, Math.min(0.95, value)); + } + return { samples, sampleRate: SAMPLE_RATE, triggerSample }; +} + +function fingerprintFromCapture(capture: AudioCapture): AcousticFingerprintV1 { + const quality = assessCaptureQuality(capture); + expect(quality.ok).toBe(true); + const ringdown = extractImpactRingdown(capture.samples, capture.sampleRate, capture.triggerSample); + const result = analyzeImpact(ringdown.samples, capture.sampleRate); + expect(result.ok).toBe(true); + if (!result.ok) throw new Error(result.reason); + expect(result.fingerprint.modes.length).toBeGreaterThanOrEqual(3); + return result.fingerprint; +} + +function peak(samples: Float32Array): number { + let maximum = 0; + for (const value of samples) maximum = Math.max(maximum, Math.abs(value)); + return maximum; +} + +describe("EVERYTHING RINGS full-vision software system", () => { + it("qualifies digital capture through held-out calibration, station checks, provenance, Atlas, graph, and playable output", async () => { + const observations: AcousticObjectObservationV1[] = []; + for (const specimen of SPECIMENS) { + for (let replicate = 0; replicate < 3; replicate += 1) { + observations.push({ + observationId: `${specimen.id}-r${replicate + 1}`, + specimenId: specimen.id, + fingerprint: fingerprintFromCapture(captureFor(specimen, replicate)), + }); + } + } + + const index = buildSonicTwinIndex(observations); + expect(index.entries).toHaveLength(3); + const heldOutQueries = SPECIMENS.map((specimen, indexValue) => ({ + queryId: `heldout-${specimen.id}`, + trueSpecimenId: specimen.id, + fingerprint: fingerprintFromCapture(captureFor(specimen, 4 + indexValue)), + })); + const retrieval = benchmarkSonicTwinRetrieval(heldOutQueries, index); + expect(retrieval.recallAt1).toBe(1); + expect(retrieval.meanReciprocalRank).toBe(1); + + const labeledScores: PartitionedSimilarityScoreV1[] = heldOutQueries.flatMap((query, queryIndex) => index.entries.map((entry) => { + const comparison = compareFingerprintToObjectModel(query.fingerprint, entry.model); + return { + pairId: `${query.queryId}::${entry.specimenId}`, + groupId: query.trueSpecimenId, + partition: queryIndex < 2 ? "calibration" as const : "evaluation" as const, + score: comparison.evidenceScore, + samePhysicalSpecimen: query.trueSpecimenId === entry.specimenId, + }; + })); + const calibration = fitAndEvaluateHeldOutCalibration( + labeledScores, + "full-vision-digital-calibration-population-1", + "full-vision-digital-held-out-population-1", + 5, + [0, 0.2, 0.4], + ); + expect(calibration.calibrationGroups).toHaveLength(2); + expect(calibration.evaluationGroups).toHaveLength(1); + expect(calibration.metrics.rocAuc).not.toBeNull(); + expect(calibration.metrics.rocAuc!).toBeGreaterThan(0.9); + expect(calibration.riskCoverage[0]?.coverage).toBe(1); + + const referenceModel = buildAcousticObjectModel(observations.filter((observation) => observation.specimenId === SPECIMENS[0]!.id)); + const stationProtocol = await createStationCalibrationProtocol({ + createdAt: "2026-08-24T16:00:00.000Z", + referencePopulation: "full-vision-digital-reference-1", + referenceSpecimenId: SPECIMENS[0]!.id, + referenceObjectModel: referenceModel, + minimumCoverage: 0.75, + maximumMedianFrequencyDistanceCents: 45, + minimumMatchedModes: 3, + }); + const stationVerdict = await evaluateVerifiedStationCalibration(stationProtocol, { + stationId: "digital-station-system-test", + createdAt: "2026-08-24T16:01:00.000Z", + specimenId: SPECIMENS[0]!.id, + fingerprint: heldOutQueries[0]!.fingerprint, + }); + expect(stationVerdict.passed).toBe(true); + + let repository = emptyResearchRepository(); + const atlasRecordIds: string[] = []; + for (let indexValue = 0; indexValue < heldOutQueries.length; indexValue += 1) { + const query = heldOutQueries[indexValue]!; + const measurement = await createMeasurementRecord({ + createdAt: `2026-08-24T16:1${indexValue}:00.000Z`, + specimenId: query.trueSpecimenId, + sessionId: `system-session-${indexValue + 1}`, + attemptId: 1, + material: "other", + acquisitionContractVersion: "full-vision-digital-capture-1", + softwareRevision: REVISION, + fingerprintAlgorithmVersion: query.fingerprint.algorithmVersion, + setup: { fixedSetup: true, microphoneDistanceCm: 20, striker: "digital impulse", strikeLocation: "digital A", supportCondition: "digital fixed" }, + station: { stationId: "digital-station-system-test", deviceDescription: "deterministic software twin", operatingSystem: "ci", runtime: "vitest", microphoneDescription: null, captureSettings: { sampleRate: SAMPLE_RATE, channelCount: 1 } }, + fingerprint: query.fingerprint, + }); + repository = await ingestMeasurement(repository, measurement); + const derivation = await createDerivationRecord({ + measurementId: measurement.measurementId, + kind: "similarity", + algorithmVersion: "acoustic-object-model-1+sonic-twin-index-1", + configDigest: await contentDigest({ stationProtocol: stationProtocol.protocolId, calibration: calibration.model.trainingPopulation }), + artifactDigest: await contentDigest({ ranking: index.entries.map((entry) => compareFingerprintToObjectModel(query.fingerprint, entry.model)) }), + createdAt: `2026-08-24T16:2${indexValue}:00.000Z`, + }); + repository = await ingestDerivation(repository, derivation); + const atlas = await createAtlasRecord({ + createdAt: `2026-08-24T16:3${indexValue}:00.000Z`, + contributor: { contributorId: "digital-system", displayName: "Full-vision software qualification" }, + specimen: { specimenId: query.trueSpecimenId, label: query.trueSpecimenId, objectFamily: query.trueSpecimenId.includes("plate") ? "plate" : "bell", material: "other", publicDescription: "Synthetic full-vision system record" }, + measurements: [{ measurementId: measurement.measurementId, derivationIds: [derivation.derivationId] }], + }); + repository = await publishRepositoryAtlasRecord(repository, atlas); + atlasRecordIds.push(atlas.atlasRecordId); + + const playable = renderPlayableNote(query.fingerprint, 60 + indexValue * 7, SAMPLE_RATE); + expect(playable.samples.length).toBeGreaterThan(0); + expect(peak(playable.samples)).toBeLessThanOrEqual(0.90001); + } + + const integrity = await verifyResearchRepositoryIntegrity(repository); + expect(integrity.valid).toBe(true); + expect(searchAtlas(repository.atlas, { text: "twin" }).length).toBeGreaterThanOrEqual(2); + const collection = await createAtlasCollection({ + createdAt: "2026-08-24T16:40:00.000Z", + contributorId: "digital-system", + title: "Full-vision digital resonators", + description: "Software qualification collection", + atlasRecordIds, + }); + const graph = await buildAtlasGraph(repository.atlas, [collection]); + expect(graph.nodes.some((node) => node.kind === "collection")).toBe(true); + expect(graph.edges.filter((edge) => edge.kind === "member-of")).toHaveLength(3); + expect(graph.edges.filter((edge) => edge.kind === "created-by")).toHaveLength(1); + const snapshot = await snapshotAtlasRegistry(repository.atlas, "2026-08-24T16:41:00.000Z"); + expect(snapshot.recordIds).toHaveLength(3); + }, 20_000); +}); \ No newline at end of file diff --git a/apps/web/src/ReleaseApp.tsx b/apps/web/src/ReleaseApp.tsx index 6393e0e1..1c5c3c54 100644 --- a/apps/web/src/ReleaseApp.tsx +++ b/apps/web/src/ReleaseApp.tsx @@ -1,11 +1,16 @@ import { + buildCanonicalReleaseVerdictForRevision, buildReleaseVerdictForRevision, empiricalCampaignSignature, evaluateEmpiricalCampaign, mergeValidationEvidence, parseEmpiricalCampaignJson, + parseGateBPlanJson, + parseGateCPlanJson, parseValidationEvidenceJson, type EmpiricalCampaignV1, + type GateBPlanV1, + type GateCPlanV1, type ValidationEvidenceV5, } from "@everything-rings/validation"; import { useMemo, useState } from "react"; @@ -35,29 +40,62 @@ function Reasons({ reasons }: { readonly reasons: readonly string[] }) { export function ReleaseApp() { const [evidence, setEvidence] = useState([]); const [campaign, setCampaign] = useState(); + const [gateBPlan, setGateBPlan] = useState(); + const [gateCPlan, setGateCPlan] = useState(); const [errors, setErrors] = useState([]); + const gateBReviews = useMemo(() => evidence.flatMap((bundle) => bundle.gateBReviews), [evidence]); const gateCReviews = useMemo(() => evidence.flatMap((bundle) => bundle.gateCReviews), [evidence]); - const verdict = useMemo( - () => buildReleaseVerdictForRevision(evidence, gateBReviews, gateCReviews, "preview", SOFTWARE_REVISION), - [evidence, gateBReviews, gateCReviews], - ); const campaignProgress = useMemo( () => campaign === undefined ? undefined : evaluateEmpiricalCampaign(campaign, evidence), [campaign, evidence], ); + const baseVerdict = useMemo( + () => buildReleaseVerdictForRevision(evidence, gateBReviews, gateCReviews, "preview", SOFTWARE_REVISION), + [evidence, gateBReviews, gateCReviews], + ); + const canonicalVerdict = useMemo( + () => buildCanonicalReleaseVerdictForRevision( + evidence, + gateBReviews, + gateCReviews, + "preview", + SOFTWARE_REVISION, + campaignProgress, + gateBPlan, + gateCPlan, + ), + [evidence, gateBReviews, gateCReviews, campaignProgress, gateBPlan, gateCPlan], + ); + + function reject(filename: string, error: string): void { + setErrors((current) => [...current.filter((item) => !item.startsWith(`${filename}:`)), `${filename}: ${error}`]); + } async function importCampaign(file: File | undefined): Promise { if (file === undefined) return; const parsed = parseEmpiricalCampaignJson(await file.text()); - if (!parsed.ok) { - setErrors((current) => [...current, `${file.name}: ${parsed.error}`]); - return; - } + if (!parsed.ok) return reject(file.name, parsed.error); setCampaign(parsed.campaign); setErrors((current) => current.filter((error) => !error.startsWith(`${file.name}:`))); } + async function importGateBPlan(file: File | undefined): Promise { + if (file === undefined) return; + const parsed = parseGateBPlanJson(await file.text()); + if (!parsed.ok) return reject(file.name, parsed.error); + setGateBPlan(parsed.plan); + setErrors((current) => current.filter((error) => !error.startsWith(`${file.name}:`))); + } + + async function importGateCPlan(file: File | undefined): Promise { + if (file === undefined) return; + const parsed = parseGateCPlanJson(await file.text()); + if (!parsed.ok) return reject(file.name, parsed.error); + setGateCPlan(parsed.plan); + setErrors((current) => current.filter((error) => !error.startsWith(`${file.name}:`))); + } + async function importFiles(files: FileList | null): Promise { if (files === null) return; const loaded: Array<{ readonly filename: string; readonly evidence: ValidationEvidenceV5 }> = []; @@ -76,29 +114,39 @@ export function ReleaseApp() { continue; } const merged = mergeValidationEvidence(existing, item.evidence); - if (!merged.ok) { - nextErrors.push(`${item.filename}: ${merged.error}`); - continue; - } - bySession.set(item.evidence.sessionId, merged.evidence); + if (!merged.ok) nextErrors.push(`${item.filename}: ${merged.error}`); + else bySession.set(item.evidence.sessionId, merged.evidence); } - setEvidence([...bySession.values()].sort((left, right) => left.object.label.localeCompare(right.object.label))); setErrors(nextErrors); } + function clearAll(): void { + setEvidence([]); + setCampaign(undefined); + setGateBPlan(undefined); + setGateCPlan(undefined); + setErrors([]); + } + function exportVerdict(): void { - const finalVerdict = buildReleaseVerdictForRevision( + const createdAt = new Date().toISOString(); + const finalVerdict = buildCanonicalReleaseVerdictForRevision( evidence, gateBReviews, gateCReviews, - new Date().toISOString(), + createdAt, SOFTWARE_REVISION, + campaignProgress, + gateBPlan, + gateCPlan, ); - downloadJson(`everything-rings-release-verdict-${Date.now()}.json`, { + downloadJson(`everything-rings-canonical-release-verdict-${Date.now()}.json`, { ...finalVerdict, evidenceContractVersion: "validation-evidence-5", gateAContractVersion: "gate-a-2", + gateBPlanContractVersion: "gate-b-plan-1", + gateCPlanContractVersion: "gate-c-plan-1", authorizedCollectionSoftwareRevision: SOFTWARE_REVISION_VALID ? SOFTWARE_REVISION : null, empiricalCampaign: campaign === undefined ? null : { campaignContractVersion: campaign.campaignContractVersion, @@ -107,6 +155,8 @@ export function ReleaseApp() { authorizedSoftwareRevision: campaign.authorizedSoftwareRevision, progress: campaignProgress, }, + gateBPlan: gateBPlan ?? null, + gateCPlan: gateCPlan ?? null, evidenceSessions: evidence.map((bundle) => ({ sessionId: bundle.sessionId, object: bundle.object, @@ -116,114 +166,87 @@ export function ReleaseApp() { }); } + const canonicalB = canonicalVerdict.gateB; + const canonicalC = canonicalVerdict.gateC; + return

EVERYTHING RINGS / RELEASE CONSOLE

Empirical release gates

-

Import a precommitted campaign and local validation-evidence-5 bundles. The console accounts for planned failures and evaluates frozen Gate A2/B/C contracts without uploading evidence or microphone audio.

+

Canonical release authority requires the complete precommitted campaign, Gate A2, one frozen five-target/two-reviewer Gate B plan, and one frozen four-target/two-reviewer/two-device Gate C plan. All evaluation remains local and microphone PCM is never imported.

- - - {campaign === undefined ? "no campaign" : campaign.campaignId} · {evidence.length} sessions · {gateBReviews.length} Gate B · {gateCReviews.length} Gate C + + + +
+

{campaign === undefined ? "no campaign" : campaign.campaignId} · {evidence.length} sessions · Gate B plan {gateBPlan === undefined ? "missing" : "loaded"} · Gate C plan {gateCPlan === undefined ? "missing" : "loaded"} · {gateBReviews.length} B judgments · {gateCReviews.length} C judgments

{errors.length > 0 ?

Rejected files

: null} - {campaign !== undefined && campaignProgress !== undefined ?
-
-

EMPIRICAL CAMPAIGN / PRECOMMITTED

{campaignProgress.collectionComplete ? "Collection accounted for" : "Collection still open"}

- -
-
-
CAMPAIGN{campaign.campaignId}
-
SIGNATURE{campaignProgress.campaignSignature}
-
PLANNED SPECIMENS{campaignProgress.plannedSpecimenCount}
-
COMPLETE SESSIONS{campaignProgress.conformingCompleteSessionCount} / {campaignProgress.plannedSessionCount}
-
GATE A2 PASSING{campaignProgress.passingSessionCount}
-
ANALYTICAL FAILURES{campaignProgress.analyticalFailureCount}
-
-

Campaign revision: {campaign.authorizedSoftwareRevision}{campaign.authorizedSoftwareRevision === SOFTWARE_REVISION ? " · matches this build" : " · differs from this build"}

- - {campaignProgress.unplannedSpecimenIds.length > 0 ?

Unplanned evidence: {campaignProgress.unplannedSpecimenIds.join(", ")}

: null} -
-
cohortspecimenmaterialsessionspassingfailuresstatus
- {campaignProgress.specimens.map((specimen) =>
- {specimen.cohort} - {specimen.specimenId}{specimen.label} - {specimen.material} - {specimen.conformingCompleteSessionCount} / {specimen.targetSessions} - {specimen.passingSessionCount} - {specimen.analyticalFailureCount} - {specimen.complete ? "ACCOUNTED" : "OPEN"} - {specimen.reasons.length > 0 ?
: null} -
)} -
-
: null} -
-

RELEASE

{verdict.releaseReady ? "Empirically ready" : "Evidence incomplete"}

-

Release becomes ready only when Gate A2, Gate B, and Gate C all pass their frozen contracts. Campaign accounting is a collection-integrity layer and does not lower or replace those gates.

+

CANONICAL RELEASE / V8

{canonicalVerdict.releaseReady ? "Empirically ready" : "Evidence incomplete"}

+

The older lower-level Gate B/C evaluators remain available for diagnostics, but they cannot make this console release-ready without the exact frozen plans and complete campaign accounting.

Authorized collection revision: {SOFTWARE_REVISION_VALID ? SOFTWARE_REVISION : "invalid / unset"}

-

Evidence revision: {verdict.softwareRevision ?? "mixed / no evidence"}

+ +
+ +
+

CAMPAIGN

Complete accounting

+

{campaignProgress?.conformingCompleteSessionCount ?? 0} / {campaignProgress?.plannedSessionCount ?? 12} complete planned sessions

+
+
-

GATE A2 / PHYSICAL

Repeatable acoustic structure

-

{verdict.gateA.distinctPassingSpecimenCount} / 5 distinct passing specimens

- +

GATE A2 / PHYSICAL

Repeatable acoustic structure

+

{baseVerdict.gateA.distinctPassingSpecimenCount} distinct passing specimens

+
+
-

GATE B / PERCEPTUAL

Reconstruction identity

-

{verdict.gateB.passingSpecimenCount} / 4 required passing specimens

- +

GATE B / CANONICAL

Fixed blinded panel

+

{canonicalB?.observedJudgmentCount ?? 0} / 10 canonical judgments

+
+
-

GATE C / PLAYABLE

Instrument identity

-

{verdict.gateC.passingSpecimenCount} / 4 specimens · {verdict.gateC.distinctDeviceCount} / 2 devices

- +

GATE C / CANONICAL

Complete 4×2×2 matrix

+

{canonicalC?.observedJudgmentCount ?? 0} / 16 canonical judgments

+
+ {campaign !== undefined && campaignProgress !== undefined ?
+

EMPIRICAL CAMPAIGN / PRECOMMITTED

{campaignProgress.collectionComplete ? "Collection accounted for" : "Collection still open"}

+
+
CAMPAIGN{campaign.campaignId}
+
SIGNATURE{campaignProgress.campaignSignature}
+
PLANNED{campaignProgress.plannedSpecimenCount}
+
COMPLETE{campaignProgress.conformingCompleteSessionCount}
+
A2 PASSING{campaignProgress.passingSessionCount}
+
ANALYTICAL FAILURES{campaignProgress.analyticalFailureCount}
+
+

Campaign revision: {campaign.authorizedSoftwareRevision}{campaign.authorizedSoftwareRevision === SOFTWARE_REVISION ? " · matches this build" : " · differs from this build"}

+
: null} +

EVIDENCE

Object sessions

specimenobjectmaterialattemptsdriftBCA2
{evidence.map((bundle) => { - const sessionVerdict = verdict.gateA.sessions.find((session) => session.sessionId === bundle.sessionId); + const sessionVerdict = baseVerdict.gateA.sessions.find((session) => session.sessionId === bundle.sessionId); return
- {bundle.object.specimenId} - {bundle.object.label} - {bundle.object.material} - {bundle.attempts.length} - {sessionVerdict?.metrics.sessionMedianDriftCents === null || sessionVerdict?.metrics.sessionMedianDriftCents === undefined ? "—" : `${sessionVerdict.metrics.sessionMedianDriftCents.toFixed(1)}¢`} - {bundle.gateBReviews.length} - {bundle.gateCReviews.length} - {sessionVerdict === undefined ? "—" : sessionVerdict.passed ? "PASS" : "OPEN"} + {bundle.object.specimenId}{bundle.object.label}{bundle.object.material}{bundle.attempts.length} + {sessionVerdict?.metrics.sessionMedianDriftCents == null ? "—" : `${sessionVerdict.metrics.sessionMedianDriftCents.toFixed(1)}¢`} + {bundle.gateBReviews.length}{bundle.gateCReviews.length}{sessionVerdict?.passed ? "PASS" : "OPEN"}
; })}
- - {verdict.gateA.sessions.length > 0 ?
- {verdict.gateA.sessions.map((session) =>
-

{session.objectLabel}

-
-
specimen ID
{session.specimenId}
-
software revision
{session.softwareRevision}
-
qualified attempts
{session.metrics.qualifiedAttempts} / 5
-
quality passing
{session.metrics.qualityPassingAttempts} / 5
-
analysis success
{session.metrics.successfulAnalyses} / 5
-
analysis failures
{session.metrics.analyticalFailures}
-
matched comparisons
{session.metrics.comparisonsWithEnoughMatches} / 4
-
median drift
{session.metrics.sessionMedianDriftCents === null ? "—" : `${session.metrics.sessionMedianDriftCents.toFixed(1)}¢`}
-
worst comparison
{session.metrics.worstComparisonMedianDriftCents === null ? "—" : `${session.metrics.worstComparisonMedianDriftCents.toFixed(1)}¢`}
-
- -
)} -
: null}
; } diff --git a/apps/web/src/TwinLabApp.tsx b/apps/web/src/TwinLabApp.tsx new file mode 100644 index 00000000..03b81a63 --- /dev/null +++ b/apps/web/src/TwinLabApp.tsx @@ -0,0 +1,149 @@ +import type { AcousticFingerprintV1 } from "@everything-rings/dsp"; +import { + benchmarkSonicTwinRetrieval, + buildSonicTwinIndex, + buildTwinVerificationPairs, + compareFingerprintToObjectModel, + fitAndEvaluateHeldOutCalibration, + retrieveSonicTwin, + validateTwinBenchmarkCorpus, + type AcousticObjectObservationV1, + type NuisanceMetadataV1, + type PartitionedSimilarityScoreV1, + type TwinBenchmarkCorpusV1, +} from "@everything-rings/fingerprint"; +import { useMemo } from "react"; + +function fp(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: "digital-A", + striker: "digital-impulse", + support: "digital-fixed", + microphoneDistanceClass: "digital-20cm", + room: "digital-room", + stationId: "digital-station", + operatorId: "software", + dayId: "digital-day-1", +}; + +const SPECIMENS = [ + { id: "digital-bell-a", frequencies: [440, 880, 1320, 1760] }, + { id: "digital-bell-b", frequencies: [470, 940, 1410, 1880] }, + { id: "digital-plate-c", frequencies: [620, 1010, 1670, 2510] }, + { id: "digital-bell-d", frequencies: [523, 1046, 1570, 2093] }, + { id: "digital-plate-e", frequencies: [710, 1190, 1860, 2740] }, + { id: "digital-bowl-f", frequencies: [355, 760, 1285, 2010] }, +] as const; + +const OBSERVATIONS: readonly AcousticObjectObservationV1[] = SPECIMENS.flatMap((specimen) => [ + { observationId: `${specimen.id}-1`, specimenId: specimen.id, fingerprint: fp(specimen.frequencies) }, + { observationId: `${specimen.id}-2`, specimenId: specimen.id, fingerprint: fp(specimen.frequencies.map((frequency, index) => frequency * (1 + 0.0015 + index * 0.00015))) }, +]); + +function objectFamily(specimenId: string): string { + if (specimenId.includes("plate")) return "plate"; + if (specimenId.includes("bowl")) return "bowl"; + return "bell"; +} + +function corpus(): TwinBenchmarkCorpusV1 { + return { + schemaVersion: 1, + corpusContractVersion: "sonic-twin-benchmark-corpus-1", + corpusId: "built-in-digital-twin-demo-2", + createdAt: "2026-08-24T15:30:00.000Z", + observations: OBSERVATIONS.map((observation, index) => ({ + ...observation, + objectFamily: objectFamily(observation.specimenId), + source: "digital-twin", + nuisance: { ...nuisance, strikeLocation: index % 2 === 0 ? "digital-A" : "digital-B" }, + })), + }; +} + +export function TwinLabApp() { + const report = useMemo(() => { + const benchmarkCorpus = corpus(); + const validation = validateTwinBenchmarkCorpus(benchmarkCorpus); + const index = buildSonicTwinIndex(OBSERVATIONS); + const queries = SPECIMENS.map((specimen, indexValue) => ({ + queryId: `query-${specimen.id}`, + trueSpecimenId: specimen.id, + fingerprint: fp(specimen.frequencies.map((frequency, modeIndex) => frequency * (1 + 0.0007 + indexValue * 0.00005 + modeIndex * 0.00003))), + })); + const ranking = retrieveSonicTwin(queries[0]!.fingerprint, index); + const retrievalMetrics = benchmarkSonicTwinRetrieval(queries, index); + const pairs = buildTwinVerificationPairs(benchmarkCorpus, 60); + const scores: PartitionedSimilarityScoreV1[] = queries.flatMap((query, queryIndex) => index.entries.map((entry) => ({ + pairId: `${query.queryId}::${entry.specimenId}`, + groupId: query.trueSpecimenId, + partition: queryIndex < 3 ? "calibration" as const : "evaluation" as const, + score: compareFingerprintToObjectModel(query.fingerprint, entry.model).evidenceScore, + samePhysicalSpecimen: query.trueSpecimenId === entry.specimenId, + }))); + const heldOutCalibration = fitAndEvaluateHeldOutCalibration( + scores, + "built-in-digital-calibration-population-2", + "built-in-digital-held-out-population-2", + ); + return { validation, index, ranking, retrievalMetrics, pairs, heldOutCalibration }; + }, []); + + return
+
+

EVERYTHING RINGS / SONIC TWIN LAB

+

Object models, retrieval, held-out calibration, abstention

+

A local software-only qualification surface. The built-in corpus is synthetic and is not physical identity evidence.

+
+ +
+
+

CORPUS

+

{report.validation.valid ? "VALID" : "INVALID"}

+

{report.validation.observationCount} observations · {report.validation.specimenCount} specimens

+
+
+

RETRIEVAL

+

Recall@1 {report.retrievalMetrics.recallAt1?.toFixed(3) ?? "—"}

+

MRR {report.retrievalMetrics.meanReciprocalRank?.toFixed(3) ?? "—"}

+
+
+

HELD-OUT CALIBRATION / DIGITAL POPULATION

+

Brier {report.heldOutCalibration.metrics.brierScore?.toFixed(3) ?? "—"}

+

AUC {report.heldOutCalibration.metrics.rocAuc?.toFixed(3) ?? "—"} · {report.heldOutCalibration.evaluationGroups.length} held-out groups

+
+
+ +
+

QUERY

Ranked specimen models

+
+
rankspecimenscoredistancemodescoveragestatusclaim
+ {report.ranking.map((result) =>
+ {result.rank}{result.specimenId}{result.evidenceScore.toFixed(3)}{result.normalizedDistance.toFixed(3)}{result.matchedModelModes}/{result.modelModeCount}digital{result.rank === 1 ? "TOP" : ""}ranking only +
)} +
+
+ +
+

Verification pairs

{report.pairs.length} deterministic same-specimen / hard-negative pairs.

+

Risk / coverage

{report.heldOutCalibration.riskCoverage.map((point) => `${point.minimumConfidence.toFixed(2)}→${point.coverage.toFixed(2)}`).join(" · ")}

+

Boundary

No built-in result is a calibrated claim about physical objects. Calibration fitting and evaluation use disjoint digital query groups and remain bound to the named digital populations.

+
+
; +} diff --git a/apps/web/src/campaignAuthoring.test.ts b/apps/web/src/campaignAuthoring.test.ts index 8527eee9..ed0d3050 100644 --- a/apps/web/src/campaignAuthoring.test.ts +++ b/apps/web/src/campaignAuthoring.test.ts @@ -30,6 +30,7 @@ describe("campaign authoring", () => { expect(RECOMMENDED_CAMPAIGN_SLOTS.slice(0, 6).map((slot) => slot.suggestedMaterial)).toEqual([ "metal", "metal", "glass", "glass", "ceramic", "ceramic", ]); + expect(RECOMMENDED_CAMPAIGN_SLOTS.find((slot) => slot.slotId === "challenge-high-q")?.selectionCriterion).not.toMatch(/closely spaced/i); }); it("prefills only protocol constants that are intentionally standardized", () => { @@ -73,6 +74,19 @@ describe("campaign authoring", () => { if (!slotResult.ok) expect(slotResult.errors).toContain("recommended campaign must contain exactly 12 frozen slots"); }); + it("requires two distinct object families for each release-core material pair", () => { + const complete = completedDraft(); + const sameMetalFamily = { + ...complete, + specimens: complete.specimens.map((specimen, index) => index === 1 + ? { ...specimen, objectFamily: complete.specimens[0]?.objectFamily ?? "family-1" } + : specimen), + }; + const result = buildEmpiricalCampaignFromDraft(sameMetalFamily, "2026-08-18T09:00:00.000Z"); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.errors).toContain("release-core metal slots must use two distinct object families"); + }); + it("builds a parser-valid manifest only after every physical field is specified", () => { const result = buildEmpiricalCampaignFromDraft(completedDraft(), "2026-08-18T09:00:00.000Z"); expect(result.ok).toBe(true); diff --git a/apps/web/src/campaignAuthoring.ts b/apps/web/src/campaignAuthoring.ts index 1de28988..bc4cf4f5 100644 --- a/apps/web/src/campaignAuthoring.ts +++ b/apps/web/src/campaignAuthoring.ts @@ -47,9 +47,9 @@ export const RECOMMENDED_CAMPAIGN_SLOTS: readonly CampaignAuthoringSlot[] = [ { slotId: "challenge-short-decay", cohort: "challenge", suggestedMaterial: "wood", selectionCriterion: "strongly damped object with short audible decay" }, { slotId: "challenge-broad", cohort: "challenge", suggestedMaterial: "plastic", selectionCriterion: "weak or broad resonant structure" }, { slotId: "challenge-coupled", cohort: "challenge", suggestedMaterial: "composite", selectionCriterion: "heterogeneous or coupled multi-part object" }, - { slotId: "challenge-high-q", cohort: "challenge", suggestedMaterial: "glass", selectionCriterion: "high-Q object with long decay or closely spaced peaks" }, + { slotId: "challenge-high-q", cohort: "challenge", suggestedMaterial: "glass", selectionCriterion: "low-damping high-Q object with long ringdown; do not select on close-mode or degeneracy behavior" }, { slotId: "challenge-low-snr", cohort: "challenge", suggestedMaterial: "other", selectionCriterion: "small or weakly radiating object near the microphone/SNR floor" }, - { slotId: "challenge-degenerate", cohort: "challenge", suggestedMaterial: "other", selectionCriterion: "geometry likely to produce near-degenerate or strike-location-sensitive modes" }, + { slotId: "challenge-degenerate", cohort: "challenge", suggestedMaterial: "other", selectionCriterion: "geometry likely to produce symmetry, near-degenerate modes, or strike-location sensitivity" }, ] as const; export function createRecommendedCampaignDraft( @@ -79,6 +79,10 @@ function nonempty(value: string): boolean { return value.trim().length > 0; } +function normalized(value: string): string { + return value.trim().toLocaleLowerCase("en-US"); +} + function protocolFromDraft(specimen: CampaignAuthoringSpecimen): FixedSetupProtocol { return { fixedSetup: true, @@ -89,6 +93,15 @@ function protocolFromDraft(specimen: CampaignAuthoringSpecimen): FixedSetupProto }; } +function enforceDistinctCoreFamilies(draft: CampaignAuthoringDraft, errors: string[]): void { + for (const material of ["metal", "glass", "ceramic"] as const) { + const core = draft.specimens.filter((specimen) => specimen.cohort === "release-core" && specimen.material === material); + if (core.length !== 2) continue; + const families = new Set(core.map((specimen) => normalized(specimen.objectFamily)).filter(Boolean)); + if (families.size !== 2) errors.push(`release-core ${material} slots must use two distinct object families`); + } +} + export function buildEmpiricalCampaignFromDraft( draft: CampaignAuthoringDraft, createdAt: string, @@ -110,12 +123,8 @@ export function buildEmpiricalCampaignFromDraft( if (expectedSlot === undefined) { errors.push(`${field}: slot is outside the recommended campaign design`); } else { - if (specimen.slotId !== expectedSlot.slotId) { - errors.push(`${field}: expected frozen slot ${expectedSlot.slotId}`); - } - if (specimen.cohort !== expectedSlot.cohort) { - errors.push(`${field}: cohort must remain ${expectedSlot.cohort}`); - } + if (specimen.slotId !== expectedSlot.slotId) errors.push(`${field}: expected frozen slot ${expectedSlot.slotId}`); + if (specimen.cohort !== expectedSlot.cohort) errors.push(`${field}: cohort must remain ${expectedSlot.cohort}`); if (expectedSlot.cohort === "release-core" && specimen.material !== expectedSlot.suggestedMaterial) { errors.push(`${field}: release-core material must remain ${expectedSlot.suggestedMaterial}`); } @@ -132,14 +141,15 @@ export function buildEmpiricalCampaignFromDraft( if (!Number.isInteger(specimen.targetSessions) || specimen.targetSessions <= 0) { errors.push(`${field}: target sessions must be a positive integer`); } - const normalized = specimen.specimenId.trim().toLocaleLowerCase("en-US"); - if (normalized.length > 0) { - if (seen.has(normalized)) errors.push(`${field}: duplicate specimen ID ${specimen.specimenId.trim()}`); - seen.add(normalized); + const normalizedId = normalized(specimen.specimenId); + if (normalizedId.length > 0) { + if (seen.has(normalizedId)) errors.push(`${field}: duplicate specimen ID ${specimen.specimenId.trim()}`); + seen.add(normalizedId); } }); + enforceDistinctCoreFamilies(draft, errors); - if (errors.length > 0) return { ok: false, errors }; + if (errors.length > 0) return { ok: false, errors: [...new Set(errors)] }; const candidate: EmpiricalCampaignV1 = { schemaVersion: 1, diff --git a/docs/DIGITAL_TWIN_QUALIFICATION.md b/docs/DIGITAL_TWIN_QUALIFICATION.md new file mode 100644 index 00000000..edaa474a --- /dev/null +++ b/docs/DIGITAL_TWIN_QUALIFICATION.md @@ -0,0 +1,79 @@ +# Digital-twin qualification lane + +Digital twins are the executable substitute for unavailable physical-object work during the current software-hardening stage. They are deliberately **not** physical Gate A2 evidence. + +## Claim boundary + +Every digital-twin result carries the semantic flag: + +`releaseGateEquivalent: false` + +A digital twin may demonstrate that acquisition-quality logic, deterministic DSP, recurrence, synthesis, playable transposition, evidence/planning code, provenance, or browser/review workflows behave as designed under controlled signals. It cannot demonstrate: + +- a real microphone transducer path; +- a real physical impact interaction; +- room/support/striker nuisance behavior not encoded in the model; +- perceptual Gate B judgments by human reviewers; +- device-listening Gate C judgments; +- physical-object identity or material inference. + +Digital artifacts must never be imported into the canonical physical Release Console as `validation-evidence-5` campaign sessions. + +## Digital specimen model + +The baseline digital specimen is a deterministic sum of damped sinusoidal modes: + +`x(t) = Σ a_i exp(-t/τ_i) sin(2π f_i t + φ_i) + n(t)` + +where modal frequency, damping, excitation amplitude, phase, and deterministic noise are explicit. Test variants may change gain, modal excitation tilt, damping scale, mode spacing, modal density, and noise level. Each variant is a new modeled interaction with known ground truth. + +This is intentionally a simplified acoustic object model. It is useful because the expected mechanism is known exactly; it is not treated as a complete simulation of a physical object. + +## Required digital stress classes + +The software qualification suite should cover: + +1. ordinary multi-mode resonant objects; +2. short-decay / heavily damped responses; +3. broad or weak modal structure; +4. coupled or multi-cluster modal structure; +5. long-decay / high-Q responses; +6. low-SNR responses; +7. close-mode / near-degenerate responses; +8. gain changes; +9. modal-excitation changes approximating strike-location sensitivity; +10. damping variation; +11. hard-negative digital specimens with different modal geometry; +12. explicit analysis failures that remain failures rather than being retried until success. + +## Qualification layers + +### Acquisition and quality + +Construct captures with a pre-trigger noise segment and known trigger. Exercise SNR, clipping, amplitude, and secondary-transient boundaries. Verify deterministic accept/reject behavior and failure reasons. + +### DSP and recurrence + +Analyze deterministic ringdowns with canonical `analyzeImpact()`. Verify only `er-dsp-2` output is evidence-versioned. Under moderate nuisance variation, evaluate mode recurrence and record matched count and modal drift. Under close-mode or weak-mode stress, failures remain informative and must not be converted to a pass by changing frozen parameters. + +### Reconstruction + +Render the fingerprint through the canonical modal renderer. Check finite output, peak bounds, deterministic rendering, and reanalysis behavior. This is an objective software test; it is not a replacement for blinded Gate B perceptual review. + +### Playable instrument + +Render notes across low, central, and high transpositions. Verify finite output, one global modal frequency ratio, Nyquist filtering, and deterministic note parameters. This is not a replacement for Gate C device listening or end-to-end acoustic latency measurement. + +### Canonical review governance + +Use synthetic evidence objects only in unit tests to prove that Gate B cannot pass without the exact five-target/two-reviewer ten-judgment matrix and Gate C cannot pass without the exact four-target/two-reviewer/two-device sixteen-judgment matrix. Synthetic reviews test evaluator semantics; they are not human evidence. + +### Provenance and Atlas + +On the post-freeze research branch, digital measurements may exercise content-addressed MeasurementRecord/DerivationRecord and Atlas schemas. They must be labeled as synthetic/non-physical artifacts and must not be promoted to calibrated Sonic Twin evidence. + +## Exit criterion + +The digital lane is complete for a revision when typecheck, unit tests, deterministic digital stress tests, build, and browser E2E all pass on the exact revision, and the qualification report explicitly states `releaseGateEquivalent: false`. + +Physical issue #64 remains open after a digital qualification pass. When physical work becomes possible, the actual microphone/transducer preflight is still required before any physical campaign evidence can be accepted. diff --git a/docs/FULL_VISION_SOFTWARE.md b/docs/FULL_VISION_SOFTWARE.md new file mode 100644 index 00000000..8730fda6 --- /dev/null +++ b/docs/FULL_VISION_SOFTWARE.md @@ -0,0 +1,75 @@ +# Full-Vision Software Architecture + +This document defines the software-qualified architecture for the long-term EVERYTHING RINGS system. It does not redefine the empirical release contracts on `main`, and it does not treat digital twins as physical evidence. + +## System model + +The mature data model is deliberately observation-centric: + +`specimen -> interaction -> capture -> immutable measurement -> derivation -> acoustic object model -> retrieval / spatial model -> Atlas publication` + +A capture is one observation. A fingerprint is one deterministic analysis of one observation. Neither is a canonical physical-object identifier. + +## Measurement and derivation boundary + +`MeasurementRecordV1` content-addresses acquisition contract, exact software revision, specimen/session/attempt identity, station metadata, setup metadata, fingerprint algorithm, and fingerprint. Raw microphone samples are excluded. + +Renderer, instrument, similarity, embedding, visualization, and Atlas products are separate `DerivationRecordV1` artifacts rooted in the immutable measurement. Updating a downstream model does not rewrite measurement identity. + +The research repository verifies every measurement, derivation, Atlas record, and specimen-merge content address before treating the repository as integrity-valid. + +## Sonic Twin + +The deterministic reference path provides: + +- repeated-observation `AcousticObjectModelV1` estimates; +- nuisance characterization for strike, support, room, station, operator, and day; +- specimen-disjoint benchmark splitting and hard-negative construction; +- match / nonmatch / abstain verification; +- specimen-level ranked retrieval with Recall@K and MRR; +- group-disjoint calibration fitting and held-out calibration evaluation; +- Brier score, calibration error, ROC AUC, and risk-coverage reporting; +- promotion policy that requires held-out improvement without prohibited regressions. + +Calibrated probabilities are scoped to their named calibration population. They are not universal physical-object probabilities. + +## Spatial acoustic model + +`SpatialModalSoundFieldV1` separates relatively stable modal frequency/decay estimates from strike-location-dependent excitation amplitudes. `fingerprintAtSpatialPoint()` returns a `SpatialPredictedFingerprintV1` that is explicitly `evidenceEligible: false` and carries source algorithm provenance instead of stamping a canonical measurement algorithm version. + +## Research-only estimators and inference + +The high-resolution damped-mode estimator and material-inference baseline are research paths. They are not release fingerprint algorithms and do not change the current scientific claim. Promotion requires separate held-out evidence and an explicit version transition. + +## Station qualification + +Station calibration protocols are content-addressed against a frozen reference object model. Authoritative evaluation first verifies the protocol digest, then evaluates mode coverage, matched-mode support, and modal drift. + +## Resonance Atlas + +Atlas publication is privacy-preserving and content-addressed. The software model includes: + +- immutable Atlas records and snapshots; +- append-only record publication; +- measurement-to-specimen ownership checks; +- explicit, evidence-backed and content-verified specimen merges; +- content-verified collections; +- search and deterministic service conformance; +- contributor, specimen, record, and collection graph projection; +- explicit edges only; no inferred same-object edge is created by graph projection. + +A future network/database adapter must satisfy the same service and artifact contracts rather than redefining them. + +## Assurance boundary + +Software qualification may support propositions about deterministic execution, artifact integrity, synthetic acoustic recovery, retrieval on named digital populations, browser flows, and local service semantics. + +Software qualification does not by itself support propositions about physical-object identity, material identity in the world, human perceptual fidelity, microphone-transducer behavior, or real playback-chain behavior. + +Machine-readable capability claims bind one proposition to its scope, exclusions, evidence references, and maturity. Software qualification manifests are content-addressed, require at least one explicit capability claim, and retain the non-empirical boundary flags. + +## Software qualification + +The full-vision workflow uses frozen dependency installation, repository-wide typechecking, all unit and digital-twin tests, complete builds, browser/lifecycle qualification, and a machine-readable qualification artifact. Workflow actions are pinned to immutable revisions and concurrent superseded runs are cancelled for the same branch or pull request. + +A full-vision software baseline is promotable only when one exact revision passes the complete qualification workflow. The subsequently merged product/research revision must itself be qualified before a named software freeze is created. diff --git a/docs/GATE_A.md b/docs/GATE_A.md index 8045cd56..a0a2c76d 100644 --- a/docs/GATE_A.md +++ b/docs/GATE_A.md @@ -1,6 +1,6 @@ # Gate A2 — physical acoustic validity -Gate A2 is the first physical release gate. It must pass before the measured modal representation is treated as physically validated. +Gate A2 is the first physical release gate. It must pass before the measured modal representation is treated as physically validated. The digital-twin lane is supporting stress validation only and is never release-gate equivalent. ## Protocol @@ -12,7 +12,26 @@ Acquisition-quality failures do not enter the five-attempt experiment and may be This rule eliminates retry-until-success and optional-stopping paths. The experiment is defined by the first five qualified physical attempts, not by the first five successful analyses. -The initial release set is: bell, wine glass, metal bowl, glass bottle, ceramic mug. Equivalent strongly resonant specimens are acceptable if the final passing set still contains distinct metal, glass, and ceramic specimens. +## Frozen campaign cohort + +The canonical first empirical campaign contains exactly twelve precommitted slots. The six release-core slots are: + +- `core-metal-1` and `core-metal-2`, from two distinct metal object families; +- `core-glass-1` and `core-glass-2`, from two distinct glass object families; +- `core-ceramic-1` and `core-ceramic-2`, from two distinct ceramic object families. + +The six challenge slots are: + +- `challenge-short-decay` — strong damping / short audible decay; +- `challenge-broad` — weak or broad resonant structure; +- `challenge-coupled` — heterogeneous or coupled multi-part structure; +- `challenge-high-q` — low damping / long ringdown, intentionally distinct from modal degeneracy; +- `challenge-low-snr` — weakly radiating or near the acquisition SNR floor; +- `challenge-degenerate` — symmetry, near-symmetry, close modes, or strike-location sensitivity. + +Specimen selection is made from a finite non-acoustic candidate register before production audition. The register records physical identity, family, material, safety/support facts, markable strike location, eligible slots, and exclusions; it must not contain fingerprints, frequencies, decay estimates, mode counts, acoustic scores, or audition rankings. A selected specimen binds slot and inventory identity in its `specimenId`, for example `core-metal-1--inv-003`. + +The campaign manifest is frozen before the first production strike. Its exact bytes and campaign signature are retained. Collection order is then derived deterministically from `SHA-256("gate-a2-order-v1||")`, sorting release-core and challenge cohorts separately and interleaving them beginning with release-core. The order is recorded before collection. For every qualified attempt record the native sample rate, actual microphone-processing settings, capture-quality diagnostics, and one terminal analytical outcome: @@ -23,7 +42,7 @@ Raw microphone PCM remains local. ## Frozen release contract — `gate-a-2` -These thresholds are fixed before the local five-specimen Gate A2 dataset is accepted and must not be relaxed after observing measurements. +These thresholds are fixed before the campaign dataset is accepted and must not be relaxed after observing measurements. A specimen session passes only when all of the following hold: @@ -43,9 +62,9 @@ Attempt 1 is never substituted. If attempt 1 fails analysis, the session cannot The exported recurrence rows and aggregate drift are caches for audit and display. The parser requires them to agree with recomputation from the retained qualified-attempt fingerprints, and the release evaluator uses the recomputed values as its source of truth. -Gate A2 passes at release level when all imported sessions come from one software revision and one fingerprint algorithm version, at least **five distinct normalized `specimenId` values** have passing sessions, and the passing specimen set contains metal, glass, and ceramic. Multiple passing sessions for one physical specimen still count as one specimen. Changing only an object label cannot increase release distinctness. Duplicate session IDs invalidate the release verdict. Reusing one normalized `specimenId` with conflicting material classes also invalidates the release-level verdict, including when the conflicting session itself fails physically. +Gate A2 passes at release level when all imported sessions come from one software revision and one fingerprint algorithm version, at least **five distinct normalized `specimenId` values** have passing sessions, and the passing specimen set contains metal, glass, and ceramic. The empirical campaign adds a stronger accounting requirement: all twelve precommitted specimen outcomes must be present or explicitly accounted before canonical adjudication. Multiple passing sessions for one physical specimen still count as one specimen. Changing only an object label cannot increase release distinctness. Duplicate session IDs invalidate the release verdict. Reusing one normalized `specimenId` with conflicting material classes also invalidates the release-level verdict, including when the conflicting session itself fails physically. -The drift bounds are release criteria for repeatable structure. They are not a general perceptual similarity metric. +The drift bounds are release criteria for repeatable structure under the frozen setup. They are not a nuisance-invariance result, a general perceptual similarity metric, an object-identity probability, or a material-identification claim. ## Session-fatal errors @@ -55,13 +74,13 @@ A true session-level internal error is different. The validation lab does not pe ## Evidence and algorithm versioning -Gate A2 uses `validation-evidence-5` with `schemaVersion: 5` and `gateAContractVersion: "gate-a-2"`. Schema v5 requires the exact software commit, and release-level evidence must use one revision and one fingerprint algorithm version. `er-dsp-2` is the canonical estimator for the new physical collection cycle; it adds the frozen -60 dB relative-amplitude measurement-support floor. Historical `er-dsp-1` bundles remain readable for audit but must not be mixed with `er-dsp-2` evidence in one session or release evaluation. +Gate A2 uses `validation-evidence-5` with `schemaVersion: 5` and `gateAContractVersion: "gate-a-2"`. Schema v5 requires the exact software commit, and release-level evidence must use one revision and one fingerprint algorithm version. `er-dsp-2` is the canonical estimator for this physical collection cycle; it includes the frozen -60 dB relative-amplitude measurement-support floor. Historical `er-dsp-1` bundles remain readable for audit but must not be mixed with `er-dsp-2` evidence in one session or release evaluation. Schema v4 was superseded before physical release collection because it lacked implementation provenance; the older record-only Gate A1 format is also superseded because it could not represent qualified analytical failures without selection bias. -## External evidence +## External and digital-twin evidence -External cross-field recurrence is supporting evidence only. It does not satisfy Gate A2 because listener position and other acquisition conditions vary across those measurements. External results must never be used to tune `gate-a-2` after the fact. +External cross-field recurrence and deterministic digital twins are supporting evidence only. Neither satisfies Gate A2 because neither proves the exact physical microphone/transducer/object path under the frozen campaign. They may be used to find software defects, exercise nuisance regimes, and compare future estimators, but must never be imported into the canonical Release Console as physical campaign evidence or used to tune `gate-a-2` after observing campaign data. ## Failure handling diff --git a/docs/GATE_B.md b/docs/GATE_B.md index 95710da9..d88313d5 100644 --- a/docs/GATE_B.md +++ b/docs/GATE_B.md @@ -1,41 +1,60 @@ # Gate B — modal reconstruction -Gate B asks whether the estimated modal fingerprint is sufficient to reconstruct the perceptual identity of a struck physical specimen. +Gate B asks whether the estimated modal fingerprint is sufficient to reconstruct the perceptual identity of a struck physical specimen. It is post-collection and microphone-free. Digital-twin rendering tests are supporting diagnostics only and are not substitutes for blinded human judgments. The baseline renderer is deliberately constrained. Each accepted mode contributes a sinusoid at its estimated frequency with its measured exponential decay. Relative modal amplitudes use the fixed renderer transform, a short attack suppresses synthesis clicks, and the final waveform is normalized. No recorded audio is mixed into the reconstruction. -## Blinded protocol +## Eligibility and canonical target selection Only physical specimens with a passing Gate A2 session are eligible. Gate A2 passes only after exactly five qualified attempts all succeed under the frozen physical contract. Gate B is grouped by stable `specimenId`, not by display label. The review target is the **fifth qualified attempt** of one passing Gate A2 session for that specimen. Each review stores the object label, session ID, and attempt ID. The owning evidence bundle binds that session to its `specimenId`; a review targeting another session, another attempt, or an analytically failed attempt does not count. -If one specimen has more than one passing Gate A2 session, all counted Gate B reviewers for that specimen must hear the **same single session/attempt target**. Reviews from different passing sessions are never pooled. If submitted Gate B reviews for one specimen span multiple eligible targets, that specimen's Gate B state remains OPEN. A label alias on another session does not create another Gate B specimen. +Before any Gate B judgment is collected, create and freeze a `gate-b-plan-1` artifact from the canonical Gate A2 verdict. Exactly five specimens are selected deterministically: + +1. the lowest deterministic selection digest among passing metal specimens; +2. the lowest deterministic selection digest among passing glass specimens; +3. the lowest deterministic selection digest among passing ceramic specimens; +4. the two lowest deterministic selection digests among the remaining passing specimens. + +The plan binds the exact Gate A software revision, the five `(specimenId, sessionId, attemptId)` targets, and exactly two normalized reviewer IDs. Target selection is not changed after listening begins. + +If one specimen has more than one passing Gate A2 session, the plan binds one exact session/attempt target. Reviews from different passing sessions are never pooled. A label alias on another session does not create another Gate B specimen. -A reviewer starts a randomized A/B trial and sees only `A` and `B`; the application stores the hidden original/reconstruction presentation order for audit. Reviewers score: +## Blinded protocol + +Exactly the two reviewer IDs frozen in the plan must each review all five canonical targets, yielding exactly ten canonical judgments. A reviewer starts a randomized A/B trial and sees only `A` and `B`; the application stores the hidden original/reconstruction presentation order for audit. Reviewers score: - same-specimen identity, 1–5; - brightness match, 1–5; - decay-character match, 1–5; - artifact severity, 1–5, where 1 means none and 5 means severe. -At least two distinct reviewer IDs are required per specimen on the selected target. Reviewer IDs are normalized for counting, so capitalization or surrounding whitespace cannot make one reviewer count twice. An unblinded judgment does not count toward the release contract. A reviewer/target judgment is immutable once submitted; conflicting repeated submissions are rejected by the evidence parser and merge path. +Reviewer IDs are normalized for counting, so capitalization or surrounding whitespace cannot make one reviewer count twice. An unblinded judgment does not count toward the release contract. A reviewer/target judgment is immutable once submitted; conflicting repeated submissions are rejected by the evidence parser and merge path. -## Frozen release contract — `gate-b-1` +Extra exploratory judgments may be retained only if they are explicitly classified as non-canonical. They must never alter the canonical ten-judgment release verdict. -These thresholds are fixed before release listening data is collected. +## Frozen release contract — `gate-b-1` with `gate-b-plan-1` -An eligible specimen passes when: +These thresholds and the sampling matrix are fixed before release listening data is collected. -- exactly one passing Gate A2 session/attempt target is represented by its counted review set; -- at least two blinded reviewers are present on that target; +An eligible canonical specimen passes when: + +- it is one of the five targets frozen in the Gate B plan; +- both and only the two fixed reviewers have one blinded judgment on that exact target; - median same-specimen identity is at least 4/5; - median brightness match is at least 3/5; - median decay-character match is at least 3/5; - median artifact severity is at most 2/5. -Gate B passes when at least five Gate A2 specimens have eligible blinded reviews, at least four specimens pass, and the passing reconstruction set contains metal, glass, and ceramic examples. +Canonical Gate B passes only when: + +- all five frozen targets are represented; +- all ten required judgments are present; +- no required reviewer/target cell is missing or substituted; +- at least four of the five specimens pass the score thresholds; and +- the passing reconstruction set contains metal, glass, and ceramic examples. -The numerical regression suite verifies deterministic output, attack continuity, measured decay preservation, and Nyquist filtering. Those checks remain necessary and remain insufficient for Gate B. +The numerical regression suite verifies deterministic output, attack continuity, measured decay preservation, and Nyquist filtering. Digital-twin reconstruction tests additionally exercise objective software behavior. Those checks remain necessary and remain insufficient for Gate B. -Failed listening results remain part of the evidence. Do not change scoring thresholds, renderer parameters, specimen IDs, or review targets solely to convert the existing release set into a pass; a renderer revision requires a new algorithm/versioned evaluation cycle. +Failed listening results remain part of the evidence. Do not change scoring thresholds, renderer parameters, specimen IDs, reviewer IDs, target selection, or the sampling matrix solely to convert the existing release set into a pass; a renderer or protocol revision requires a new versioned evaluation cycle. diff --git a/docs/GATE_C.md b/docs/GATE_C.md index 7475aded..ea31968a 100644 --- a/docs/GATE_C.md +++ b/docs/GATE_C.md @@ -1,6 +1,6 @@ # Gate C — playable specimen identity -Gate C tests whether one measured physical specimen can function as a coherent chromatic instrument without losing its acoustic identity. +Gate C tests whether one measured physical specimen can function as a coherent chromatic instrument without losing its acoustic identity. It is post-Gate-B and microphone-free. Digital-twin instrument tests are supporting diagnostics only and are not substitutes for device listening. The instrument chooses one anchor resonance from the fingerprint. A requested note defines one global frequency ratio, and every modal frequency is multiplied by that ratio. Relative amplitudes and measured decay constants remain unchanged. The specimen therefore retains one modal geometry across notes. @@ -17,11 +17,23 @@ The validation evidence keeps timing quantities separate: These are diagnostics. Their sum is not claimed as measured acoustic end-to-end latency. Device listening or loopback measurement remains necessary. -## Device protocol +## Eligibility and canonical plan + +Only specimens that pass canonical Gate B are eligible. Gate B selects one exact `(sessionId, attemptId)` target per passing `specimenId`, and that target is the fifth qualified attempt of a passing Gate A2 session. Gate C inherits it exactly. A device review for another passing Gate A2 session or another attempt of the same specimen does not count, even when its human-readable label is identical. -Only specimens that pass Gate B are eligible. Gate B selects one exact `(sessionId, attemptId)` target per passing `specimenId`, and that target is the fifth qualified attempt of a passing Gate A2 session. Gate C inherits it exactly. A device review for another passing Gate A2 session or another attempt of the same specimen does not count, even when its human-readable label is identical. +Before any Gate C judgment is collected, create and freeze a `gate-c-plan-1` artifact from the canonical Gate B verdict. It binds: -Play the chromatic instrument across the useful range and record the judgment on the device producing the audio. Each review records a stable device ID/class and scores: +- exactly four deterministically selected Gate-B-passing specimen targets; +- exactly two normalized reviewer IDs; +- exactly two distinct device IDs/classes; +- at least one mobile device among those two; +- the exact inherited Gate B `(sessionId, attemptId)` for every specimen. + +The canonical matrix is therefore exactly **4 specimens × 2 reviewers × 2 devices = 16 judgments**. No required cell may be replaced after listening begins. + +## Device protocol + +Each of the two fixed reviewers evaluates each of the four fixed specimens on both fixed devices. Play the chromatic instrument across the useful range and record the judgment on the device producing the audio. Each review records its stable device ID/class and scores: - identity across the useful note range, 1–5; - timbre continuity across adjacent notes, 1–5; @@ -32,22 +44,29 @@ Include low and high transpositions where Nyquist filtering removes modes. A rev Repeated submissions from the same normalized reviewer ID, device ID, and selected target count once defensively; the normal UI and evidence merge path treat that logical judgment as immutable and reject conflicting repeated submissions. A device ID must map consistently to one device class across the release evidence; conflicting desktop/mobile/tablet claims for the same normalized device ID invalidate Gate C. -## Frozen release contract — `gate-c-1` +Extra exploratory device judgments may be retained only if explicitly classified as non-canonical. They must never alter the canonical sixteen-judgment verdict. + +## Frozen release contract — `gate-c-1` with `gate-c-plan-1` -These thresholds are fixed before release device-listening data is collected. +These thresholds and the sampling matrix are fixed before release device-listening data is collected. -An eligible specimen passes when: +An eligible specimen passes only when all four of its canonical reviewer/device judgments are present and: -- every counted review targets the exact Gate B-selected session/attempt for that specimen; +- every judgment targets the exact Gate B-selected session/attempt; - median identity across the range is at least 4/5; - median timbre continuity is at least 4/5; - median useful range spans at least 12 semitones; -- every counted device review for that specimen accepts note-on latency. +- every one of the four judgments accepts note-on latency. -Gate C passes when at least four Gate B specimens have eligible device reviews and all four pass. **Device diversity is computed only from reviews attached to Gate C-passing specimens:** those passing-specimen reviews must cover at least two distinct normalized device IDs, and at least one of those devices must be consistently identified as mobile. A mobile review attached only to a specimen that fails Gate C cannot satisfy the release mobile/device-diversity requirement. +Canonical Gate C passes only when: -Device-class consistency is checked across all eligible selected-target reviews, including reviews for specimens that fail Gate C. A contradictory device identity remains an evidence-integrity failure even when that review does not contribute to the passing-specimen device count. +- all four frozen specimen targets are represented; +- all sixteen required reviewer/device/target cells are present; +- both planned devices are represented for every specimen; +- both planned reviewers are represented on both devices for every specimen; +- the planned devices include a mobile device; and +- all four specimens pass. -Software scheduling telemetry, modal-ratio preservation, and browser latency reports remain supporting diagnostics. They do not independently satisfy Gate C. +Software scheduling telemetry, modal-ratio preservation, browser latency reports, numerical synthesis tests, and digital-twin transposition tests remain supporting diagnostics. They do not independently satisfy Gate C. -A renderer or realtime-engine revision made in response to failure must be versioned and re-evaluated; do not alter `gate-c-1`, specimen identity, or target provenance solely to fit an observed release set. +A renderer or realtime-engine revision made in response to failure must be versioned and re-evaluated; do not alter `gate-c-1`, the canonical plan, specimen identity, reviewer IDs, device IDs, or target provenance solely to fit an observed release set. diff --git a/docs/SESSION_LIFECYCLE.md b/docs/SESSION_LIFECYCLE.md index 6a0ce420..056d22c7 100644 --- a/docs/SESSION_LIFECYCLE.md +++ b/docs/SESSION_LIFECYCLE.md @@ -1,48 +1,37 @@ -# Consumer session lifecycle +# Empirical session lifecycle -The consumer microphone session uses explicit ownership for every asynchronous startup. This is a product reliability and privacy boundary on `post-freeze-development`; it does not change acquisition thresholds, fingerprint estimation, validation evidence, or empirical gate semantics. +The frozen empirical acquisition path uses explicit ownership for every asynchronous microphone startup. This is a measurement-integrity and privacy boundary. It does not change acquisition-quality thresholds, fingerprint estimation, validation evidence, campaign accounting, or Gate A/B/C scoring semantics. ## Ownership model -Every `start()` receives a monotonically increasing lifecycle generation. `stop()`, unmount, a new `start()`, or a shared-link route transition invalidates the previous generation before resources are released. +Every `start()` receives a monotonically increasing lifecycle generation. A later `start()`, explicit `stop()`, page departure, or component unmount invalidates the previous generation before resources are released. -A callback is allowed to change consumer state only when both conditions hold: +Capture-worklet, analysis-worker, and realtime timing callbacks may change application state only when both their generation and their exact resource object remain current. Events queued by an older session are therefore inert after that session loses ownership. -1. its lifecycle generation is still current; -2. its exact `SessionResources` object is still the active resource owner. - -Capture-worklet messages, analysis-worker success/failure/error messages, and realtime instrument timing callbacks therefore cannot re-enter the UI after their session has been superseded. - -React render state is not used as the source of truth for lifecycle decisions. The hook maintains a synchronous state reference so a callback captured by an earlier render can still distinguish an active session from an already-idle one. +Lifecycle decisions use a synchronous state reference instead of a stale React render closure. ## Startup resources -Microphone acquisition, `AudioContext.resume()`, capture-graph creation, and analysis-worker construction occur before a session becomes active. These partial resources are tracked by `OpeningSessionResources`. - -Cancellation disposes whatever exists immediately. If a delayed operation returns after cancellation, attaching that late resource to the already-disposed startup scope releases it immediately. This covers a microphone stream resolving after cancel as well as graph/worker resources that complete after their generation has lost ownership. +Microphone acquisition, `AudioContext` creation/resume, capture-graph creation, and analysis-worker construction are owned by `OpeningSessionResources` until a complete startup is transferred atomically to the active session. -Startup cancellation is silent. A rejection from a superseded generation must not be converted into a microphone failure message. +Cancellation releases every partial resource already present. A microphone, graph, context, or worker that resolves after cancellation is rejected by the disposed startup scope and cleaned immediately. Cancellation of a superseded startup is silent and must not surface as a microphone-start failure. ## Active resources -Disposal clears event handlers before disconnecting resources, then stops sample playback and realtime notes, disconnects the graph, stops all microphone tracks, terminates the analysis worker, and closes the audio context. +Teardown is failure-isolated. Event handlers are cleared and each playback, worklet, graph, microphone-track, worker, and audio-context cleanup is attempted independently so one unavailable browser primitive cannot prevent release of the others. -Queued events are still guarded by lifecycle generation and exact resource identity, so cleanup does not rely on event-handler removal alone. +`pagehide` stops the complete acquisition session, including microphone tracks, worker, graph, and `AudioContext`. Hidden-page visibility changes silence output but do not themselves reinterpret an in-flight measurement; a real page departure owns the destructive stop boundary. -`pagehide` stops the complete consumer session. A page transition therefore cannot leave microphone capture or an `AudioContext` owned by the departing page. +## Empirical invariant -## Validation +A planned physical attempt is accepted only from the currently owned session. Stale capture or analysis callbacks from a cancelled, departed, or superseded session cannot populate a newer session or alter its qualified-attempt ledger. + +The lifecycle layer changes resource ownership only. It does not modify what constitutes an acquisition-quality-passing attempt or any analytical result. -Deterministic unit coverage exercises: +## Validation -- generation invalidation and start → cancel → start ownership; -- exact-resource callback ownership; -- queued callback no-op after invalidation; -- late microphone cleanup after cancellation; -- partial microphone/context cleanup; -- late graph/worker cleanup; -- one-time transfer from startup ownership to active-session ownership. +Deterministic unit tests cover generation invalidation, exact-resource ownership, stale queued callbacks, late microphone cleanup, partial startup cleanup, late graph/worker cleanup, and one-time startup-to-active ownership transfer. -The browser lifecycle journey additionally exercises an unresolved microphone request, cancellation before resolution, cleanup of the late stream, a fresh subsequent session owner, cancellation of that owner, and a shared-link route superseding another unresolved startup without stale microphone failure UI. +The empirical browser journey covers unresolved microphone start → cancel → late-stream cleanup → fresh session ownership → cancel → unresolved start → `pagehide` → late-stream cleanup → active session → `pagehide` → microphone teardown, with no stale microphone-failure state. -The complete existing consumer, history, Acoustic Capsule, permission, mobile, build, typecheck, and test matrix remains the integration gate. +The complete empirical install, typecheck, test, build, route, acquisition, permission, mobile, and post-collection review matrix remains required for any freeze that includes this lifecycle implementation. diff --git a/packages/dsp/src/analysis/prony-research.ts b/packages/dsp/src/analysis/prony-research.ts new file mode 100644 index 00000000..514df178 --- /dev/null +++ b/packages/dsp/src/analysis/prony-research.ts @@ -0,0 +1,156 @@ +export interface ResearchDampedModeEstimateV1 { + readonly frequencyHz: number; + readonly decaySeconds: number; + readonly poleRadius: number; + readonly poleAngleRadians: number; +} + +export interface PronyResearchResultV1 { + readonly researchEstimatorVersion: "prony-damped-modes-research-1"; + readonly evidenceEligible: false; + readonly requestedModeCount: number; + readonly modes: readonly ResearchDampedModeEstimateV1[]; + readonly residualRms: number; +} + +interface Complex { + re: number; + im: number; +} + +function cadd(a: Complex, b: Complex): Complex { return { re: a.re + b.re, im: a.im + b.im }; } +function csub(a: Complex, b: Complex): Complex { return { re: a.re - b.re, im: a.im - b.im }; } +function cmul(a: Complex, b: Complex): Complex { return { re: a.re * b.re - a.im * b.im, im: a.re * b.im + a.im * b.re }; } +function cdiv(a: Complex, b: Complex): Complex { + const d = b.re * b.re + b.im * b.im; + if (!(d > 0)) return { re: Number.POSITIVE_INFINITY, im: Number.POSITIVE_INFINITY }; + return { re: (a.re * b.re + a.im * b.im) / d, im: (a.im * b.re - a.re * b.im) / d }; +} +function cabs(a: Complex): number { return Math.hypot(a.re, a.im); } + +function solveLinearSystem(matrix: number[][], rhs: number[]): number[] { + const n = rhs.length; + const a = matrix.map((row, index) => [...row, rhs[index]!]); + for (let column = 0; column < n; column += 1) { + let pivot = column; + for (let row = column + 1; row < n; row += 1) { + if (Math.abs(a[row]![column]!) > Math.abs(a[pivot]![column]!)) pivot = row; + } + if (Math.abs(a[pivot]![column]!) < 1e-14) throw new Error("Prony normal equations are singular"); + [a[column], a[pivot]] = [a[pivot]!, a[column]!]; + const scale = a[column]![column]!; + for (let j = column; j <= n; j += 1) a[column]![j] = a[column]![j]! / scale; + for (let row = 0; row < n; row += 1) { + if (row === column) continue; + const factor = a[row]![column]!; + if (factor === 0) continue; + for (let j = column; j <= n; j += 1) a[row]![j] = a[row]![j]! - factor * a[column]![j]!; + } + } + return a.map((row) => row[n]!); +} + +function linearPredictionCoefficients(samples: readonly number[], order: number): number[] { + const normal = Array.from({ length: order }, () => Array(order).fill(0)); + const rhs = Array(order).fill(0); + for (let n = order; n < samples.length; n += 1) { + const target = -samples[n]!; + for (let i = 0; i < order; i += 1) { + const xi = samples[n - i - 1]!; + rhs[i] = rhs[i]! + xi * target; + for (let j = 0; j < order; j += 1) normal[i]![j] = normal[i]![j]! + xi * samples[n - j - 1]!; + } + } + const trace = normal.reduce((sum, row, index) => sum + Math.abs(row[index] ?? 0), 0); + const ridge = Math.max(1e-14, trace * 1e-12 / Math.max(1, order)); + for (let index = 0; index < order; index += 1) normal[index]![index] = normal[index]![index]! + ridge; + return solveLinearSystem(normal, rhs); +} + +function polynomialValue(coefficients: readonly number[], z: Complex): Complex { + let value: Complex = { re: coefficients[0]!, im: 0 }; + for (let index = 1; index < coefficients.length; index += 1) { + value = cadd(cmul(value, z), { re: coefficients[index]!, im: 0 }); + } + return value; +} + +function durandKerner(coefficients: readonly number[]): Complex[] { + const degree = coefficients.length - 1; + if (degree < 1) return []; + let roots = Array.from({ length: degree }, (_, index) => { + const angle = 2 * Math.PI * (index + 0.37) / degree; + return { re: 0.9 * Math.cos(angle), im: 0.9 * Math.sin(angle) }; + }); + for (let iteration = 0; iteration < 300; iteration += 1) { + let maximumStep = 0; + const next = roots.map((root, index) => { + let denominator: Complex = { re: 1, im: 0 }; + for (let other = 0; other < roots.length; other += 1) { + if (other === index) continue; + denominator = cmul(denominator, csub(root, roots[other]!)); + } + if (cabs(denominator) < 1e-18) denominator = cadd(denominator, { re: 1e-12, im: 1e-12 }); + const step = cdiv(polynomialValue(coefficients, root), denominator); + maximumStep = Math.max(maximumStep, cabs(step)); + return csub(root, step); + }); + roots = next; + if (maximumStep < 1e-11) break; + } + return roots; +} + +function residualRms(samples: readonly number[], coefficients: readonly number[]): number { + const order = coefficients.length; + let squared = 0; + let count = 0; + for (let n = order; n < samples.length; n += 1) { + let prediction = 0; + for (let index = 0; index < order; index += 1) prediction -= coefficients[index]! * samples[n - index - 1]!; + squared += (samples[n]! - prediction) ** 2; + count += 1; + } + return count === 0 ? 0 : Math.sqrt(squared / count); +} + +/** + * Research-only Prony/linear-prediction estimator for synthetic close-mode experiments. + * It is intentionally not an AcousticFingerprintV1 producer and is never evidence-eligible. + */ +export function estimateDampedModesPronyResearch( + samples: readonly number[], + sampleRate: number, + modeCount: number, +): PronyResearchResultV1 { + if (!(sampleRate > 0) || !Number.isFinite(sampleRate)) throw new Error("sampleRate must be finite and positive"); + if (!Number.isInteger(modeCount) || modeCount <= 0 || modeCount > 12) throw new Error("modeCount must be an integer in [1,12]"); + const order = 2 * modeCount; + if (samples.length < Math.max(32, order * 8)) throw new Error("insufficient samples for Prony research estimator"); + if (samples.some((value) => !Number.isFinite(value))) throw new Error("Prony samples must be finite"); + + const coefficients = linearPredictionCoefficients(samples, order); + const roots = durandKerner([1, ...coefficients]); + const modes = roots + .map((root) => { + const radius = cabs(root); + const angle = Math.atan2(root.im, root.re); + if (!(angle > 0 && angle < Math.PI) || !(radius > 0 && radius < 1.0005)) return null; + const frequencyHz = angle * sampleRate / (2 * Math.PI); + const safeRadius = Math.min(radius, 0.999999999); + const decaySeconds = -1 / (sampleRate * Math.log(safeRadius)); + if (!(frequencyHz > 0 && frequencyHz < sampleRate / 2) || !(decaySeconds > 0) || !Number.isFinite(decaySeconds)) return null; + return { frequencyHz, decaySeconds, poleRadius: radius, poleAngleRadians: angle }; + }) + .filter((mode): mode is ResearchDampedModeEstimateV1 => mode !== null) + .sort((left, right) => left.frequencyHz - right.frequencyHz) + .slice(0, modeCount); + + return { + researchEstimatorVersion: "prony-damped-modes-research-1", + evidenceEligible: false, + requestedModeCount: modeCount, + modes, + residualRms: residualRms(samples, coefficients), + }; +} diff --git a/packages/dsp/src/index.ts b/packages/dsp/src/index.ts index e2fd0aa6..6ae8b531 100644 --- a/packages/dsp/src/index.ts +++ b/packages/dsp/src/index.ts @@ -8,6 +8,11 @@ export { analyzeImpact, analyzeImpactWithConfig, } from "./analysis/analyze-impact"; +export { + estimateDampedModesPronyResearch, + type PronyResearchResultV1, + type ResearchDampedModeEstimateV1, +} from "./analysis/prony-research"; export { DEFAULT_DECAY_FIT_CONFIG, type DecayEstimate, diff --git a/packages/dsp/test/prony-research.test.ts b/packages/dsp/test/prony-research.test.ts new file mode 100644 index 00000000..768e1015 --- /dev/null +++ b/packages/dsp/test/prony-research.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { estimateDampedModesPronyResearch } from "../src"; + +function synthesize(sampleRate: number, durationSeconds: number, modes: readonly { f: number; tau: number; amplitude: number; phase: number }[]): Float64Array { + const samples = new Float64Array(Math.round(sampleRate * durationSeconds)); + for (let index = 0; index < samples.length; index += 1) { + const t = index / sampleRate; + let value = 0; + for (const mode of modes) value += mode.amplitude * Math.exp(-t / mode.tau) * Math.sin(2 * Math.PI * mode.f * t + mode.phase); + samples[index] = value; + } + return samples; +} + +describe("research-only Prony damped-mode estimator", () => { + it("separates two close damped modes in a deterministic digital twin", () => { + const sampleRate = 8_000; + const truth = [ + { f: 440, tau: 1.0, amplitude: 0.8, phase: 0.2 }, + { f: 448, tau: 0.72, amplitude: 0.55, phase: 1.1 }, + ]; + const samples = synthesize(sampleRate, 1.5, truth); + const result = estimateDampedModesPronyResearch(samples, sampleRate, 2); + expect(result.evidenceEligible).toBe(false); + expect(result.modes).toHaveLength(2); + expect(result.modes[0]!.frequencyHz).toBeCloseTo(440, 0); + expect(result.modes[1]!.frequencyHz).toBeCloseTo(448, 0); + expect(result.modes[0]!.decaySeconds).toBeGreaterThan(0.5); + expect(result.modes[1]!.decaySeconds).toBeGreaterThan(0.4); + expect(result.residualRms).toBeLessThan(1e-3); + }); + + it("never stamps an AcousticFingerprint algorithm version", () => { + const samples = synthesize(8_000, 0.5, [{ f: 700, tau: 0.3, amplitude: 1, phase: 0 }]); + const result = estimateDampedModesPronyResearch(samples, 8_000, 1); + expect(result.researchEstimatorVersion).toBe("prony-damped-modes-research-1"); + expect(result).not.toHaveProperty("algorithmVersion"); + expect(result.evidenceEligible).toBe(false); + }); +}); diff --git a/packages/fingerprint/src/benchmark-corpus.ts b/packages/fingerprint/src/benchmark-corpus.ts new file mode 100644 index 00000000..c1bf3cf3 --- /dev/null +++ b/packages/fingerprint/src/benchmark-corpus.ts @@ -0,0 +1,163 @@ +import type { AcousticFingerprintV1 } from "@everything-rings/dsp"; +import { selectHardNegativePairs, specimenDisjointSplit, type BenchmarkSplit, type NuisanceMetadataV1 } from "./nuisance"; + +export interface TwinBenchmarkObservationV1 { + readonly observationId: string; + readonly specimenId: string; + readonly objectFamily: string; + readonly source: "digital-twin" | "physical" | "external-dataset"; + readonly fingerprint: AcousticFingerprintV1; + readonly nuisance: NuisanceMetadataV1; +} + +export interface TwinBenchmarkCorpusV1 { + readonly schemaVersion: 1; + readonly corpusContractVersion: "sonic-twin-benchmark-corpus-1"; + readonly corpusId: string; + readonly createdAt: string; + readonly observations: readonly TwinBenchmarkObservationV1[]; +} + +export interface TwinBenchmarkCorpusValidationV1 { + readonly valid: boolean; + readonly observationCount: number; + readonly specimenCount: number; + readonly objectFamilyCount: number; + readonly sourceCounts: Readonly>; + readonly splitSpecimenCounts: Readonly>; + readonly reasons: readonly string[]; +} + +function normalized(value: string): string { + return value.trim().toLocaleLowerCase("en-US"); +} + +export function validateTwinBenchmarkCorpus(corpus: TwinBenchmarkCorpusV1): TwinBenchmarkCorpusValidationV1 { + const reasons: string[] = []; + if (corpus.schemaVersion !== 1) reasons.push("schemaVersion must be 1"); + if (corpus.corpusContractVersion !== "sonic-twin-benchmark-corpus-1") reasons.push("corpus contract version mismatch"); + if (corpus.corpusId.trim().length === 0) reasons.push("corpusId is required"); + if (!Number.isFinite(Date.parse(corpus.createdAt))) reasons.push("createdAt is invalid"); + if (corpus.observations.length === 0) reasons.push("corpus must contain observations"); + + const observationIds = new Set(); + const specimenFamily = new Map(); + const specimenSplit = new Map(); + const sourceCounts: Record = { + "digital-twin": 0, + physical: 0, + "external-dataset": 0, + }; + + for (const observation of corpus.observations) { + const observationId = observation.observationId.trim(); + const specimenId = normalized(observation.specimenId); + const family = normalized(observation.objectFamily); + if (observationId.length === 0) reasons.push("observationId is required"); + if (observationIds.has(observationId)) reasons.push(`duplicate observationId ${observationId}`); + observationIds.add(observationId); + if (specimenId.length === 0) reasons.push(`observation ${observationId} has empty specimenId`); + if (family.length === 0) reasons.push(`observation ${observationId} has empty objectFamily`); + const priorFamily = specimenFamily.get(specimenId); + if (priorFamily !== undefined && priorFamily !== family) reasons.push(`specimen ${observation.specimenId} has conflicting objectFamily labels`); + else specimenFamily.set(specimenId, family); + const split = specimenDisjointSplit(specimenId); + const priorSplit = specimenSplit.get(specimenId); + if (priorSplit !== undefined && priorSplit !== split) reasons.push(`specimen ${observation.specimenId} leaks across benchmark splits`); + specimenSplit.set(specimenId, split); + sourceCounts[observation.source] += 1; + if (observation.fingerprint.modes.length === 0) reasons.push(`observation ${observationId} has no fingerprint modes`); + } + + const splitSpecimenCounts: Record = { train: 0, validation: 0, test: 0 }; + for (const split of specimenSplit.values()) splitSpecimenCounts[split] += 1; + return { + valid: reasons.length === 0, + observationCount: corpus.observations.length, + specimenCount: specimenFamily.size, + objectFamilyCount: new Set(specimenFamily.values()).size, + sourceCounts, + splitSpecimenCounts, + reasons: [...new Set(reasons)], + }; +} + +export interface TwinVerificationPairV1 { + readonly pairId: string; + readonly samePhysicalSpecimen: boolean; + readonly leftObservationId: string; + readonly rightObservationId: string; + readonly leftSpecimenId: string; + readonly rightSpecimenId: string; + readonly split: BenchmarkSplit; + readonly reference: AcousticFingerprintV1; + readonly candidate: AcousticFingerprintV1; + readonly pairKind: "same-specimen" | "hard-negative"; +} + +function pairId(left: TwinBenchmarkObservationV1, right: TwinBenchmarkObservationV1, kind: string): string { + return `${kind}:${left.observationId}::${right.observationId}`; +} + +export function buildTwinVerificationPairs( + corpus: TwinBenchmarkCorpusV1, + hardNegativeLimitPerSplit = 200, +): readonly TwinVerificationPairV1[] { + const validation = validateTwinBenchmarkCorpus(corpus); + if (!validation.valid) throw new Error(`invalid benchmark corpus: ${validation.reasons.join("; ")}`); + if (!Number.isInteger(hardNegativeLimitPerSplit) || hardNegativeLimitPerSplit <= 0) { + throw new Error("hardNegativeLimitPerSplit must be a positive integer"); + } + const pairs: TwinVerificationPairV1[] = []; + for (const split of ["train", "validation", "test"] as const) { + const observations = corpus.observations.filter((observation) => specimenDisjointSplit(observation.specimenId) === split); + const bySpecimen = new Map(); + 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]!; + pairs.push({ + pairId: pairId(left, right, "same"), + samePhysicalSpecimen: true, + leftObservationId: left.observationId, + rightObservationId: right.observationId, + leftSpecimenId: left.specimenId, + rightSpecimenId: right.specimenId, + split, + reference: left.fingerprint, + candidate: right.fingerprint, + pairKind: "same-specimen", + }); + } + } + } + + 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)); + 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, + 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)); +} diff --git a/packages/fingerprint/src/calibration.ts b/packages/fingerprint/src/calibration.ts new file mode 100644 index 00000000..7fd58f3c --- /dev/null +++ b/packages/fingerprint/src/calibration.ts @@ -0,0 +1,295 @@ +export interface LabeledSimilarityScoreV1 { + readonly pairId: string; + readonly score: number; + readonly samePhysicalSpecimen: boolean; +} + +export interface IsotonicCalibrationBinV1 { + readonly minimumScore: number; + readonly maximumScore: number; + readonly sampleCount: number; + readonly positiveCount: number; + readonly probability: number; +} + +export interface SonicTwinCalibrationModelV1 { + readonly schemaVersion: 1; + readonly calibrationVersion: "sonic-twin-isotonic-calibration-1"; + readonly trainingPopulation: string; + readonly labelSemantics: "same-physical-specimen"; + readonly trainingSampleCount: number; + readonly trainingPositiveCount: number; + readonly bins: readonly IsotonicCalibrationBinV1[]; +} + +interface MutableBlock { + minimumScore: number; + maximumScore: number; + sampleCount: number; + positiveCount: number; +} + +function assertScore(score: number): void { + if (!Number.isFinite(score)) throw new Error("similarity score must be finite"); +} + +function blockProbability(block: MutableBlock): number { + return block.positiveCount / block.sampleCount; +} + +function assertBinarySupport(samples: readonly LabeledSimilarityScoreV1[], label: string): void { + if (!samples.some((sample) => sample.samePhysicalSpecimen)) throw new Error(`${label} requires at least one positive example`); + if (!samples.some((sample) => !sample.samePhysicalSpecimen)) throw new Error(`${label} requires at least one negative example`); +} + +export function fitIsotonicCalibration( + samples: readonly LabeledSimilarityScoreV1[], + trainingPopulation: string, +): SonicTwinCalibrationModelV1 { + if (samples.length === 0) throw new Error("calibration requires labeled samples"); + if (trainingPopulation.trim().length === 0) throw new Error("calibration trainingPopulation is required"); + const seen = new Set(); + for (const sample of samples) { + assertScore(sample.score); + if (sample.pairId.trim().length === 0) throw new Error("calibration pairId is required"); + if (seen.has(sample.pairId)) throw new Error(`duplicate calibration pairId ${sample.pairId}`); + seen.add(sample.pairId); + } + + const ordered = [...samples].sort((left, right) => left.score - right.score || left.pairId.localeCompare(right.pairId)); + const blocks: MutableBlock[] = ordered.map((sample) => ({ + minimumScore: sample.score, + maximumScore: sample.score, + sampleCount: 1, + positiveCount: sample.samePhysicalSpecimen ? 1 : 0, + })); + + let index = 0; + while (index < blocks.length - 1) { + if (blockProbability(blocks[index]!) <= blockProbability(blocks[index + 1]!)) { + index += 1; + continue; + } + const left = blocks[index]!; + const right = blocks[index + 1]!; + blocks.splice(index, 2, { + minimumScore: left.minimumScore, + maximumScore: right.maximumScore, + sampleCount: left.sampleCount + right.sampleCount, + positiveCount: left.positiveCount + right.positiveCount, + }); + if (index > 0) index -= 1; + } + + return { + schemaVersion: 1, + calibrationVersion: "sonic-twin-isotonic-calibration-1", + trainingPopulation: trainingPopulation.trim(), + labelSemantics: "same-physical-specimen", + trainingSampleCount: samples.length, + trainingPositiveCount: samples.filter((sample) => sample.samePhysicalSpecimen).length, + bins: blocks.map((block) => ({ ...block, probability: blockProbability(block) })), + }; +} + +export function predictCalibratedProbability( + model: SonicTwinCalibrationModelV1, + score: number, +): number { + assertScore(score); + if (model.bins.length === 0) throw new Error("calibration model has no bins"); + if (score <= model.bins[0]!.maximumScore) return model.bins[0]!.probability; + for (let index = 1; index < model.bins.length; index += 1) { + const previous = model.bins[index - 1]!; + const current = model.bins[index]!; + if (score <= current.maximumScore) { + if (score <= current.minimumScore) { + const span = current.minimumScore - previous.maximumScore; + if (!(span > 0)) return current.probability; + const t = (score - previous.maximumScore) / span; + return previous.probability + Math.max(0, Math.min(1, t)) * (current.probability - previous.probability); + } + return current.probability; + } + } + return model.bins[model.bins.length - 1]!.probability; +} + +export interface CalibratedPredictionV1 { + readonly pairId: string; + readonly probability: number; + readonly samePhysicalSpecimen: boolean; +} + +export interface CalibrationMetricsV1 { + readonly sampleCount: number; + readonly brierScore: number | null; + readonly expectedCalibrationError: number | null; + readonly rocAuc: number | null; +} + +export function calibratedPredictions( + model: SonicTwinCalibrationModelV1, + samples: readonly LabeledSimilarityScoreV1[], +): readonly CalibratedPredictionV1[] { + return samples.map((sample) => ({ + pairId: sample.pairId, + probability: predictCalibratedProbability(model, sample.score), + samePhysicalSpecimen: sample.samePhysicalSpecimen, + })); +} + +function rocAuc(predictions: readonly CalibratedPredictionV1[]): number | null { + const positives = predictions.filter((prediction) => prediction.samePhysicalSpecimen); + const negatives = predictions.filter((prediction) => !prediction.samePhysicalSpecimen); + if (positives.length === 0 || negatives.length === 0) return null; + let wins = 0; + for (const positive of positives) { + for (const negative of negatives) { + if (positive.probability > negative.probability) wins += 1; + else if (positive.probability === negative.probability) wins += 0.5; + } + } + return wins / (positives.length * negatives.length); +} + +export function evaluateCalibration( + predictions: readonly CalibratedPredictionV1[], + binCount = 10, +): CalibrationMetricsV1 { + if (!Number.isInteger(binCount) || binCount <= 0) throw new Error("calibration binCount must be a positive integer"); + if (predictions.length === 0) return { sampleCount: 0, brierScore: null, expectedCalibrationError: null, rocAuc: null }; + let squared = 0; + for (const prediction of predictions) { + if (!(prediction.probability >= 0 && prediction.probability <= 1) || !Number.isFinite(prediction.probability)) { + throw new Error("calibrated probability must be finite and in [0,1]"); + } + const label = prediction.samePhysicalSpecimen ? 1 : 0; + squared += (prediction.probability - label) ** 2; + } + + let ece = 0; + for (let bin = 0; bin < binCount; bin += 1) { + const lower = bin / binCount; + const upper = (bin + 1) / binCount; + const members = predictions.filter((prediction) => prediction.probability >= lower && (bin === binCount - 1 ? prediction.probability <= upper : prediction.probability < upper)); + if (members.length === 0) continue; + const meanProbability = members.reduce((sum, member) => sum + member.probability, 0) / members.length; + const positiveRate = members.filter((member) => member.samePhysicalSpecimen).length / members.length; + ece += members.length / predictions.length * Math.abs(meanProbability - positiveRate); + } + + return { + sampleCount: predictions.length, + brierScore: squared / predictions.length, + expectedCalibrationError: ece, + rocAuc: rocAuc(predictions), + }; +} + +export interface RiskCoveragePointV1 { + readonly minimumConfidence: number; + readonly coverage: number; + readonly errorRate: number | null; + readonly coveredCount: number; +} + +export function riskCoverageCurve( + predictions: readonly CalibratedPredictionV1[], + thresholds: readonly number[] = [0, 0.1, 0.2, 0.3, 0.4, 0.49], +): readonly RiskCoveragePointV1[] { + return thresholds.map((minimumConfidence) => { + if (!(minimumConfidence >= 0 && minimumConfidence <= 0.5) || !Number.isFinite(minimumConfidence)) { + throw new Error("minimumConfidence must be finite and in [0,0.5]"); + } + const covered = predictions.filter((prediction) => Math.abs(prediction.probability - 0.5) >= minimumConfidence); + const errors = covered.filter((prediction) => (prediction.probability >= 0.5) !== prediction.samePhysicalSpecimen).length; + return { + minimumConfidence, + coverage: predictions.length === 0 ? 0 : covered.length / predictions.length, + errorRate: covered.length === 0 ? null : errors / covered.length, + coveredCount: covered.length, + }; + }); +} + +export type CalibrationPartition = "calibration" | "evaluation"; + +export interface PartitionedSimilarityScoreV1 extends LabeledSimilarityScoreV1 { + readonly groupId: string; + readonly partition: CalibrationPartition; +} + +export interface HeldOutCalibrationEvaluationV1 { + readonly schemaVersion: 1; + readonly evaluationVersion: "sonic-twin-held-out-calibration-1"; + readonly calibrationPopulation: string; + readonly evaluationPopulation: string; + readonly calibrationGroups: readonly string[]; + readonly evaluationGroups: readonly string[]; + readonly calibrationSampleCount: number; + readonly evaluationSampleCount: number; + readonly model: SonicTwinCalibrationModelV1; + readonly predictions: readonly CalibratedPredictionV1[]; + readonly metrics: CalibrationMetricsV1; + readonly riskCoverage: readonly RiskCoveragePointV1[]; +} + +/** + * Fits probability calibration only on the calibration partition and reports + * metrics only on group-disjoint evaluation samples. A group is normally a + * physical specimen or held-out query identity; it may not cross partitions. + */ +export function fitAndEvaluateHeldOutCalibration( + samples: readonly PartitionedSimilarityScoreV1[], + calibrationPopulation: string, + evaluationPopulation: string, + binCount = 10, + riskThresholds: readonly number[] = [0, 0.1, 0.2, 0.3, 0.4, 0.49], +): HeldOutCalibrationEvaluationV1 { + if (calibrationPopulation.trim().length === 0 || evaluationPopulation.trim().length === 0) { + throw new Error("held-out calibration requires named calibration and evaluation populations"); + } + const seenPairIds = new Set(); + const calibrationGroups = new Set(); + const evaluationGroups = new Set(); + for (const sample of samples) { + assertScore(sample.score); + if (sample.pairId.trim().length === 0 || sample.groupId.trim().length === 0) throw new Error("held-out calibration requires pairId and groupId"); + if (seenPairIds.has(sample.pairId)) throw new Error(`duplicate held-out calibration pairId ${sample.pairId}`); + seenPairIds.add(sample.pairId); + const group = sample.groupId.trim().toLocaleLowerCase("en-US"); + (sample.partition === "calibration" ? calibrationGroups : evaluationGroups).add(group); + } + for (const group of calibrationGroups) { + if (evaluationGroups.has(group)) throw new Error(`calibration leakage: group ${group} appears in both partitions`); + } + + const calibrationSamples = samples.filter((sample) => sample.partition === "calibration"); + const evaluationSamples = samples.filter((sample) => sample.partition === "evaluation"); + if (calibrationSamples.length === 0 || evaluationSamples.length === 0) throw new Error("held-out calibration requires non-empty calibration and evaluation partitions"); + assertBinarySupport(calibrationSamples, "calibration partition"); + assertBinarySupport(evaluationSamples, "evaluation partition"); + + const strip = (sample: PartitionedSimilarityScoreV1): LabeledSimilarityScoreV1 => ({ + pairId: sample.pairId, + score: sample.score, + samePhysicalSpecimen: sample.samePhysicalSpecimen, + }); + const model = fitIsotonicCalibration(calibrationSamples.map(strip), calibrationPopulation); + const predictions = calibratedPredictions(model, evaluationSamples.map(strip)); + return { + schemaVersion: 1, + evaluationVersion: "sonic-twin-held-out-calibration-1", + calibrationPopulation: calibrationPopulation.trim(), + evaluationPopulation: evaluationPopulation.trim(), + calibrationGroups: [...calibrationGroups].sort(), + evaluationGroups: [...evaluationGroups].sort(), + calibrationSampleCount: calibrationSamples.length, + evaluationSampleCount: evaluationSamples.length, + model, + predictions, + metrics: evaluateCalibration(predictions, binCount), + riskCoverage: riskCoverageCurve(predictions, riskThresholds), + }; +} diff --git a/packages/fingerprint/src/index.ts b/packages/fingerprint/src/index.ts index 9655c16d..6eccefc6 100644 --- a/packages/fingerprint/src/index.ts +++ b/packages/fingerprint/src/index.ts @@ -6,3 +6,100 @@ export { type ModeMatch, type ModeRecurrenceConfig, } from "./recurrence"; +export { + DEFAULT_SONIC_TWIN_BASELINE_CONFIG, + benchmarkSonicTwinBaseline, + compareSonicTwinBaseline, + type LabeledTwinPair, + type SonicTwinBaselineComparison, + type SonicTwinBaselineConfig, + type SonicTwinBenchmarkMetrics, + type SonicTwinDecision, +} from "./sonic-twin"; +export { + characterizeNuisance, + selectHardNegativePairs, + specimenDisjointSplit, + type BenchmarkSplit, + type HardNegativePairV1, + type LabeledFingerprintObservationV1, + type NuisanceCharacterizationV1, + type NuisanceFactor, + type NuisanceFactorSummaryV1, + type NuisanceMetadataV1, + type WithinSpecimenPairMetricV1, +} from "./nuisance"; +export { + DEFAULT_ACOUSTIC_OBJECT_MODEL_CONFIG, + buildAcousticObjectModel, + compareFingerprintToObjectModel, + type AcousticObjectModeEstimateV1, + type AcousticObjectModelConfigV1, + type AcousticObjectModelV1, + type AcousticObjectObservationV1, + type FingerprintToObjectModelComparisonV1, +} from "./object-model"; +export { + buildSpatialModalSoundField, + fingerprintAtSpatialPoint, + type SpatialFingerprintObservationV1, + type SpatialModalFieldModeV1, + type SpatialModalFieldQueryV1, + type SpatialModalSampleV1, + type SpatialModalSoundFieldV1, + type SpatialPointV1, + type SpatialPredictedFingerprintV1, +} from "./modal-field"; +export { + benchmarkSonicTwinRetrieval, + buildSonicTwinIndex, + retrieveSonicTwin, + type SonicTwinIndexEntryV1, + type SonicTwinIndexV1, + type SonicTwinRetrievalMetricsV1, + type SonicTwinRetrievalQueryV1, + type SonicTwinRetrievalResultV1, +} from "./retrieval"; +export { + buildTwinVerificationPairs, + validateTwinBenchmarkCorpus, + type TwinBenchmarkCorpusV1, + type TwinBenchmarkCorpusValidationV1, + type TwinBenchmarkObservationV1, + type TwinVerificationPairV1, +} from "./benchmark-corpus"; +export { + calibratedPredictions, + evaluateCalibration, + fitAndEvaluateHeldOutCalibration, + fitIsotonicCalibration, + predictCalibratedProbability, + riskCoverageCurve, + type CalibratedPredictionV1, + type CalibrationMetricsV1, + type CalibrationPartition, + type HeldOutCalibrationEvaluationV1, + type IsotonicCalibrationBinV1, + type LabeledSimilarityScoreV1, + type PartitionedSimilarityScoreV1, + type RiskCoveragePointV1, + type SonicTwinCalibrationModelV1, +} from "./calibration"; +export { + benchmarkMaterialResearch, + fitMaterialCentroidResearch, + predictMaterialResearch, + type MaterialBenchmarkExampleV1, + type MaterialBenchmarkMetricsV1, + type MaterialCentroidV1, + type MaterialInferenceModelV1, + type MaterialPredictionV1, + type MaterialTrainingExampleV1, +} from "./material-research"; +export { + DEFAULT_SIMILARITY_PROMOTION_POLICY, + evaluateSimilarityAlgorithmPromotion, + type SimilarityAlgorithmHeldOutMetricsV1, + type SimilarityPromotionPolicyV1, + type SimilarityPromotionVerdictV1, +} from "./promotion"; diff --git a/packages/fingerprint/src/material-research.ts b/packages/fingerprint/src/material-research.ts new file mode 100644 index 00000000..e285b5c9 --- /dev/null +++ b/packages/fingerprint/src/material-research.ts @@ -0,0 +1,166 @@ +import type { AcousticObjectModelV1 } from "./object-model"; + +export interface MaterialTrainingExampleV1 { + readonly exampleId: string; + readonly materialLabel: string; + readonly model: AcousticObjectModelV1; +} + +export interface MaterialCentroidV1 { + readonly materialLabel: string; + readonly exampleCount: number; + readonly featureCentroid: readonly number[]; +} + +export interface MaterialInferenceModelV1 { + readonly schemaVersion: 1; + readonly materialInferenceVersion: "material-centroid-research-1"; + readonly evidenceEligible: false; + readonly trainingPopulation: string; + readonly featureVersion: "modal-summary-features-1"; + readonly featureDimension: number; + readonly centroids: readonly MaterialCentroidV1[]; +} + +const MAX_MODES = 8; + +function modelFeatures(model: AcousticObjectModelV1): number[] { + const modes = [...model.modes].sort((left, right) => left.frequencyHzMedian - right.frequencyHzMedian).slice(0, MAX_MODES); + const anchor = modes[0]?.frequencyHzMedian ?? 1; + const features: number[] = [Math.min(1, modes.length / MAX_MODES)]; + for (let index = 0; index < MAX_MODES; index += 1) { + const mode = modes[index]; + if (mode === undefined) { + features.push(0, 0, 0, 0); + continue; + } + features.push( + Math.log2(Math.max(mode.frequencyHzMedian / anchor, 1e-9)) / 8, + Math.log(Math.max(mode.decaySecondsMedian, 1e-9)) / 8, + Math.log10(Math.max(mode.relativeAmplitudeMedian, 1e-9)) / 4, + mode.observationSupportFraction, + ); + } + return features; +} + +function distance(left: readonly number[], right: readonly number[]): number { + if (left.length !== right.length) throw new Error("material feature dimensions differ"); + let squared = 0; + for (let index = 0; index < left.length; index += 1) squared += (left[index]! - right[index]!) ** 2; + return Math.sqrt(squared / Math.max(1, left.length)); +} + +export function fitMaterialCentroidResearch( + examples: readonly MaterialTrainingExampleV1[], + trainingPopulation: string, +): MaterialInferenceModelV1 { + if (examples.length === 0) throw new Error("material research model requires examples"); + if (trainingPopulation.trim().length === 0) throw new Error("material trainingPopulation is required"); + const seen = new Set(); + const grouped = new Map(); + for (const example of examples) { + if (example.exampleId.trim().length === 0) throw new Error("material exampleId is required"); + if (seen.has(example.exampleId)) throw new Error(`duplicate material exampleId ${example.exampleId}`); + seen.add(example.exampleId); + const label = example.materialLabel.trim(); + if (label.length === 0) throw new Error("materialLabel is required"); + const group = grouped.get(label) ?? []; + group.push(modelFeatures(example.model)); + grouped.set(label, group); + } + if (grouped.size < 2) throw new Error("material research model requires at least two material labels"); + const featureDimension = modelFeatures(examples[0]!.model).length; + const centroids = [...grouped.entries()].map(([materialLabel, features]) => ({ + materialLabel, + exampleCount: features.length, + featureCentroid: Array.from({ length: featureDimension }, (_, index) => features.reduce((sum, row) => sum + row[index]!, 0) / features.length), + })).sort((left, right) => left.materialLabel.localeCompare(right.materialLabel)); + return { + schemaVersion: 1, + materialInferenceVersion: "material-centroid-research-1", + evidenceEligible: false, + trainingPopulation: trainingPopulation.trim(), + featureVersion: "modal-summary-features-1", + featureDimension, + centroids, + }; +} + +export interface MaterialPredictionV1 { + readonly predictionVersion: "material-centroid-prediction-1"; + readonly decision: "label" | "abstain"; + readonly materialLabel: string | null; + readonly nearestDistance: number; + readonly secondDistance: number | null; + readonly distanceMargin: number | null; + readonly trainingPopulation: string; + readonly calibratedProbability: null; +} + +export function predictMaterialResearch( + model: MaterialInferenceModelV1, + objectModel: AcousticObjectModelV1, + minimumDistanceMargin = 0.03, +): MaterialPredictionV1 { + if (!(minimumDistanceMargin >= 0) || !Number.isFinite(minimumDistanceMargin)) throw new Error("minimumDistanceMargin must be finite and non-negative"); + const features = modelFeatures(objectModel); + const ranked = model.centroids + .map((centroid) => ({ materialLabel: centroid.materialLabel, distance: distance(features, centroid.featureCentroid) })) + .sort((left, right) => left.distance - right.distance || left.materialLabel.localeCompare(right.materialLabel)); + const nearest = ranked[0]; + if (nearest === undefined) throw new Error("material model has no centroids"); + const second = ranked[1]; + const margin = second === undefined ? null : second.distance - nearest.distance; + const decision = margin !== null && margin >= minimumDistanceMargin ? "label" as const : "abstain" as const; + return { + predictionVersion: "material-centroid-prediction-1", + decision, + materialLabel: decision === "label" ? nearest.materialLabel : null, + nearestDistance: nearest.distance, + secondDistance: second?.distance ?? null, + distanceMargin: margin, + trainingPopulation: model.trainingPopulation, + calibratedProbability: null, + }; +} + +export interface MaterialBenchmarkExampleV1 { + readonly exampleId: string; + readonly trueMaterialLabel: string; + readonly model: AcousticObjectModelV1; +} + +export interface MaterialBenchmarkMetricsV1 { + readonly exampleCount: number; + readonly coveredCount: number; + readonly abstainedCount: number; + readonly coverage: number; + readonly coveredAccuracy: number | null; + readonly errors: readonly { readonly exampleId: string; readonly expected: string; readonly predicted: string }[]; +} + +export function benchmarkMaterialResearch( + model: MaterialInferenceModelV1, + examples: readonly MaterialBenchmarkExampleV1[], + minimumDistanceMargin = 0.03, +): MaterialBenchmarkMetricsV1 { + const errors: Array<{ exampleId: string; expected: string; predicted: string }> = []; + let covered = 0; + let correct = 0; + for (const example of examples) { + const prediction = predictMaterialResearch(model, example.model, minimumDistanceMargin); + if (prediction.decision === "abstain" || prediction.materialLabel === null) continue; + covered += 1; + if (prediction.materialLabel === example.trueMaterialLabel) correct += 1; + else errors.push({ exampleId: example.exampleId, expected: example.trueMaterialLabel, predicted: prediction.materialLabel }); + } + return { + exampleCount: examples.length, + coveredCount: covered, + abstainedCount: examples.length - covered, + coverage: examples.length === 0 ? 0 : covered / examples.length, + coveredAccuracy: covered === 0 ? null : correct / covered, + errors, + }; +} diff --git a/packages/fingerprint/src/modal-field.ts b/packages/fingerprint/src/modal-field.ts new file mode 100644 index 00000000..5534599c --- /dev/null +++ b/packages/fingerprint/src/modal-field.ts @@ -0,0 +1,182 @@ +import type { AcousticFingerprintV1, AcousticMode, AcousticFingerprintAlgorithmVersion } from "@everything-rings/dsp"; +import { centsDistance } from "./recurrence"; +import { buildAcousticObjectModel, type AcousticObjectModelV1 } from "./object-model"; + +export interface SpatialPointV1 { + readonly x: number; + readonly y: number; + readonly z: number; +} + +export interface SpatialFingerprintObservationV1 { + readonly observationId: string; + readonly specimenId: string; + readonly strikePoint: SpatialPointV1; + readonly fingerprint: AcousticFingerprintV1; +} + +export interface SpatialModalSampleV1 { + readonly observationId: string; + readonly strikePoint: SpatialPointV1; + readonly relativeAmplitude: number; + readonly sourceFrequencyHz: number; +} + +export interface SpatialModalFieldModeV1 { + readonly frequencyHz: number; + readonly decaySeconds: number; + readonly samples: readonly SpatialModalSampleV1[]; +} + +export interface SpatialModalSoundFieldV1 { + readonly schemaVersion: 1; + readonly modalFieldVersion: "spatial-modal-sound-field-1"; + readonly specimenId: string; + readonly objectModel: AcousticObjectModelV1; + readonly modes: readonly SpatialModalFieldModeV1[]; + readonly observationCount: number; +} + +function assertPoint(point: SpatialPointV1): void { + if (![point.x, point.y, point.z].every(Number.isFinite)) throw new Error("spatial strike point coordinates must be finite"); +} + +export function buildSpatialModalSoundField( + observations: readonly SpatialFingerprintObservationV1[], + maximumModeAssociationCents = 120, +): SpatialModalSoundFieldV1 { + if (observations.length < 2) throw new Error("spatial modal field requires at least two observations"); + if (!(maximumModeAssociationCents > 0) || !Number.isFinite(maximumModeAssociationCents)) throw new Error("maximumModeAssociationCents must be finite and positive"); + const specimenId = observations[0]!.specimenId.trim(); + if (specimenId.length === 0) throw new Error("spatial modal field specimenId is required"); + const normalized = specimenId.toLocaleLowerCase("en-US"); + const observationIds = new Set(); + for (const observation of observations) { + if (observation.specimenId.trim().toLocaleLowerCase("en-US") !== normalized) throw new Error("spatial modal field cannot mix specimen IDs"); + if (observationIds.has(observation.observationId)) throw new Error(`duplicate spatial observationId ${observation.observationId}`); + observationIds.add(observation.observationId); + assertPoint(observation.strikePoint); + } + const objectModel = buildAcousticObjectModel(observations.map((observation) => ({ + observationId: observation.observationId, + specimenId: observation.specimenId, + fingerprint: observation.fingerprint, + }))); + + const modes = objectModel.modes.map((modelMode): SpatialModalFieldModeV1 => { + const samples: SpatialModalSampleV1[] = []; + for (const observation of observations) { + let best: { frequencyHz: number; relativeAmplitude: number; distance: number } | undefined; + for (const mode of observation.fingerprint.modes) { + const distance = centsDistance(modelMode.frequencyHzMedian, mode.frequencyHz); + if (distance <= maximumModeAssociationCents && (best === undefined || distance < best.distance)) { + best = { frequencyHz: mode.frequencyHz, relativeAmplitude: mode.relativeAmplitude, distance }; + } + } + if (best !== undefined) { + samples.push({ + observationId: observation.observationId, + strikePoint: observation.strikePoint, + relativeAmplitude: best.relativeAmplitude, + sourceFrequencyHz: best.frequencyHz, + }); + } + } + return { frequencyHz: modelMode.frequencyHzMedian, decaySeconds: modelMode.decaySecondsMedian, samples }; + }); + + return { + schemaVersion: 1, + modalFieldVersion: "spatial-modal-sound-field-1", + specimenId, + objectModel, + modes, + observationCount: observations.length, + }; +} + +function distance(left: SpatialPointV1, right: SpatialPointV1): number { + return Math.hypot(left.x - right.x, left.y - right.y, left.z - right.z); +} + +function interpolateAmplitude(samples: readonly SpatialModalSampleV1[], point: SpatialPointV1, power: number): number { + if (samples.length === 0) return 0; + for (const sample of samples) if (distance(sample.strikePoint, point) <= 1e-9) return sample.relativeAmplitude; + let weighted = 0; + let weightSum = 0; + for (const sample of samples) { + const d = Math.max(1e-9, distance(sample.strikePoint, point)); + const weight = 1 / d ** power; + weighted += weight * sample.relativeAmplitude; + weightSum += weight; + } + return weightSum === 0 ? 0 : weighted / weightSum; +} + +export interface SpatialModalFieldQueryV1 { + readonly point: SpatialPointV1; + readonly interpolationPower?: number; + readonly sampleRate?: number; + readonly durationSeconds?: number; +} + +/** + * A spatial-field prediction is a derived model output, not a fresh acoustic + * measurement. It deliberately cannot be mistaken for an evidence-eligible + * AcousticFingerprintV1 because it carries no canonical algorithmVersion. + */ +export interface SpatialPredictedFingerprintV1 { + readonly schemaVersion: 1; + readonly predictionContractVersion: "spatial-predicted-fingerprint-1"; + readonly evidenceEligible: false; + readonly sourceFingerprintAlgorithmVersions: readonly AcousticFingerprintAlgorithmVersion[]; + readonly specimenId: string; + readonly sampleRate: number; + readonly durationSeconds: number; + readonly strikePoint: SpatialPointV1; + readonly modes: readonly AcousticMode[]; +} + +export function fingerprintAtSpatialPoint( + field: SpatialModalSoundFieldV1, + query: SpatialModalFieldQueryV1, +): SpatialPredictedFingerprintV1 { + assertPoint(query.point); + const interpolationPower = query.interpolationPower ?? 2; + if (!(interpolationPower > 0) || !Number.isFinite(interpolationPower)) throw new Error("interpolationPower must be finite and positive"); + const amplitudes = field.modes.map((mode) => interpolateAmplitude(mode.samples, query.point, interpolationPower)); + const maximumAmplitude = Math.max(0, ...amplitudes); + const sampleRate = query.sampleRate ?? field.objectModel.sampleRates[0] ?? 48_000; + const durationSeconds = query.durationSeconds ?? 2; + if (!(sampleRate > 0) || !Number.isFinite(sampleRate)) throw new Error("spatial predicted sampleRate must be finite and positive"); + if (!(durationSeconds > 0) || !Number.isFinite(durationSeconds)) throw new Error("spatial predicted durationSeconds must be finite and positive"); + return { + schemaVersion: 1, + predictionContractVersion: "spatial-predicted-fingerprint-1", + evidenceEligible: false, + sourceFingerprintAlgorithmVersions: [...field.objectModel.fingerprintAlgorithmVersions], + specimenId: field.specimenId, + sampleRate, + durationSeconds, + strikePoint: { ...query.point }, + modes: field.modes.flatMap((mode, index): AcousticMode[] => { + const amplitude = amplitudes[index] ?? 0; + if (!(amplitude > 0) || !(maximumAmplitude > 0)) return []; + const relativeAmplitude = amplitude / maximumAmplitude; + return [{ + frequencyHz: mode.frequencyHz, + relativeAmplitude, + decaySeconds: mode.decaySeconds, + q: Math.PI * mode.frequencyHz * mode.decaySeconds, + confidence: Math.min(1, mode.samples.length / Math.max(1, field.observationCount)), + diagnostics: { + prominenceDb: 0, + persistenceSeconds: mode.decaySeconds, + frequencyStdCents: field.objectModel.modes[index]?.frequencyMadCents ?? 0, + decayFitScore: 0, + observationCount: mode.samples.length, + }, + }]; + }), + }; +} diff --git a/packages/fingerprint/src/nuisance.ts b/packages/fingerprint/src/nuisance.ts new file mode 100644 index 00000000..7391105e --- /dev/null +++ b/packages/fingerprint/src/nuisance.ts @@ -0,0 +1,187 @@ +import type { AcousticFingerprintV1 } from "@everything-rings/dsp"; +import { fingerprintRecurrence } from "./recurrence"; + +export interface NuisanceMetadataV1 { + readonly strikeLocation: string; + readonly striker: string; + readonly support: string; + readonly microphoneDistanceClass: string; + readonly room: string; + readonly stationId: string; + readonly operatorId: string; + readonly dayId: string; +} + +export interface LabeledFingerprintObservationV1 { + readonly observationId: string; + readonly specimenId: string; + readonly fingerprint: AcousticFingerprintV1; + readonly nuisance: NuisanceMetadataV1; +} + +export type NuisanceFactor = keyof NuisanceMetadataV1; + +export interface WithinSpecimenPairMetricV1 { + readonly leftObservationId: string; + readonly rightObservationId: string; + readonly specimenId: string; + readonly differingFactors: readonly NuisanceFactor[]; + readonly matchedModes: number; + readonly medianFrequencyDriftCents: number; + readonly meanFrequencyDriftCents: number; +} + +export interface NuisanceFactorSummaryV1 { + readonly factor: NuisanceFactor; + readonly comparisonCount: number; + readonly medianFrequencyDriftCents: number | null; + readonly p90FrequencyDriftCents: number | null; +} + +export interface NuisanceCharacterizationV1 { + readonly schemaVersion: 1; + readonly characterizationVersion: "nuisance-characterization-1"; + readonly observationCount: number; + readonly specimenCount: number; + readonly withinSpecimenPairs: readonly WithinSpecimenPairMetricV1[]; + readonly factorSummaries: readonly NuisanceFactorSummaryV1[]; +} + +function median(values: readonly number[]): number | null { + if (values.length === 0) return null; + const ordered = [...values].sort((left, right) => left - right); + const middle = Math.floor(ordered.length / 2); + if (ordered.length % 2 === 1) return ordered[middle] ?? null; + return ((ordered[middle - 1] ?? 0) + (ordered[middle] ?? 0)) / 2; +} + +function percentile(values: readonly number[], quantile: number): number | null { + if (values.length === 0) return null; + const ordered = [...values].sort((left, right) => left - right); + const index = Math.min(ordered.length - 1, Math.max(0, Math.ceil(quantile * ordered.length) - 1)); + return ordered[index] ?? null; +} + +const NUISANCE_FACTORS: readonly NuisanceFactor[] = [ + "strikeLocation", + "striker", + "support", + "microphoneDistanceClass", + "room", + "stationId", + "operatorId", + "dayId", +]; + +function differingFactors(left: NuisanceMetadataV1, right: NuisanceMetadataV1): NuisanceFactor[] { + return NUISANCE_FACTORS.filter((factor) => left[factor] !== right[factor]); +} + +export function characterizeNuisance( + observations: readonly LabeledFingerprintObservationV1[], +): NuisanceCharacterizationV1 { + const bySpecimen = new Map(); + for (const observation of observations) { + const key = observation.specimenId.trim().toLocaleLowerCase("en-US"); + 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()) { + 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, + differingFactors: differingFactors(left.nuisance, right.nuisance), + matchedModes: recurrence.matchedCount, + medianFrequencyDriftCents: recurrence.medianCents, + meanFrequencyDriftCents: recurrence.meanCents, + }); + } + } + } + + const factorSummaries = NUISANCE_FACTORS.map((factor) => { + const values = pairs + .filter((pair) => pair.differingFactors.length === 1 && pair.differingFactors[0] === factor) + .map((pair) => pair.medianFrequencyDriftCents) + .filter(Number.isFinite); + return { + factor, + comparisonCount: values.length, + medianFrequencyDriftCents: median(values), + p90FrequencyDriftCents: percentile(values, 0.9), + }; + }); + + return { + schemaVersion: 1, + characterizationVersion: "nuisance-characterization-1", + observationCount: observations.length, + specimenCount: bySpecimen.size, + withinSpecimenPairs: pairs, + factorSummaries, + }; +} + +export interface HardNegativePairV1 { + readonly leftObservationId: string; + readonly rightObservationId: string; + readonly leftSpecimenId: string; + readonly rightSpecimenId: string; + readonly medianFrequencyDriftCents: number; + readonly matchedModes: number; +} + +export function selectHardNegativePairs( + observations: readonly LabeledFingerprintObservationV1[], + limit: number, +): readonly HardNegativePairV1[] { + if (!Number.isInteger(limit) || limit <= 0) throw new Error("hard-negative limit must be a positive integer"); + 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; + const recurrence = fingerprintRecurrence(left.fingerprint, right.fingerprint); + pairs.push({ + leftObservationId: left.observationId, + rightObservationId: right.observationId, + leftSpecimenId: left.specimenId, + rightSpecimenId: right.specimenId, + medianFrequencyDriftCents: recurrence.medianCents, + matchedModes: recurrence.matchedCount, + }); + } + } + return pairs + .sort((left, right) => left.medianFrequencyDriftCents - right.medianFrequencyDriftCents || right.matchedModes - left.matchedModes) + .slice(0, limit); +} + +function fnv1a32(value: string): number { + let hash = 0x811c9dc5; + for (let index = 0; index < value.length; index += 1) { + hash ^= value.charCodeAt(index); + hash = Math.imul(hash, 0x01000193) >>> 0; + } + return hash >>> 0; +} + +export type BenchmarkSplit = "train" | "validation" | "test"; + +export function specimenDisjointSplit(specimenId: string): BenchmarkSplit { + const bucket = fnv1a32(specimenId.trim().toLocaleLowerCase("en-US")) % 100; + if (bucket < 70) return "train"; + if (bucket < 85) return "validation"; + return "test"; +} diff --git a/packages/fingerprint/src/object-model.ts b/packages/fingerprint/src/object-model.ts new file mode 100644 index 00000000..fe52e742 --- /dev/null +++ b/packages/fingerprint/src/object-model.ts @@ -0,0 +1,239 @@ +import type { AcousticFingerprintV1, AcousticFingerprintAlgorithmVersion } from "@everything-rings/dsp"; +import { centsDistance } from "./recurrence"; + +export interface AcousticObjectObservationV1 { + readonly observationId: string; + readonly specimenId: string; + readonly fingerprint: AcousticFingerprintV1; +} + +export interface AcousticObjectModelConfigV1 { + readonly clusteringToleranceCents: number; + readonly minimumObservationSupportFraction: number; + readonly minimumObservationSupportCount: number; +} + +export const DEFAULT_ACOUSTIC_OBJECT_MODEL_CONFIG: AcousticObjectModelConfigV1 = { + clusteringToleranceCents: 80, + minimumObservationSupportFraction: 0.5, + minimumObservationSupportCount: 2, +}; + +export interface AcousticObjectModeEstimateV1 { + readonly frequencyHzMedian: number; + readonly frequencyMadCents: number; + readonly decaySecondsMedian: number; + readonly decayLogMad: number; + readonly relativeAmplitudeMedian: number; + readonly relativeAmplitudeDbMad: number; + readonly observationSupportCount: number; + readonly observationSupportFraction: number; + readonly sourceObservationIds: readonly string[]; +} + +export interface AcousticObjectModelV1 { + readonly schemaVersion: 1; + readonly objectModelVersion: "acoustic-object-model-1"; + readonly specimenId: string; + readonly observationCount: number; + readonly fingerprintAlgorithmVersions: readonly AcousticFingerprintAlgorithmVersion[]; + readonly sampleRates: readonly number[]; + readonly modes: readonly AcousticObjectModeEstimateV1[]; +} + +interface ModeSample { + readonly observationId: string; + readonly frequencyHz: number; + readonly decaySeconds: number; + readonly relativeAmplitude: number; +} + +interface MutableCluster { + readonly samples: ModeSample[]; +} + +function median(values: readonly number[]): number { + if (values.length === 0) throw new Error("median requires at least one value"); + const ordered = [...values].sort((left, right) => left - right); + const middle = Math.floor(ordered.length / 2); + if (ordered.length % 2 === 1) return ordered[middle]!; + return (ordered[middle - 1]! + ordered[middle]!) / 2; +} + +function medianAbsoluteDeviation(values: readonly number[]): number { + const center = median(values); + return median(values.map((value) => Math.abs(value - center))); +} + +function dbAmplitude(value: number): number { + return 20 * Math.log10(Math.max(value, 1e-9)); +} + +function clusterCenterFrequency(cluster: MutableCluster): number { + return median(cluster.samples.map((sample) => sample.frequencyHz)); +} + +function validateConfig(config: AcousticObjectModelConfigV1): void { + if (!(config.clusteringToleranceCents > 0) || !Number.isFinite(config.clusteringToleranceCents)) { + throw new Error("object-model clusteringToleranceCents must be finite and positive"); + } + if (!(config.minimumObservationSupportFraction > 0 && config.minimumObservationSupportFraction <= 1)) { + throw new Error("object-model minimumObservationSupportFraction must be in (0, 1]"); + } + if (!Number.isInteger(config.minimumObservationSupportCount) || config.minimumObservationSupportCount <= 0) { + throw new Error("object-model minimumObservationSupportCount must be a positive integer"); + } +} + +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)) { + throw new Error("object model cannot mix different specimen IDs"); + } + const observationIds = new Set(); + for (const observation of observations) { + const id = observation.observationId.trim(); + if (id.length === 0) throw new Error("object model observationId is required"); + if (observationIds.has(id)) throw new Error(`duplicate object-model observationId ${id}`); + observationIds.add(id); + } + + const clusters: MutableCluster[] = []; + for (const observation of observations) { + const usedClusters = new Set(); + const modes = [...observation.fingerprint.modes].sort((left, right) => left.frequencyHz - right.frequencyHz); + for (const mode of modes) { + let bestIndex = -1; + let bestDistance = Number.POSITIVE_INFINITY; + for (let index = 0; index < clusters.length; index += 1) { + if (usedClusters.has(index)) continue; + const distance = centsDistance(clusterCenterFrequency(clusters[index]!), mode.frequencyHz); + if (distance <= config.clusteringToleranceCents && distance < bestDistance) { + bestIndex = index; + bestDistance = distance; + } + } + const sample: ModeSample = { + observationId: observation.observationId, + frequencyHz: mode.frequencyHz, + decaySeconds: mode.decaySeconds, + relativeAmplitude: mode.relativeAmplitude, + }; + if (bestIndex < 0) { + clusters.push({ samples: [sample] }); + usedClusters.add(clusters.length - 1); + } else { + clusters[bestIndex]!.samples.push(sample); + usedClusters.add(bestIndex); + } + } + } + + const minimumSupport = Math.max( + config.minimumObservationSupportCount, + Math.ceil(observations.length * config.minimumObservationSupportFraction), + ); + const modes = clusters + .filter((cluster) => new Set(cluster.samples.map((sample) => sample.observationId)).size >= minimumSupport) + .map((cluster): AcousticObjectModeEstimateV1 => { + const frequencies = cluster.samples.map((sample) => sample.frequencyHz); + const center = median(frequencies); + const frequencyOffsets = frequencies.map((frequency) => centsDistance(center, frequency)); + const decays = cluster.samples.map((sample) => sample.decaySeconds); + 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(); + return { + frequencyHzMedian: center, + frequencyMadCents: medianAbsoluteDeviation(frequencyOffsets), + decaySecondsMedian: median(decays), + decayLogMad: medianAbsoluteDeviation(decayLogs), + relativeAmplitudeMedian: median(amplitudes), + relativeAmplitudeDbMad: medianAbsoluteDeviation(amplitudeDb), + observationSupportCount: sourceObservationIds.length, + observationSupportFraction: sourceObservationIds.length / observations.length, + sourceObservationIds, + }; + }) + .sort((left, right) => left.frequencyHzMedian - right.frequencyHzMedian); + + return { + schemaVersion: 1, + objectModelVersion: "acoustic-object-model-1", + specimenId, + observationCount: observations.length, + fingerprintAlgorithmVersions: [...new Set(observations.map((observation) => observation.fingerprint.algorithmVersion))].sort(), + sampleRates: [...new Set(observations.map((observation) => observation.fingerprint.sampleRate))].sort((left, right) => left - right), + modes, + }; +} + +export interface FingerprintToObjectModelComparisonV1 { + readonly comparisonVersion: "fingerprint-object-model-comparison-1"; + readonly specimenId: string; + readonly matchedModelModes: number; + readonly modelModeCount: number; + readonly coverage: number; + readonly medianFrequencyDistanceCents: number | null; + readonly p90FrequencyDistanceCents: number | null; + readonly normalizedDistance: number; + readonly evidenceScore: number; +} + +function percentile(values: readonly number[], quantile: number): number | null { + if (values.length === 0) return null; + const ordered = [...values].sort((left, right) => left - right); + const index = Math.min(ordered.length - 1, Math.max(0, Math.ceil(ordered.length * quantile) - 1)); + return ordered[index] ?? null; +} + +export function compareFingerprintToObjectModel( + fingerprint: AcousticFingerprintV1, + model: AcousticObjectModelV1, + maximumMatchDistanceCents = 180, +): FingerprintToObjectModelComparisonV1 { + if (!(maximumMatchDistanceCents > 0) || !Number.isFinite(maximumMatchDistanceCents)) { + throw new Error("maximumMatchDistanceCents must be finite and positive"); + } + const usedFingerprintModes = new Set(); + const distances: number[] = []; + for (const modelMode of model.modes) { + let bestIndex = -1; + let bestDistance = Number.POSITIVE_INFINITY; + for (let index = 0; index < fingerprint.modes.length; index += 1) { + if (usedFingerprintModes.has(index)) continue; + const distance = centsDistance(modelMode.frequencyHzMedian, fingerprint.modes[index]!.frequencyHz); + if (distance < bestDistance) { + bestIndex = index; + bestDistance = distance; + } + } + if (bestIndex >= 0 && bestDistance <= maximumMatchDistanceCents) { + usedFingerprintModes.add(bestIndex); + distances.push(bestDistance); + } + } + const coverage = model.modes.length === 0 ? 0 : distances.length / model.modes.length; + const medianDistance = distances.length === 0 ? null : median(distances); + const normalizedFrequency = medianDistance === null ? 1 : Math.min(1, medianDistance / maximumMatchDistanceCents); + const normalizedDistance = Math.min(1, 0.65 * normalizedFrequency + 0.35 * (1 - coverage)); + return { + comparisonVersion: "fingerprint-object-model-comparison-1", + specimenId: model.specimenId, + matchedModelModes: distances.length, + modelModeCount: model.modes.length, + coverage, + medianFrequencyDistanceCents: medianDistance, + p90FrequencyDistanceCents: percentile(distances, 0.9), + normalizedDistance, + evidenceScore: 1 - normalizedDistance, + }; +} diff --git a/packages/fingerprint/src/promotion.ts b/packages/fingerprint/src/promotion.ts new file mode 100644 index 00000000..caf1fb8c --- /dev/null +++ b/packages/fingerprint/src/promotion.ts @@ -0,0 +1,87 @@ +export interface SimilarityAlgorithmHeldOutMetricsV1 { + readonly algorithmVersion: string; + readonly evaluationPopulation: string; + readonly specimenDisjoint: boolean; + readonly queryCount: number; + readonly recallAt1: number | null; + readonly meanReciprocalRank: number | null; + readonly rocAuc: number | null; + readonly brierScore: number | null; + readonly coverage: number; +} + +export interface SimilarityPromotionPolicyV1 { + readonly policyVersion: "similarity-promotion-policy-1"; + readonly minimumQueryCount: number; + readonly minimumCoverage: number; + readonly maximumRecallAt1Regression: number; + readonly maximumMrrRegression: number; + readonly maximumAucRegression: number; + readonly maximumBrierIncrease: number; + readonly minimumAnyPrimaryImprovement: number; +} + +export const DEFAULT_SIMILARITY_PROMOTION_POLICY: SimilarityPromotionPolicyV1 = { + policyVersion: "similarity-promotion-policy-1", + minimumQueryCount: 20, + minimumCoverage: 0.5, + maximumRecallAt1Regression: 0, + maximumMrrRegression: 0, + maximumAucRegression: 0, + maximumBrierIncrease: 0, + minimumAnyPrimaryImprovement: 0.01, +}; + +export interface SimilarityPromotionVerdictV1 { + readonly verdictVersion: "similarity-promotion-verdict-1"; + readonly promoted: boolean; + readonly baselineAlgorithmVersion: string; + readonly candidateAlgorithmVersion: string; + readonly evaluationPopulation: string; + readonly reasons: readonly string[]; + readonly improvements: { + readonly recallAt1: number | null; + readonly meanReciprocalRank: number | null; + readonly rocAuc: number | null; + readonly brierScoreReduction: number | null; + }; +} + +function delta(candidate: number | null, baseline: number | null): number | null { + return candidate === null || baseline === null ? null : candidate - baseline; +} + +export function evaluateSimilarityAlgorithmPromotion( + baseline: SimilarityAlgorithmHeldOutMetricsV1, + candidate: SimilarityAlgorithmHeldOutMetricsV1, + policy: SimilarityPromotionPolicyV1 = DEFAULT_SIMILARITY_PROMOTION_POLICY, +): SimilarityPromotionVerdictV1 { + const reasons: string[] = []; + if (baseline.evaluationPopulation !== candidate.evaluationPopulation) reasons.push("baseline and candidate evaluation populations differ"); + if (!baseline.specimenDisjoint || !candidate.specimenDisjoint) reasons.push("promotion requires specimen-disjoint held-out evaluation"); + if (candidate.queryCount < policy.minimumQueryCount) reasons.push(`candidate query count is below ${policy.minimumQueryCount}`); + if (candidate.coverage < policy.minimumCoverage) reasons.push(`candidate coverage is below ${policy.minimumCoverage}`); + + const recall = delta(candidate.recallAt1, baseline.recallAt1); + const mrr = delta(candidate.meanReciprocalRank, baseline.meanReciprocalRank); + const auc = delta(candidate.rocAuc, baseline.rocAuc); + const brierReduction = baseline.brierScore === null || candidate.brierScore === null ? null : baseline.brierScore - candidate.brierScore; + if (recall !== null && recall < -policy.maximumRecallAt1Regression) reasons.push("candidate regresses Recall@1 beyond policy"); + if (mrr !== null && mrr < -policy.maximumMrrRegression) reasons.push("candidate regresses MRR beyond policy"); + if (auc !== null && auc < -policy.maximumAucRegression) reasons.push("candidate regresses ROC AUC beyond policy"); + if (brierReduction !== null && brierReduction < -policy.maximumBrierIncrease) reasons.push("candidate increases Brier score beyond policy"); + + const primaryImprovements = [recall, mrr, auc, brierReduction].filter((value): value is number => value !== null); + if (primaryImprovements.length === 0) reasons.push("no comparable primary held-out metric exists"); + else if (Math.max(...primaryImprovements) < policy.minimumAnyPrimaryImprovement) reasons.push(`candidate does not improve any primary metric by ${policy.minimumAnyPrimaryImprovement}`); + + return { + verdictVersion: "similarity-promotion-verdict-1", + promoted: reasons.length === 0, + baselineAlgorithmVersion: baseline.algorithmVersion, + candidateAlgorithmVersion: candidate.algorithmVersion, + evaluationPopulation: candidate.evaluationPopulation, + reasons, + improvements: { recallAt1: recall, meanReciprocalRank: mrr, rocAuc: auc, brierScoreReduction: brierReduction }, + }; +} diff --git a/packages/fingerprint/src/retrieval.ts b/packages/fingerprint/src/retrieval.ts new file mode 100644 index 00000000..b51c941d --- /dev/null +++ b/packages/fingerprint/src/retrieval.ts @@ -0,0 +1,129 @@ +import type { AcousticFingerprintV1 } from "@everything-rings/dsp"; +import { + buildAcousticObjectModel, + compareFingerprintToObjectModel, + type AcousticObjectModelV1, + type AcousticObjectObservationV1, +} from "./object-model"; + +export interface SonicTwinIndexEntryV1 { + readonly specimenId: string; + readonly model: AcousticObjectModelV1; +} + +export interface SonicTwinIndexV1 { + readonly schemaVersion: 1; + readonly indexVersion: "sonic-twin-index-1"; + readonly entries: readonly SonicTwinIndexEntryV1[]; +} + +export function buildSonicTwinIndex( + observations: readonly AcousticObjectObservationV1[], +): SonicTwinIndexV1 { + const grouped = new Map(); + for (const observation of observations) { + const normalized = observation.specimenId.trim().toLocaleLowerCase("en-US"); + if (normalized.length === 0) throw new Error("Sonic Twin index observations require specimenId"); + const current = grouped.get(normalized) ?? []; + current.push(observation); + 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)); + return { schemaVersion: 1, indexVersion: "sonic-twin-index-1", entries }; +} + +export interface SonicTwinRetrievalResultV1 { + readonly rank: number; + readonly specimenId: string; + readonly evidenceScore: number; + readonly normalizedDistance: number; + readonly matchedModelModes: number; + readonly modelModeCount: number; +} + +export function retrieveSonicTwin( + query: AcousticFingerprintV1, + index: SonicTwinIndexV1, + limit = index.entries.length, +): readonly SonicTwinRetrievalResultV1[] { + if (!Number.isInteger(limit) || limit <= 0) throw new Error("retrieval limit must be a positive integer"); + return index.entries + .map((entry) => { + const comparison = compareFingerprintToObjectModel(query, entry.model); + return { + specimenId: entry.specimenId, + evidenceScore: comparison.evidenceScore, + normalizedDistance: comparison.normalizedDistance, + matchedModelModes: comparison.matchedModelModes, + modelModeCount: comparison.modelModeCount, + }; + }) + .sort((left, right) => right.evidenceScore - left.evidenceScore || left.specimenId.localeCompare(right.specimenId)) + .slice(0, Math.min(limit, index.entries.length)) + .map((result, indexValue) => ({ rank: indexValue + 1, ...result })); +} + +export interface SonicTwinRetrievalQueryV1 { + readonly queryId: string; + readonly trueSpecimenId: string; + readonly fingerprint: AcousticFingerprintV1; +} + +export interface SonicTwinRetrievalMetricsV1 { + readonly queryCount: number; + readonly eligibleQueryCount: number; + readonly recallAt1: number | null; + readonly recallAt3: number | null; + readonly recallAt5: number | null; + readonly meanReciprocalRank: number | null; + readonly meanTrueSpecimenRank: number | null; + readonly missingTrueSpecimenCount: number; +} + +function rate(numerator: number, denominator: number): number | null { + return denominator === 0 ? null : numerator / denominator; +} + +export function benchmarkSonicTwinRetrieval( + queries: readonly SonicTwinRetrievalQueryV1[], + index: SonicTwinIndexV1, +): SonicTwinRetrievalMetricsV1 { + const indexedSpecimens = new Set(index.entries.map((entry) => entry.specimenId.trim().toLocaleLowerCase("en-US"))); + let eligible = 0; + let hit1 = 0; + let hit3 = 0; + let hit5 = 0; + let reciprocalRankSum = 0; + let rankSum = 0; + let missing = 0; + + for (const query of queries) { + const target = query.trueSpecimenId.trim().toLocaleLowerCase("en-US"); + if (!indexedSpecimens.has(target)) { + missing += 1; + continue; + } + eligible += 1; + const ranked = retrieveSonicTwin(query.fingerprint, index); + const rank = ranked.findIndex((result) => result.specimenId.trim().toLocaleLowerCase("en-US") === target) + 1; + if (rank <= 0) throw new Error("indexed true specimen disappeared from retrieval ranking"); + if (rank <= 1) hit1 += 1; + if (rank <= 3) hit3 += 1; + if (rank <= 5) hit5 += 1; + reciprocalRankSum += 1 / rank; + rankSum += rank; + } + + return { + queryCount: queries.length, + eligibleQueryCount: eligible, + recallAt1: rate(hit1, eligible), + recallAt3: rate(hit3, eligible), + recallAt5: rate(hit5, eligible), + meanReciprocalRank: eligible === 0 ? null : reciprocalRankSum / eligible, + meanTrueSpecimenRank: eligible === 0 ? null : rankSum / eligible, + missingTrueSpecimenCount: missing, + }; +} diff --git a/packages/fingerprint/src/sonic-twin.ts b/packages/fingerprint/src/sonic-twin.ts new file mode 100644 index 00000000..f81ed854 --- /dev/null +++ b/packages/fingerprint/src/sonic-twin.ts @@ -0,0 +1,185 @@ +import type { AcousticFingerprintV1 } from "@everything-rings/dsp"; +import { fingerprintRecurrence } from "./recurrence"; + +export interface SonicTwinBaselineConfig { + readonly minimumMatchedModes: number; + readonly maximumFrequencyMedianCentsForMatch: number; + readonly minimumFrequencyMedianCentsForNonMatch: number; + readonly maximumMedianDecayLogRatioForMatch: number; + readonly maximumMedianAmplitudeDbForMatch: number; +} + +export const DEFAULT_SONIC_TWIN_BASELINE_CONFIG: SonicTwinBaselineConfig = { + minimumMatchedModes: 3, + maximumFrequencyMedianCentsForMatch: 35, + minimumFrequencyMedianCentsForNonMatch: 120, + maximumMedianDecayLogRatioForMatch: 0.45, + maximumMedianAmplitudeDbForMatch: 8, +}; + +export type SonicTwinDecision = "match" | "nonmatch" | "abstain"; + +export interface SonicTwinBaselineComparison { + readonly baselineVersion: "sonic-twin-baseline-1"; + readonly decision: SonicTwinDecision; + readonly matchedModes: number; + readonly frequencyMedianCents: number; + readonly frequencyMeanCents: number; + readonly medianDecayLogRatio: number | null; + readonly medianAmplitudeDbDifference: number | null; + readonly reasons: readonly string[]; + readonly calibratedProbability: null; +} + +function median(values: readonly number[]): number | null { + if (values.length === 0) return null; + const ordered = [...values].sort((left, right) => left - right); + const middle = Math.floor(ordered.length / 2); + if (ordered.length % 2 === 1) return ordered[middle] ?? null; + return ((ordered[middle - 1] ?? 0) + (ordered[middle] ?? 0)) / 2; +} + +function safeDbRatio(left: number, right: number): number { + const floor = 1e-6; + return 20 * Math.abs(Math.log10(Math.max(left, floor) / Math.max(right, floor))); +} + +export function compareSonicTwinBaseline( + reference: AcousticFingerprintV1, + candidate: AcousticFingerprintV1, + config: SonicTwinBaselineConfig = DEFAULT_SONIC_TWIN_BASELINE_CONFIG, +): SonicTwinBaselineComparison { + const recurrence = fingerprintRecurrence(reference, candidate); + const paired = recurrence.matches.flatMap((match) => { + if (match.candidateIndex === undefined) return []; + const left = reference.modes[match.referenceIndex]; + const right = candidate.modes[match.candidateIndex]; + if (left === undefined || right === undefined) return []; + return [{ left, right }]; + }); + const decay = median(paired.map(({ left, right }) => Math.abs(Math.log(Math.max(left.decaySeconds, 1e-6) / Math.max(right.decaySeconds, 1e-6))))); + const amplitude = median(paired.map(({ left, right }) => safeDbRatio(left.relativeAmplitude, right.relativeAmplitude))); + const reasons: string[] = []; + + if (recurrence.matchedCount < config.minimumMatchedModes) { + reasons.push(`fewer than ${config.minimumMatchedModes} modes are comparable`); + return { + baselineVersion: "sonic-twin-baseline-1", + decision: "abstain", + matchedModes: recurrence.matchedCount, + frequencyMedianCents: recurrence.medianCents, + frequencyMeanCents: recurrence.meanCents, + medianDecayLogRatio: decay, + medianAmplitudeDbDifference: amplitude, + reasons, + calibratedProbability: null, + }; + } + + const frequencyMatch = recurrence.medianCents <= config.maximumFrequencyMedianCentsForMatch; + const decayMatch = decay !== null && decay <= config.maximumMedianDecayLogRatioForMatch; + const amplitudeMatch = amplitude !== null && amplitude <= config.maximumMedianAmplitudeDbForMatch; + if (frequencyMatch && decayMatch && amplitudeMatch) { + return { + baselineVersion: "sonic-twin-baseline-1", + decision: "match", + matchedModes: recurrence.matchedCount, + frequencyMedianCents: recurrence.medianCents, + frequencyMeanCents: recurrence.meanCents, + medianDecayLogRatio: decay, + medianAmplitudeDbDifference: amplitude, + reasons: [], + calibratedProbability: null, + }; + } + + if (recurrence.medianCents >= config.minimumFrequencyMedianCentsForNonMatch) { + reasons.push("modal-frequency separation exceeds the nonmatch threshold"); + return { + baselineVersion: "sonic-twin-baseline-1", + decision: "nonmatch", + matchedModes: recurrence.matchedCount, + frequencyMedianCents: recurrence.medianCents, + frequencyMeanCents: recurrence.meanCents, + medianDecayLogRatio: decay, + medianAmplitudeDbDifference: amplitude, + reasons, + calibratedProbability: null, + }; + } + + if (!frequencyMatch) reasons.push("frequency agreement is outside the match region"); + if (!decayMatch) reasons.push("decay agreement is outside the match region"); + if (!amplitudeMatch) reasons.push("modal-amplitude agreement is outside the match region"); + return { + baselineVersion: "sonic-twin-baseline-1", + decision: "abstain", + matchedModes: recurrence.matchedCount, + frequencyMedianCents: recurrence.medianCents, + frequencyMeanCents: recurrence.meanCents, + medianDecayLogRatio: decay, + medianAmplitudeDbDifference: amplitude, + reasons, + calibratedProbability: null, + }; +} + +export interface LabeledTwinPair { + readonly samePhysicalSpecimen: boolean; + readonly reference: AcousticFingerprintV1; + readonly candidate: AcousticFingerprintV1; +} + +export interface SonicTwinBenchmarkMetrics { + readonly pairCount: number; + readonly coveredPairCount: number; + readonly abstainedPairCount: number; + readonly coverage: number; + readonly truePositiveRate: number | null; + readonly falsePositiveRate: number | null; + readonly trueNegativeRate: number | null; + readonly falseNegativeRate: number | null; +} + +function rate(numerator: number, denominator: number): number | null { + return denominator === 0 ? null : numerator / denominator; +} + +export function benchmarkSonicTwinBaseline( + pairs: readonly LabeledTwinPair[], + config: SonicTwinBaselineConfig = DEFAULT_SONIC_TWIN_BASELINE_CONFIG, +): SonicTwinBenchmarkMetrics { + let tp = 0; + let fp = 0; + let tn = 0; + let fn = 0; + let positives = 0; + let negatives = 0; + let covered = 0; + + for (const pair of pairs) { + const result = compareSonicTwinBaseline(pair.reference, pair.candidate, config); + if (result.decision === "abstain") continue; + covered += 1; + if (pair.samePhysicalSpecimen) { + positives += 1; + if (result.decision === "match") tp += 1; + else fn += 1; + } else { + negatives += 1; + if (result.decision === "nonmatch") tn += 1; + else fp += 1; + } + } + + return { + pairCount: pairs.length, + coveredPairCount: covered, + abstainedPairCount: pairs.length - covered, + coverage: pairs.length === 0 ? 0 : covered / pairs.length, + truePositiveRate: rate(tp, positives), + falsePositiveRate: rate(fp, negatives), + trueNegativeRate: rate(tn, negatives), + falseNegativeRate: rate(fn, positives), + }; +} diff --git a/packages/fingerprint/test/calibration-heldout.test.ts b/packages/fingerprint/test/calibration-heldout.test.ts new file mode 100644 index 00000000..f08b95d8 --- /dev/null +++ b/packages/fingerprint/test/calibration-heldout.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { fitAndEvaluateHeldOutCalibration, type PartitionedSimilarityScoreV1 } from "../src"; + +const samples: readonly PartitionedSimilarityScoreV1[] = [ + { pairId: "cal-a-pos", groupId: "cal-a", partition: "calibration", score: 0.92, samePhysicalSpecimen: true }, + { pairId: "cal-a-neg", groupId: "cal-a", partition: "calibration", score: 0.18, samePhysicalSpecimen: false }, + { pairId: "cal-b-pos", groupId: "cal-b", partition: "calibration", score: 0.84, samePhysicalSpecimen: true }, + { pairId: "cal-b-neg", groupId: "cal-b", partition: "calibration", score: 0.25, samePhysicalSpecimen: false }, + { pairId: "eval-c-pos", groupId: "eval-c", partition: "evaluation", score: 0.88, samePhysicalSpecimen: true }, + { pairId: "eval-c-neg", groupId: "eval-c", partition: "evaluation", score: 0.12, samePhysicalSpecimen: false }, + { pairId: "eval-d-pos", groupId: "eval-d", partition: "evaluation", score: 0.79, samePhysicalSpecimen: true }, + { pairId: "eval-d-neg", groupId: "eval-d", partition: "evaluation", score: 0.30, samePhysicalSpecimen: false }, +]; + +describe("held-out Sonic Twin calibration", () => { + it("fits only on calibration groups and reports only group-disjoint evaluation metrics", () => { + const report = fitAndEvaluateHeldOutCalibration(samples, "digital-calibration-population", "digital-evaluation-population", 4, [0, 0.25]); + expect(report.calibrationSampleCount).toBe(4); + expect(report.evaluationSampleCount).toBe(4); + expect(report.model.trainingSampleCount).toBe(4); + expect(report.predictions).toHaveLength(4); + expect(report.metrics.sampleCount).toBe(4); + expect(report.metrics.rocAuc).toBe(1); + expect(new Set(report.calibrationGroups)).toEqual(new Set(["cal-a", "cal-b"])); + expect(new Set(report.evaluationGroups)).toEqual(new Set(["eval-c", "eval-d"])); + }); + + it("rejects group leakage across calibration and evaluation partitions", () => { + const leaking = [...samples, { pairId: "leak", groupId: "cal-a", partition: "evaluation" as const, score: 0.5, samePhysicalSpecimen: false }]; + expect(() => fitAndEvaluateHeldOutCalibration(leaking, "cal", "eval")).toThrow(/leakage/); + }); + + it("rejects evaluation sets that cannot measure both classes", () => { + const oneClass = samples.filter((sample) => sample.partition === "calibration" || sample.samePhysicalSpecimen); + expect(() => fitAndEvaluateHeldOutCalibration(oneClass, "cal", "eval")).toThrow(/negative/); + }); +}); diff --git a/packages/fingerprint/test/digital-twin.test.ts b/packages/fingerprint/test/digital-twin.test.ts new file mode 100644 index 00000000..44440e58 --- /dev/null +++ b/packages/fingerprint/test/digital-twin.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it } from "vitest"; +import { analyzeImpact } from "@everything-rings/dsp"; +import { fingerprintRecurrence } from "../src/recurrence"; + +interface TwinMode { + readonly frequencyHz: number; + readonly decaySeconds: number; + readonly amplitude: number; + readonly phase: number; +} + +interface DigitalTwin { + readonly name: string; + readonly modes: readonly TwinMode[]; +} + +interface Nuisance { + readonly gain: number; + readonly dampingScale: number; + readonly excitationTilt: number; + readonly noiseAmplitude: number; + readonly seed: number; +} + +const SAMPLE_RATE = 48_000; +const DURATION_SECONDS = 2.6; + +function prng(seed: number): () => number { + let state = seed >>> 0; + return () => { + state = (1664525 * state + 1013904223) >>> 0; + return state / 0x1_0000_0000; + }; +} + +function renderTwin(twin: DigitalTwin, nuisance: Nuisance): Float32Array { + const count = Math.round(SAMPLE_RATE * DURATION_SECONDS); + const samples = new Float32Array(count); + const random = prng(nuisance.seed); + for (let index = 0; index < count; index += 1) { + const t = index / SAMPLE_RATE; + let value = 0; + for (let modeIndex = 0; modeIndex < twin.modes.length; modeIndex += 1) { + const mode = twin.modes[modeIndex]!; + const positionWeight = Math.max(0.2, 1 + nuisance.excitationTilt * (modeIndex - (twin.modes.length - 1) / 2)); + value += nuisance.gain + * mode.amplitude + * positionWeight + * Math.exp(-t / (mode.decaySeconds * nuisance.dampingScale)) + * Math.sin(2 * Math.PI * mode.frequencyHz * t + mode.phase); + } + value += nuisance.noiseAmplitude * (2 * random() - 1); + samples[index] = Math.max(-0.9, Math.min(0.9, value)); + } + return samples; +} + +const ordinaryTwin: DigitalTwin = { + name: "ordinary-resonant", + modes: [ + { frequencyHz: 437, decaySeconds: 1.5, amplitude: 0.18, phase: 0.1 }, + { frequencyHz: 913, decaySeconds: 1.1, amplitude: 0.13, phase: 0.8 }, + { frequencyHz: 1517, decaySeconds: 0.85, amplitude: 0.10, phase: 1.4 }, + { frequencyHz: 2321, decaySeconds: 0.62, amplitude: 0.07, phase: 2.1 }, + { frequencyHz: 3413, decaySeconds: 0.48, amplitude: 0.05, phase: 2.7 }, + ], +}; + +const highQDigitalTwin: DigitalTwin = { + name: "high-q", + modes: [ + { frequencyHz: 521, decaySeconds: 2.2, amplitude: 0.14, phase: 0.2 }, + { frequencyHz: 1183, decaySeconds: 1.9, amplitude: 0.12, phase: 0.9 }, + { frequencyHz: 1987, decaySeconds: 1.6, amplitude: 0.09, phase: 1.7 }, + { frequencyHz: 2879, decaySeconds: 1.4, amplitude: 0.06, phase: 2.4 }, + ], +}; + +function analyzeTwin(twin: DigitalTwin, nuisance: Nuisance) { + return analyzeImpact(renderTwin(twin, nuisance), SAMPLE_RATE); +} + +describe("digital-twin acoustic stress validation", () => { + it("recovers a stable fingerprint across gain, excitation, damping, and noise nuisance", () => { + const reference = analyzeTwin(ordinaryTwin, { + gain: 1, + dampingScale: 1, + excitationTilt: 0, + noiseAmplitude: 0.0002, + seed: 1, + }); + expect(reference.ok).toBe(true); + if (!reference.ok) return; + + const nuisances: Nuisance[] = [ + { gain: 0.8, dampingScale: 0.95, excitationTilt: -0.08, noiseAmplitude: 0.0004, seed: 2 }, + { gain: 1.1, dampingScale: 1.05, excitationTilt: 0.08, noiseAmplitude: 0.0006, seed: 3 }, + { gain: 0.9, dampingScale: 1.1, excitationTilt: 0.12, noiseAmplitude: 0.0008, seed: 4 }, + { gain: 1.05, dampingScale: 0.9, excitationTilt: -0.12, noiseAmplitude: 0.0005, seed: 5 }, + ]; + + for (const nuisance of nuisances) { + const candidate = analyzeTwin(ordinaryTwin, nuisance); + expect(candidate.ok).toBe(true); + if (!candidate.ok) continue; + const recurrence = fingerprintRecurrence(reference.fingerprint, candidate.fingerprint); + expect(recurrence.matchedCount).toBeGreaterThanOrEqual(3); + expect(recurrence.medianCents).toBeLessThanOrEqual(25); + } + }); + + it("keeps a distinct digital twin acoustically separated from the ordinary twin", () => { + const left = analyzeTwin(ordinaryTwin, { gain: 1, dampingScale: 1, excitationTilt: 0, noiseAmplitude: 0.0002, seed: 6 }); + const right = analyzeTwin(highQDigitalTwin, { gain: 1, dampingScale: 1, excitationTilt: 0, noiseAmplitude: 0.0002, seed: 7 }); + expect(left.ok).toBe(true); + expect(right.ok).toBe(true); + if (!left.ok || !right.ok) return; + const recurrence = fingerprintRecurrence(left.fingerprint, right.fingerprint); + expect(recurrence.medianCents).toBeGreaterThan(50); + }); + + it("turns extreme low-SNR stress into an explicit analysis outcome, never silent corruption", () => { + const result = analyzeTwin(ordinaryTwin, { + gain: 0.03, + dampingScale: 0.7, + excitationTilt: 0.25, + noiseAmplitude: 0.03, + seed: 8, + }); + if (result.ok) { + expect(result.fingerprint.modes.length).toBeGreaterThanOrEqual(3); + expect(result.fingerprint.modes.every((mode) => Number.isFinite(mode.frequencyHz) && mode.frequencyHz > 0)).toBe(true); + } else { + expect(["NO_STABLE_RESONANCES", "SIGNAL_TOO_SHORT"]).toContain(result.reason); + } + }); +}); diff --git a/packages/fingerprint/test/full-vision.test.ts b/packages/fingerprint/test/full-vision.test.ts new file mode 100644 index 00000000..64834020 --- /dev/null +++ b/packages/fingerprint/test/full-vision.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it } from "vitest"; +import type { AcousticFingerprintV1 } from "@everything-rings/dsp"; +import { + benchmarkSonicTwinRetrieval, + buildAcousticObjectModel, + buildSonicTwinIndex, + buildTwinVerificationPairs, + calibratedPredictions, + compareFingerprintToObjectModel, + evaluateCalibration, + fitIsotonicCalibration, + predictCalibratedProbability, + retrieveSonicTwin, + riskCoverageCurve, + validateTwinBenchmarkCorpus, + type AcousticObjectObservationV1, + type NuisanceMetadataV1, + type TwinBenchmarkCorpusV1, +} from "../src"; + +function fp(frequencies: readonly number[], amplitudeScale = 1, decayScale = 1): AcousticFingerprintV1 { + return { + version: 1, + algorithmVersion: "er-dsp-2", + sampleRate: 48_000, + durationSeconds: 2, + modes: frequencies.map((frequencyHz, index) => ({ + frequencyHz, + relativeAmplitude: amplitudeScale / (index + 1), + decaySeconds: decayScale * 0.9 / (index + 1), + q: 100, + confidence: 0.9, + diagnostics: { prominenceDb: 20, persistenceSeconds: 0.2, frequencyStdCents: 2, decayFitScore: 0.95, observationCount: 12 }, + })), + }; +} + +function observations(): AcousticObjectObservationV1[] { + return [ + { observationId: "a-1", specimenId: "specimen-a", fingerprint: fp([440, 880, 1320]) }, + { observationId: "a-2", specimenId: "specimen-a", fingerprint: fp([441, 881.5, 1322]) }, + { observationId: "b-1", specimenId: "specimen-b", fingerprint: fp([610, 1220, 1830]) }, + { observationId: "b-2", specimenId: "specimen-b", fingerprint: fp([611, 1221, 1832]) }, + { observationId: "c-1", specimenId: "specimen-c", fingerprint: fp([442, 884, 1326]) }, + { observationId: "c-2", specimenId: "specimen-c", fingerprint: fp([443, 885, 1329]) }, + ]; +} + +const nuisance: NuisanceMetadataV1 = { + strikeLocation: "A", + striker: "wood", + support: "felt", + microphoneDistanceClass: "20cm", + room: "digital-room", + stationId: "digital-station", + operatorId: "digital-operator", + dayId: "day-1", +}; + +describe("full-vision Sonic Twin infrastructure", () => { + it("aggregates repeated observations into an uncertainty-preserving object model", () => { + const model = buildAcousticObjectModel(observations().filter((observation) => observation.specimenId === "specimen-a")); + expect(model.specimenId).toBe("specimen-a"); + expect(model.observationCount).toBe(2); + expect(model.modes).toHaveLength(3); + expect(model.modes.every((mode) => mode.observationSupportFraction === 1)).toBe(true); + expect(model.modes[0]!.frequencyHzMedian).toBeGreaterThan(440); + expect(model.modes[0]!.frequencyHzMedian).toBeLessThan(441.1); + expect(model.modes[0]!.frequencyMadCents).toBeGreaterThanOrEqual(0); + + const same = compareFingerprintToObjectModel(fp([440.5, 880.5, 1321]), model); + const different = compareFingerprintToObjectModel(fp([610, 1220, 1830]), model); + expect(same.evidenceScore).toBeGreaterThan(different.evidenceScore); + expect(same.coverage).toBe(1); + }); + + it("retrieves a query against specimen-level object models and reports ranking metrics", () => { + const index = buildSonicTwinIndex(observations()); + expect(index.entries).toHaveLength(3); + const ranked = retrieveSonicTwin(fp([440.8, 881, 1321.5]), index); + expect(ranked[0]?.specimenId).toBe("specimen-a"); + expect(ranked[0]?.rank).toBe(1); + + const metrics = benchmarkSonicTwinRetrieval([ + { queryId: "qa", trueSpecimenId: "specimen-a", fingerprint: fp([440.7, 881, 1321]) }, + { queryId: "qb", trueSpecimenId: "specimen-b", fingerprint: fp([610.5, 1220.5, 1831]) }, + ], index); + expect(metrics.recallAt1).toBe(1); + expect(metrics.meanReciprocalRank).toBe(1); + expect(metrics.missingTrueSpecimenCount).toBe(0); + }); + + it("validates a provenance-aware specimen-disjoint benchmark corpus and constructs labeled pairs", () => { + const data = observations(); + const corpus: TwinBenchmarkCorpusV1 = { + schemaVersion: 1, + corpusContractVersion: "sonic-twin-benchmark-corpus-1", + corpusId: "digital-full-vision-corpus-1", + createdAt: "2026-08-24T15:30:00.000Z", + observations: data.map((observation, index) => ({ + ...observation, + objectFamily: observation.specimenId === "specimen-b" ? "bowl" : "bell", + source: "digital-twin" as const, + nuisance: { ...nuisance, strikeLocation: index % 2 === 0 ? "A" : "B" }, + })), + }; + const validation = validateTwinBenchmarkCorpus(corpus); + expect(validation.valid).toBe(true); + expect(validation.specimenCount).toBe(3); + expect(validation.sourceCounts["digital-twin"]).toBe(6); + + const pairs = buildTwinVerificationPairs(corpus, 20); + expect(pairs.some((pair) => pair.samePhysicalSpecimen)).toBe(true); + expect(pairs.every((pair) => pair.split === pairs.find((candidate) => candidate.pairId === pair.pairId)?.split)).toBe(true); + for (const pair of pairs) { + if (pair.samePhysicalSpecimen) expect(pair.leftSpecimenId).toBe(pair.rightSpecimenId); + else expect(pair.leftSpecimenId).not.toBe(pair.rightSpecimenId); + } + }); + + it("fits a data-bound monotonic calibration model and reports calibration/risk metrics", () => { + const training = [ + { pairId: "n1", score: 0.05, samePhysicalSpecimen: false }, + { pairId: "n2", score: 0.15, samePhysicalSpecimen: false }, + { pairId: "n3", score: 0.35, samePhysicalSpecimen: false }, + { pairId: "p1", score: 0.65, samePhysicalSpecimen: true }, + { pairId: "p2", score: 0.85, samePhysicalSpecimen: true }, + { pairId: "p3", score: 0.95, samePhysicalSpecimen: true }, + ]; + const model = fitIsotonicCalibration(training, "digital-twin-corpus-1"); + expect(model.trainingSampleCount).toBe(6); + expect(predictCalibratedProbability(model, 0.9)).toBeGreaterThan(predictCalibratedProbability(model, 0.1)); + for (let index = 1; index < model.bins.length; index += 1) { + expect(model.bins[index]!.probability).toBeGreaterThanOrEqual(model.bins[index - 1]!.probability); + } + + const predictions = calibratedPredictions(model, training); + const metrics = evaluateCalibration(predictions, 5); + expect(metrics.brierScore).not.toBeNull(); + expect(metrics.rocAuc).toBe(1); + const curve = riskCoverageCurve(predictions, [0, 0.25, 0.49]); + expect(curve).toHaveLength(3); + expect(curve[0]!.coverage).toBe(1); + expect(curve[2]!.coverage).toBeLessThanOrEqual(curve[0]!.coverage); + }); +}); diff --git a/packages/fingerprint/test/material-research.test.ts b/packages/fingerprint/test/material-research.test.ts new file mode 100644 index 00000000..9709de74 --- /dev/null +++ b/packages/fingerprint/test/material-research.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; +import type { AcousticFingerprintV1 } from "@everything-rings/dsp"; +import { benchmarkMaterialResearch, buildAcousticObjectModel, fitMaterialCentroidResearch, predictMaterialResearch } from "../src"; + +function fp(base: number, ratios: readonly number[], decays: readonly number[]): AcousticFingerprintV1 { + return { + version: 1, + algorithmVersion: "er-dsp-2", + sampleRate: 48_000, + durationSeconds: 2, + modes: ratios.map((ratio, index) => ({ + frequencyHz: base * ratio, + relativeAmplitude: 1 / (index + 1), + decaySeconds: decays[index] ?? 0.4, + q: 100, + confidence: 0.9, + diagnostics: { prominenceDb: 20, persistenceSeconds: 0.2, frequencyStdCents: 2, decayFitScore: 0.95, observationCount: 12 }, + })), + }; +} + +function model(specimenId: string, fingerprints: readonly AcousticFingerprintV1[]) { + return buildAcousticObjectModel(fingerprints.map((fingerprint, index) => ({ observationId: `${specimenId}-${index}`, specimenId, fingerprint }))); +} + +describe("research-only material inference", () => { + it("learns population-bound modal centroids and abstains when evidence margin is weak", () => { + const metalA = model("metal-a", [fp(440, [1, 2.05, 3.2], [1.1, 0.8, 0.6]), fp(442, [1, 2.04, 3.19], [1.05, 0.78, 0.58])]); + const metalB = model("metal-b", [fp(500, [1, 2.02, 3.15], [1.0, 0.75, 0.55]), fp(502, [1, 2.03, 3.16], [1.02, 0.77, 0.56])]); + const glassA = model("glass-a", [fp(600, [1, 2.45, 4.1], [1.8, 1.3, 1.0]), fp(602, [1, 2.44, 4.08], [1.75, 1.28, 0.98])]); + const glassB = model("glass-b", [fp(670, [1, 2.48, 4.12], [1.7, 1.25, 0.95]), fp(672, [1, 2.47, 4.11], [1.72, 1.27, 0.96])]); + const research = fitMaterialCentroidResearch([ + { exampleId: "ma", materialLabel: "metal", model: metalA }, + { exampleId: "mb", materialLabel: "metal", model: metalB }, + { exampleId: "ga", materialLabel: "glass", model: glassA }, + { exampleId: "gb", materialLabel: "glass", model: glassB }, + ], "digital-material-population-1"); + expect(research.evidenceEligible).toBe(false); + expect(research.centroids).toHaveLength(2); + + const metalQuery = model("metal-q", [fp(550, [1, 2.04, 3.18], [1.03, 0.77, 0.57]), fp(552, [1, 2.03, 3.17], [1.01, 0.76, 0.56])]); + const prediction = predictMaterialResearch(research, metalQuery, 0.005); + expect(prediction.decision).toBe("label"); + expect(prediction.materialLabel).toBe("metal"); + expect(prediction.calibratedProbability).toBeNull(); + expect(prediction.trainingPopulation).toBe("digital-material-population-1"); + + const ambiguous = predictMaterialResearch(research, metalQuery, 10); + expect(ambiguous.decision).toBe("abstain"); + expect(ambiguous.materialLabel).toBeNull(); + }); + + it("reports coverage and covered accuracy without turning software labels into a physical claim", () => { + const metal = model("metal", [fp(440, [1, 2, 3], [1, 0.8, 0.6]), fp(441, [1, 2, 3], [1, 0.8, 0.6])]); + const glass = model("glass", [fp(600, [1, 2.5, 4], [1.8, 1.3, 1]), fp(601, [1, 2.5, 4], [1.8, 1.3, 1])]); + const fitted = fitMaterialCentroidResearch([ + { exampleId: "m", materialLabel: "metal", model: metal }, + { exampleId: "g", materialLabel: "glass", model: glass }, + ], "digital-two-class-1"); + const report = benchmarkMaterialResearch(fitted, [ + { exampleId: "mt", trueMaterialLabel: "metal", model: metal }, + { exampleId: "gt", trueMaterialLabel: "glass", model: glass }, + ], 0); + expect(report.coverage).toBe(1); + expect(report.coveredAccuracy).toBe(1); + expect(report.errors).toHaveLength(0); + }); +}); diff --git a/packages/fingerprint/test/modal-field.test.ts b/packages/fingerprint/test/modal-field.test.ts new file mode 100644 index 00000000..6375bede --- /dev/null +++ b/packages/fingerprint/test/modal-field.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import type { AcousticFingerprintV1 } from "@everything-rings/dsp"; +import { buildSpatialModalSoundField, fingerprintAtSpatialPoint } from "../src"; + +function fp(amplitudes: readonly number[]): AcousticFingerprintV1 { + const frequencies = [440, 880, 1320]; + return { + version: 1, + algorithmVersion: "er-dsp-2", + sampleRate: 48_000, + durationSeconds: 2, + modes: frequencies.map((frequencyHz, index) => ({ + frequencyHz: frequencyHz + index * 0.2, + relativeAmplitude: amplitudes[index] ?? 0.1, + decaySeconds: 0.8 / (index + 1), + q: 100, + confidence: 0.9, + diagnostics: { prominenceDb: 20, persistenceSeconds: 0.2, frequencyStdCents: 2, decayFitScore: 0.95, observationCount: 12 }, + })), + }; +} + +describe("spatial modal sound field", () => { + it("preserves modal structure while keeping spatial predictions outside measurement evidence", () => { + const field = buildSpatialModalSoundField([ + { observationId: "left", specimenId: "specimen-field", strikePoint: { x: 0, y: 0, z: 0 }, fingerprint: fp([1, 0.2, 0.1]) }, + { observationId: "right", specimenId: "specimen-field", strikePoint: { x: 1, y: 0, z: 0 }, fingerprint: fp([0.1, 1, 0.4]) }, + { observationId: "top", specimenId: "specimen-field", strikePoint: { x: 0.5, y: 1, z: 0 }, fingerprint: fp([0.3, 0.4, 1]) }, + ]); + expect(field.modes).toHaveLength(3); + expect(field.objectModel.observationCount).toBe(3); + + const left = fingerprintAtSpatialPoint(field, { point: { x: 0, y: 0, z: 0 } }); + const right = fingerprintAtSpatialPoint(field, { point: { x: 1, y: 0, z: 0 } }); + const center = fingerprintAtSpatialPoint(field, { point: { x: 0.5, y: 0.4, z: 0 } }); + expect(left.evidenceEligible).toBe(false); + expect(left.predictionContractVersion).toBe("spatial-predicted-fingerprint-1"); + expect(left.sourceFingerprintAlgorithmVersions).toContain("er-dsp-2"); + expect("algorithmVersion" in left).toBe(false); + expect(left.modes[0]!.relativeAmplitude).toBe(1); + expect(right.modes[1]!.relativeAmplitude).toBe(1); + expect(center.modes.every((mode) => Number.isFinite(mode.relativeAmplitude))).toBe(true); + expect(center.modes.map((mode) => mode.frequencyHz)).toEqual(left.modes.map((mode) => mode.frequencyHz)); + }); + + it("rejects mixed specimen identity in one field", () => { + expect(() => buildSpatialModalSoundField([ + { observationId: "a", specimenId: "a", strikePoint: { x: 0, y: 0, z: 0 }, fingerprint: fp([1, 1, 1]) }, + { observationId: "b", specimenId: "b", strikePoint: { x: 1, y: 0, z: 0 }, fingerprint: fp([1, 1, 1]) }, + ])).toThrow(/mix specimen IDs/); + }); +}); diff --git a/packages/fingerprint/test/nuisance.test.ts b/packages/fingerprint/test/nuisance.test.ts new file mode 100644 index 00000000..8fff4389 --- /dev/null +++ b/packages/fingerprint/test/nuisance.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; +import type { AcousticFingerprintV1 } from "@everything-rings/dsp"; +import { + characterizeNuisance, + selectHardNegativePairs, + specimenDisjointSplit, + type LabeledFingerprintObservationV1, + type NuisanceMetadataV1, +} from "../src"; + +function fp(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.8 / (index + 1), + q: 100, + confidence: 0.9, + diagnostics: { prominenceDb: 20, persistenceSeconds: 0.2, frequencyStdCents: 2, decayFitScore: 0.95, observationCount: 12 }, + })), + }; +} + +const baseNuisance: NuisanceMetadataV1 = { + strikeLocation: "A", + striker: "wood", + support: "felt", + microphoneDistanceClass: "20cm", + room: "room-1", + stationId: "station-1", + operatorId: "operator-1", + dayId: "day-1", +}; + +function observation(id: string, specimenId: string, frequencies: readonly number[], nuisance: NuisanceMetadataV1): LabeledFingerprintObservationV1 { + return { observationId: id, specimenId, fingerprint: fp(frequencies), nuisance }; +} + +describe("Sonic Twin nuisance benchmark utilities", () => { + it("isolates one-factor repeated-measurement drift", () => { + const data = [ + observation("a1", "specimen-a", [440, 880, 1320], baseNuisance), + observation("a2", "specimen-a", [441, 882, 1323], { ...baseNuisance, strikeLocation: "B" }), + observation("a3", "specimen-a", [442, 884, 1326], { ...baseNuisance, stationId: "station-2" }), + ]; + const report = characterizeNuisance(data); + expect(report.specimenCount).toBe(1); + expect(report.withinSpecimenPairs).toHaveLength(3); + expect(report.factorSummaries.find((summary) => summary.factor === "strikeLocation")?.comparisonCount).toBe(1); + expect(report.factorSummaries.find((summary) => summary.factor === "stationId")?.comparisonCount).toBe(1); + }); + + it("selects the acoustically closest different-specimen observations as hard negatives", () => { + const data = [ + observation("a", "specimen-a", [440, 880, 1320], baseNuisance), + observation("b", "specimen-b", [441, 882, 1323], baseNuisance), + observation("c", "specimen-c", [600, 1200, 1800], baseNuisance), + ]; + const hard = selectHardNegativePairs(data, 1); + expect(hard).toHaveLength(1); + expect(new Set([hard[0]?.leftSpecimenId, hard[0]?.rightSpecimenId])).toEqual(new Set(["specimen-a", "specimen-b"])); + }); + + it("assigns every capture of a specimen to the same deterministic benchmark split", () => { + const first = specimenDisjointSplit("Specimen-001"); + expect(first).toBe(specimenDisjointSplit(" specimen-001 ")); + expect(["train", "validation", "test"]).toContain(first); + }); +}); diff --git a/packages/fingerprint/test/promotion.test.ts b/packages/fingerprint/test/promotion.test.ts new file mode 100644 index 00000000..b4bfd152 --- /dev/null +++ b/packages/fingerprint/test/promotion.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { evaluateSimilarityAlgorithmPromotion } from "../src"; + +const baseline = { + algorithmVersion: "baseline-1", + evaluationPopulation: "digital-heldout-1", + specimenDisjoint: true, + queryCount: 100, + recallAt1: 0.80, + meanReciprocalRank: 0.86, + rocAuc: 0.90, + brierScore: 0.16, + coverage: 0.8, +}; + +describe("similarity algorithm promotion governance", () => { + it("promotes only a held-out candidate with a real primary improvement and no prohibited regression", () => { + const result = evaluateSimilarityAlgorithmPromotion(baseline, { + ...baseline, + algorithmVersion: "candidate-2", + recallAt1: 0.84, + meanReciprocalRank: 0.88, + rocAuc: 0.92, + brierScore: 0.14, + }); + expect(result.promoted).toBe(true); + expect(result.improvements.recallAt1).toBeCloseTo(0.04); + }); + + it("rejects evaluation leakage and a candidate that merely changes its version name", () => { + const leakage = evaluateSimilarityAlgorithmPromotion(baseline, { ...baseline, algorithmVersion: "candidate-leaky", specimenDisjoint: false, recallAt1: 0.95 }); + expect(leakage.promoted).toBe(false); + expect(leakage.reasons.some((reason) => reason.includes("specimen-disjoint"))).toBe(true); + + const noImprovement = evaluateSimilarityAlgorithmPromotion(baseline, { ...baseline, algorithmVersion: "candidate-same" }); + expect(noImprovement.promoted).toBe(false); + expect(noImprovement.reasons.some((reason) => reason.includes("does not improve"))).toBe(true); + }); +}); diff --git a/packages/fingerprint/test/sonic-twin.test.ts b/packages/fingerprint/test/sonic-twin.test.ts new file mode 100644 index 00000000..bd3e27e3 --- /dev/null +++ b/packages/fingerprint/test/sonic-twin.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; +import type { AcousticFingerprintV1 } from "@everything-rings/dsp"; +import { benchmarkSonicTwinBaseline, compareSonicTwinBaseline } from "../src"; + +function fingerprint( + frequencies: readonly number[], + decayScale = 1, + amplitudeScale = 1, +): AcousticFingerprintV1 { + return { + version: 1, + algorithmVersion: "er-dsp-2", + sampleRate: 48_000, + durationSeconds: 2, + modes: frequencies.map((frequencyHz, index) => ({ + frequencyHz, + relativeAmplitude: Math.min(1, amplitudeScale / (index + 1)), + decaySeconds: decayScale * (0.8 / (index + 1)), + q: 100, + confidence: 0.9, + diagnostics: { + prominenceDb: 20, + persistenceSeconds: 0.2, + frequencyStdCents: 2, + decayFitScore: 0.95, + observationCount: 12, + }, + })), + }; +} + +describe("Sonic Twin deterministic baseline", () => { + const base = fingerprint([440, 880, 1320, 1760]); + + it("matches close repeated measurements without emitting a probability", () => { + const repeated = fingerprint([441, 882, 1323, 1764], 1.05, 0.95); + const result = compareSonicTwinBaseline(base, repeated); + expect(result.decision).toBe("match"); + expect(result.matchedModes).toBeGreaterThanOrEqual(3); + expect(result.calibratedProbability).toBeNull(); + }); + + it("rejects clearly separated hard negatives", () => { + const other = fingerprint([520, 1040, 1560, 2080]); + const result = compareSonicTwinBaseline(base, other); + expect(result.decision).toBe("nonmatch"); + expect(result.calibratedProbability).toBeNull(); + }); + + it("abstains in the ambiguous region", () => { + const ambiguous = fingerprint([455, 910, 1365, 1820], 1.7, 0.3); + const result = compareSonicTwinBaseline(base, ambiguous); + expect(result.decision).toBe("abstain"); + }); + + it("reports coverage separately from verification error rates", () => { + const metrics = benchmarkSonicTwinBaseline([ + { samePhysicalSpecimen: true, reference: base, candidate: fingerprint([441, 882, 1323, 1764], 1.02, 0.98) }, + { samePhysicalSpecimen: false, reference: base, candidate: fingerprint([530, 1060, 1590, 2120]) }, + { samePhysicalSpecimen: true, reference: base, candidate: fingerprint([455, 910, 1365, 1820], 1.8, 0.25) }, + ]); + expect(metrics.pairCount).toBe(3); + expect(metrics.coveredPairCount).toBe(2); + expect(metrics.abstainedPairCount).toBe(1); + expect(metrics.truePositiveRate).toBe(1); + expect(metrics.falsePositiveRate).toBe(0); + }); +}); diff --git a/packages/validation/src/artifact-envelope.ts b/packages/validation/src/artifact-envelope.ts new file mode 100644 index 00000000..5a443883 --- /dev/null +++ b/packages/validation/src/artifact-envelope.ts @@ -0,0 +1,128 @@ +import { contentDigest } from "./provenance"; + +export type EverythingRingsArtifactKind = + | "measurement" + | "derivation" + | "atlas-record" + | "atlas-snapshot" + | "atlas-collection" + | "station-calibration-protocol" + | "station-calibration-verdict" + | "sonic-twin-benchmark" + | "software-qualification"; + +export interface ArtifactEnvelopeV1 { + readonly schemaVersion: 1; + readonly envelopeContractVersion: "everything-rings-artifact-envelope-1"; + readonly artifactKind: EverythingRingsArtifactKind; + readonly artifactContractVersion: string; + readonly createdAt: string; + readonly payloadDigest: string; + readonly envelopeId: string; + readonly payload: T; +} + +export async function createArtifactEnvelope( + artifactKind: EverythingRingsArtifactKind, + artifactContractVersion: string, + payload: T, + createdAt: string, +): Promise> { + if (artifactContractVersion.trim().length === 0) throw new Error("artifactContractVersion is required"); + if (!Number.isFinite(Date.parse(createdAt))) throw new Error("artifact envelope createdAt is invalid"); + const payloadDigest = await contentDigest(payload); + const identity = { + schemaVersion: 1 as const, + envelopeContractVersion: "everything-rings-artifact-envelope-1" as const, + artifactKind, + artifactContractVersion: artifactContractVersion.trim(), + createdAt, + payloadDigest, + }; + const envelopeId = await contentDigest(identity); + return { ...identity, envelopeId, payload }; +} + +export async function verifyArtifactEnvelope(envelope: ArtifactEnvelopeV1): Promise { + if (envelope.schemaVersion !== 1 || envelope.envelopeContractVersion !== "everything-rings-artifact-envelope-1") return false; + if (!/^sha256:[0-9a-f]{64}$/.test(envelope.payloadDigest) || !/^sha256:[0-9a-f]{64}$/.test(envelope.envelopeId)) return false; + if (await contentDigest(envelope.payload) !== envelope.payloadDigest) return false; + const identity = { + schemaVersion: envelope.schemaVersion, + envelopeContractVersion: envelope.envelopeContractVersion, + artifactKind: envelope.artifactKind, + artifactContractVersion: envelope.artifactContractVersion, + createdAt: envelope.createdAt, + payloadDigest: envelope.payloadDigest, + }; + return envelope.envelopeId === await contentDigest(identity); +} + +export type ArtifactEnvelopeParseResult = + | { readonly ok: true; readonly envelope: ArtifactEnvelopeV1 } + | { readonly ok: false; readonly error: string }; + +const KINDS = new Set([ + "measurement", "derivation", "atlas-record", "atlas-snapshot", "atlas-collection", + "station-calibration-protocol", "station-calibration-verdict", "sonic-twin-benchmark", "software-qualification", +]); + +export async function parseArtifactEnvelope(value: unknown): Promise { + try { + if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("artifact envelope must be an object"); + const record = value as Record; + if (record.schemaVersion !== 1) throw new Error("artifact envelope schemaVersion must be 1"); + if (record.envelopeContractVersion !== "everything-rings-artifact-envelope-1") throw new Error("artifact envelope contract mismatch"); + if (typeof record.artifactKind !== "string" || !KINDS.has(record.artifactKind as EverythingRingsArtifactKind)) throw new Error("artifactKind is invalid"); + if (typeof record.artifactContractVersion !== "string" || record.artifactContractVersion.trim().length === 0) throw new Error("artifactContractVersion is required"); + if (typeof record.createdAt !== "string" || !Number.isFinite(Date.parse(record.createdAt))) throw new Error("createdAt is invalid"); + if (typeof record.payloadDigest !== "string" || typeof record.envelopeId !== "string") throw new Error("artifact envelope digests are required"); + const envelope = record as unknown as ArtifactEnvelopeV1; + if (!await verifyArtifactEnvelope(envelope)) throw new Error("artifact envelope content digest verification failed"); + return { ok: true, envelope }; + } catch (error) { + return { ok: false, error: error instanceof Error ? error.message : String(error) }; + } +} + +export async function parseArtifactEnvelopeJson(json: string): Promise { + try { + return parseArtifactEnvelope(JSON.parse(json) as unknown); + } catch (error) { + return { ok: false, error: `invalid JSON: ${error instanceof Error ? error.message : String(error)}` }; + } +} + +export interface ArtifactMigrationStepV1 { + readonly artifactKind: EverythingRingsArtifactKind; + readonly fromContractVersion: string; + readonly toContractVersion: string; + readonly migrate: (payload: unknown) => unknown; +} + +export class ArtifactMigrationRegistryV1 { + private readonly steps = new Map(); + + register(step: ArtifactMigrationStepV1): void { + if (step.fromContractVersion === step.toContractVersion) throw new Error("migration must change contract version"); + const key = `${step.artifactKind}\u0000${step.fromContractVersion}`; + if (this.steps.has(key)) throw new Error(`duplicate migration from ${step.artifactKind}/${step.fromContractVersion}`); + this.steps.set(key, step); + } + + migrate(artifactKind: EverythingRingsArtifactKind, fromVersion: string, targetVersion: string, payload: unknown): unknown { + let currentVersion = fromVersion; + let currentPayload = payload; + const visited = new Set(); + while (currentVersion !== targetVersion) { + const marker = `${artifactKind}\u0000${currentVersion}`; + if (visited.has(marker)) throw new Error("artifact migration cycle detected"); + visited.add(marker); + const step = this.steps.get(marker); + if (step === undefined) throw new Error(`no migration path from ${artifactKind}/${currentVersion} to ${targetVersion}`); + currentPayload = step.migrate(currentPayload); + currentVersion = step.toContractVersion; + } + return currentPayload; + } +} diff --git a/packages/validation/src/assurance.ts b/packages/validation/src/assurance.ts new file mode 100644 index 00000000..59ac15e6 --- /dev/null +++ b/packages/validation/src/assurance.ts @@ -0,0 +1,123 @@ +import { contentDigest } from "./provenance"; + +export type EvidenceDomain = "software" | "digital-twin" | "external-dataset" | "physical" | "human-perceptual" | "playback-transducer"; + +export interface AssuranceEvidenceReferenceV1 { + readonly evidenceId: string; + readonly domain: EvidenceDomain; + readonly description: string; +} + +export interface CapabilityClaimV1 { + readonly schemaVersion: 1; + readonly claimContractVersion: "capability-claim-1"; + readonly claimId: string; + readonly createdAt: string; + readonly capability: string; + readonly proposition: string; + readonly scope: string; + readonly exclusions: readonly string[]; + readonly evidence: readonly AssuranceEvidenceReferenceV1[]; + readonly maturity: "implemented" | "software-qualified" | "empirically-qualified"; +} + +function isContentDigest(id: string): boolean { + return /^sha256:[0-9a-f]{64}$/.test(id); +} + +function assertEvidenceId(id: string): void { + if (!isContentDigest(id) && !/^[0-9a-f]{40}$/.test(id) && !/^github-run:\d+$/.test(id)) { + throw new Error("assurance evidenceId must be a SHA-256 digest, Git revision, or github-run:"); + } +} + +export async function createCapabilityClaim( + input: Omit, +): Promise { + if (!Number.isFinite(Date.parse(input.createdAt))) throw new Error("capability claim createdAt is invalid"); + if (input.capability.trim().length === 0 || input.proposition.trim().length === 0 || input.scope.trim().length === 0) throw new Error("capability claim requires capability, proposition, and scope"); + if (input.evidence.length === 0) throw new Error("capability claim requires evidence references"); + for (const reference of input.evidence) { + assertEvidenceId(reference.evidenceId); + if (reference.description.trim().length === 0) throw new Error("assurance evidence description is required"); + } + if (input.maturity === "software-qualified" && input.evidence.every((reference) => reference.domain !== "software" && reference.domain !== "digital-twin")) { + throw new Error("software-qualified claim requires software or digital-twin evidence"); + } + if (input.maturity === "empirically-qualified" && !input.evidence.some((reference) => reference.domain === "physical" || reference.domain === "human-perceptual" || reference.domain === "playback-transducer")) { + throw new Error("empirically-qualified claim requires empirical-domain evidence"); + } + const payload = { + ...input, + exclusions: [...input.exclusions], + evidence: [...input.evidence], + schemaVersion: 1 as const, + claimContractVersion: "capability-claim-1" as const, + }; + return { ...payload, claimId: await contentDigest(payload) }; +} + +export async function verifyCapabilityClaim(claim: CapabilityClaimV1): Promise { + if (claim.schemaVersion !== 1 || claim.claimContractVersion !== "capability-claim-1") return false; + if (!isContentDigest(claim.claimId) || !Number.isFinite(Date.parse(claim.createdAt))) return false; + if (claim.capability.trim().length === 0 || claim.proposition.trim().length === 0 || claim.scope.trim().length === 0 || claim.evidence.length === 0) return false; + if (!claim.evidence.every((reference) => { + try { assertEvidenceId(reference.evidenceId); } catch { return false; } + return reference.description.trim().length > 0; + })) return false; + const { claimId, ...payload } = claim; + return claimId === await contentDigest(payload); +} + +export interface SoftwareQualificationManifestV1 { + readonly schemaVersion: 1; + readonly qualificationContractVersion: "full-vision-software-manifest-1"; + readonly manifestId: string; + readonly sourceRevision: string; + readonly createdAt: string; + readonly testRunIds: readonly string[]; + readonly capabilityClaimIds: readonly string[]; + readonly physicalObjectTested: false; + readonly humanPerceptualValidationPerformed: false; + readonly realPlaybackTransducerValidated: false; + readonly releaseGateEquivalent: false; +} + +export async function createSoftwareQualificationManifest( + input: Omit, +): Promise { + if (!/^[0-9a-f]{40}$/.test(input.sourceRevision)) throw new Error("software qualification sourceRevision must be exact 40-hex Git revision"); + if (!Number.isFinite(Date.parse(input.createdAt))) throw new Error("software qualification createdAt is invalid"); + if (input.testRunIds.length === 0) throw new Error("software qualification requires at least one test run"); + if (input.capabilityClaimIds.length === 0) throw new Error("software qualification requires at least one capability claim"); + input.testRunIds.forEach((id) => { if (!/^github-run:\d+$/.test(id)) throw new Error("testRunIds must use github-run:"); }); + input.capabilityClaimIds.forEach((id) => { if (!isContentDigest(id)) throw new Error("capabilityClaimIds must be SHA-256 claim digests"); }); + const testRunIds = [...new Set(input.testRunIds)].sort(); + const capabilityClaimIds = [...new Set(input.capabilityClaimIds)].sort(); + if (testRunIds.length !== input.testRunIds.length) throw new Error("software qualification testRunIds must be unique"); + if (capabilityClaimIds.length !== input.capabilityClaimIds.length) throw new Error("software qualification capabilityClaimIds must be unique"); + const payload = { + ...input, + testRunIds, + capabilityClaimIds, + schemaVersion: 1 as const, + qualificationContractVersion: "full-vision-software-manifest-1" as const, + physicalObjectTested: false as const, + humanPerceptualValidationPerformed: false as const, + realPlaybackTransducerValidated: false as const, + releaseGateEquivalent: false as const, + }; + return { ...payload, manifestId: await contentDigest(payload) }; +} + +export async function verifySoftwareQualificationManifest(manifest: SoftwareQualificationManifestV1): Promise { + if (manifest.schemaVersion !== 1 || manifest.qualificationContractVersion !== "full-vision-software-manifest-1") return false; + if (!isContentDigest(manifest.manifestId) || !/^[0-9a-f]{40}$/.test(manifest.sourceRevision)) return false; + if (!Number.isFinite(Date.parse(manifest.createdAt)) || manifest.testRunIds.length === 0 || manifest.capabilityClaimIds.length === 0) return false; + if (!manifest.testRunIds.every((id) => /^github-run:\d+$/.test(id))) return false; + if (!manifest.capabilityClaimIds.every(isContentDigest)) return false; + if (new Set(manifest.testRunIds).size !== manifest.testRunIds.length || new Set(manifest.capabilityClaimIds).size !== manifest.capabilityClaimIds.length) return false; + if (manifest.physicalObjectTested !== false || manifest.humanPerceptualValidationPerformed !== false || manifest.realPlaybackTransducerValidated !== false || manifest.releaseGateEquivalent !== false) return false; + const { manifestId, ...payload } = manifest; + return manifestId === await contentDigest(payload); +} diff --git a/packages/validation/src/atlas-network.ts b/packages/validation/src/atlas-network.ts new file mode 100644 index 00000000..ae3450d4 --- /dev/null +++ b/packages/validation/src/atlas-network.ts @@ -0,0 +1,124 @@ +import { verifyAtlasSpecimenMerge, type AtlasRegistryStateV1 } from "./atlas-registry"; +import { contentDigest } from "./provenance"; + +export interface AtlasCollectionV1 { + readonly schemaVersion: 1; + readonly collectionContractVersion: "atlas-collection-1"; + readonly collectionId: string; + readonly createdAt: string; + readonly contributorId: string; + readonly title: string; + readonly description: string | null; + readonly atlasRecordIds: readonly string[]; + readonly visibility: "public"; +} + +function assertDigest(value: string, field: string): void { + if (!/^sha256:[0-9a-f]{64}$/.test(value)) throw new Error(`${field} must be a sha256 digest`); +} + +export async function createAtlasCollection( + input: Omit, +): Promise { + if (!Number.isFinite(Date.parse(input.createdAt))) throw new Error("Atlas collection createdAt is invalid"); + if (input.contributorId.trim().length === 0) throw new Error("Atlas collection contributorId is required"); + if (input.title.trim().length === 0) throw new Error("Atlas collection title is required"); + if (input.atlasRecordIds.length === 0) throw new Error("Atlas collection requires at least one record"); + const atlasRecordIds = [...new Set(input.atlasRecordIds)].sort(); + atlasRecordIds.forEach((id) => assertDigest(id, "atlasRecordId")); + const payload = { + ...input, + atlasRecordIds, + schemaVersion: 1 as const, + collectionContractVersion: "atlas-collection-1" as const, + visibility: "public" as const, + }; + return { ...payload, collectionId: await contentDigest(payload) }; +} + +export async function verifyAtlasCollection(collection: AtlasCollectionV1): Promise { + if (collection.schemaVersion !== 1 || collection.collectionContractVersion !== "atlas-collection-1" || collection.visibility !== "public") return false; + if (!/^sha256:[0-9a-f]{64}$/.test(collection.collectionId)) return false; + if (!Number.isFinite(Date.parse(collection.createdAt))) return false; + if (collection.contributorId.trim().length === 0 || collection.title.trim().length === 0 || collection.atlasRecordIds.length === 0) return false; + if (new Set(collection.atlasRecordIds).size !== collection.atlasRecordIds.length) return false; + if (!collection.atlasRecordIds.every((id) => /^sha256:[0-9a-f]{64}$/.test(id))) return false; + const { collectionId, ...payload } = collection; + return collectionId === await contentDigest(payload); +} + +export type AtlasGraphNodeKind = "record" | "specimen" | "contributor" | "collection"; +export type AtlasGraphEdgeKind = "describes" | "published-by" | "created-by" | "member-of" | "merged-into"; + +export interface AtlasGraphNodeV1 { + readonly id: string; + readonly kind: AtlasGraphNodeKind; + readonly label: string; +} + +export interface AtlasGraphEdgeV1 { + readonly from: string; + readonly to: string; + readonly kind: AtlasGraphEdgeKind; +} + +export interface AtlasGraphSnapshotV1 { + readonly schemaVersion: 1; + readonly graphContractVersion: "atlas-graph-1"; + readonly nodes: readonly AtlasGraphNodeV1[]; + readonly edges: readonly AtlasGraphEdgeV1[]; +} + +export async function buildAtlasGraph( + registry: AtlasRegistryStateV1, + collections: readonly AtlasCollectionV1[] = [], +): Promise { + const nodes = new Map(); + const edges: AtlasGraphEdgeV1[] = []; + const addNode = (node: AtlasGraphNodeV1) => { if (!nodes.has(node.id)) nodes.set(node.id, node); }; + + for (const record of registry.records) { + const recordNode = `record:${record.atlasRecordId}`; + const specimenNode = `specimen:${record.specimen.specimenId.trim().toLocaleLowerCase("en-US")}`; + addNode({ id: recordNode, kind: "record", label: record.specimen.label }); + addNode({ id: specimenNode, kind: "specimen", label: record.specimen.label }); + edges.push({ from: recordNode, to: specimenNode, kind: "describes" }); + if (record.contributor !== null) { + const contributorNode = `contributor:${record.contributor.contributorId.trim().toLocaleLowerCase("en-US")}`; + addNode({ id: contributorNode, kind: "contributor", label: record.contributor.displayName ?? record.contributor.contributorId }); + edges.push({ from: recordNode, to: contributorNode, kind: "published-by" }); + } + } + + const collectionIds = new Set(); + for (const collection of collections) { + if (!await verifyAtlasCollection(collection)) throw new Error(`Atlas collection ${collection.collectionId} failed content verification`); + if (collectionIds.has(collection.collectionId)) throw new Error(`duplicate Atlas collection ${collection.collectionId}`); + collectionIds.add(collection.collectionId); + const collectionNode = `collection:${collection.collectionId}`; + addNode({ id: collectionNode, kind: "collection", label: collection.title }); + const contributorNode = `contributor:${collection.contributorId.trim().toLocaleLowerCase("en-US")}`; + addNode({ id: contributorNode, kind: "contributor", label: collection.contributorId }); + edges.push({ from: collectionNode, to: contributorNode, kind: "created-by" }); + for (const recordId of collection.atlasRecordIds) { + const recordNode = `record:${recordId}`; + if (!nodes.has(recordNode)) throw new Error(`Atlas collection references unknown record ${recordId}`); + edges.push({ from: recordNode, to: collectionNode, kind: "member-of" }); + } + } + + for (const merge of registry.specimenMerges) { + if (!await verifyAtlasSpecimenMerge(merge)) throw new Error(`Atlas merge ${merge.mergeId} failed content verification`); + const alias = `specimen:${merge.aliasSpecimenId.trim().toLocaleLowerCase("en-US")}`; + const canonical = `specimen:${merge.canonicalSpecimenId.trim().toLocaleLowerCase("en-US")}`; + if (!nodes.has(alias) || !nodes.has(canonical)) throw new Error("Atlas merge graph refers to unknown specimen node"); + edges.push({ from: alias, to: canonical, kind: "merged-into" }); + } + + return { + schemaVersion: 1, + graphContractVersion: "atlas-graph-1", + nodes: [...nodes.values()].sort((left, right) => left.id.localeCompare(right.id)), + edges: edges.sort((left, right) => `${left.from}|${left.kind}|${left.to}`.localeCompare(`${right.from}|${right.kind}|${right.to}`)), + }; +} diff --git a/packages/validation/src/atlas-registry.ts b/packages/validation/src/atlas-registry.ts new file mode 100644 index 00000000..5d299f0a --- /dev/null +++ b/packages/validation/src/atlas-registry.ts @@ -0,0 +1,185 @@ +import { + createAtlasSnapshot, + verifyAtlasRecord, + type ResonanceAtlasRecordV1, + type ResonanceAtlasSnapshotV1, +} from "./atlas"; +import { contentDigest } from "./provenance"; + +export interface AtlasSpecimenMergeV1 { + readonly schemaVersion: 1; + readonly mergeContractVersion: "atlas-specimen-merge-1"; + readonly mergeId: string; + readonly createdAt: string; + readonly aliasSpecimenId: string; + readonly canonicalSpecimenId: string; + readonly rationale: string; + readonly supportingRecordIds: readonly string[]; +} + +export interface AtlasRegistryStateV1 { + readonly schemaVersion: 1; + readonly registryContractVersion: "resonance-atlas-registry-1"; + readonly records: readonly ResonanceAtlasRecordV1[]; + readonly specimenMerges: readonly AtlasSpecimenMergeV1[]; +} + +export function emptyAtlasRegistry(): AtlasRegistryStateV1 { + return { schemaVersion: 1, registryContractVersion: "resonance-atlas-registry-1", records: [], specimenMerges: [] }; +} + +function normalized(value: string): string { + return value.trim().toLocaleLowerCase("en-US"); +} + +function assertDigest(value: string, field: string): void { + if (!/^sha256:[0-9a-f]{64}$/.test(value)) throw new Error(`${field} must be a sha256 content digest`); +} + +export async function publishAtlasRecord( + state: AtlasRegistryStateV1, + record: ResonanceAtlasRecordV1, +): Promise { + if (!await verifyAtlasRecord(record)) throw new Error("Atlas record failed content verification"); + if (state.records.some((existing) => existing.atlasRecordId === record.atlasRecordId)) return state; + + const measurementOwner = new Map(); + for (const existing of state.records) { + for (const measurement of existing.measurements) measurementOwner.set(measurement.measurementId, normalized(existing.specimen.specimenId)); + } + const incomingSpecimen = normalized(record.specimen.specimenId); + for (const measurement of record.measurements) { + const owner = measurementOwner.get(measurement.measurementId); + if (owner !== undefined && owner !== incomingSpecimen) { + throw new Error(`measurement ${measurement.measurementId} is already published under another specimen`); + } + } + + return { + ...state, + records: [...state.records, record].sort((left, right) => left.atlasRecordId.localeCompare(right.atlasRecordId)), + }; +} + +export async function createAtlasSpecimenMerge( + input: Omit, +): Promise { + if (!Number.isFinite(Date.parse(input.createdAt))) throw new Error("Atlas specimen merge createdAt is invalid"); + if (normalized(input.aliasSpecimenId).length === 0 || normalized(input.canonicalSpecimenId).length === 0) throw new Error("merge specimen IDs are required"); + if (normalized(input.aliasSpecimenId) === normalized(input.canonicalSpecimenId)) throw new Error("merge alias and canonical specimen must differ"); + if (input.rationale.trim().length === 0) throw new Error("merge rationale is required"); + if (input.supportingRecordIds.length === 0) throw new Error("merge requires supporting Atlas records"); + const supportingRecordIds = [...new Set(input.supportingRecordIds)].sort(); + supportingRecordIds.forEach((id) => assertDigest(id, "supportingRecordId")); + const payload = { + ...input, + supportingRecordIds, + schemaVersion: 1 as const, + mergeContractVersion: "atlas-specimen-merge-1" as const, + }; + return { ...payload, mergeId: await contentDigest(payload) }; +} + +export async function verifyAtlasSpecimenMerge(merge: AtlasSpecimenMergeV1): Promise { + if (merge.schemaVersion !== 1 || merge.mergeContractVersion !== "atlas-specimen-merge-1") return false; + if (!/^sha256:[0-9a-f]{64}$/.test(merge.mergeId)) return false; + if (!Number.isFinite(Date.parse(merge.createdAt))) return false; + if (normalized(merge.aliasSpecimenId).length === 0 || normalized(merge.canonicalSpecimenId).length === 0) return false; + if (normalized(merge.aliasSpecimenId) === normalized(merge.canonicalSpecimenId)) return false; + if (merge.rationale.trim().length === 0 || merge.supportingRecordIds.length === 0) return false; + if (new Set(merge.supportingRecordIds).size !== merge.supportingRecordIds.length) return false; + if (!merge.supportingRecordIds.every((id) => /^sha256:[0-9a-f]{64}$/.test(id))) return false; + const { mergeId, ...payload } = merge; + return mergeId === await contentDigest(payload); +} + +function resolveCanonicalSpecimenId(state: AtlasRegistryStateV1, specimenId: string): string { + let current = normalized(specimenId); + const visited = new Set(); + while (true) { + if (visited.has(current)) throw new Error("Atlas specimen merge cycle detected"); + visited.add(current); + const merge = state.specimenMerges.find((candidate) => normalized(candidate.aliasSpecimenId) === current); + if (merge === undefined) return current; + current = normalized(merge.canonicalSpecimenId); + } +} + +export async function applyAtlasSpecimenMerge( + state: AtlasRegistryStateV1, + merge: AtlasSpecimenMergeV1, +): Promise { + if (!await verifyAtlasSpecimenMerge(merge)) throw new Error("Atlas specimen merge failed content verification"); + if (state.specimenMerges.some((existing) => existing.mergeId === merge.mergeId)) return state; + if (state.specimenMerges.some((existing) => normalized(existing.aliasSpecimenId) === normalized(merge.aliasSpecimenId))) { + throw new Error("Atlas alias specimen already has a canonical merge"); + } + const records = new Set(state.records.map((record) => record.atlasRecordId)); + for (const recordId of merge.supportingRecordIds) { + if (!records.has(recordId)) throw new Error(`merge supporting record ${recordId} is absent from the registry`); + } + const knownSpecimens = new Set(state.records.map((record) => normalized(record.specimen.specimenId))); + if (!knownSpecimens.has(normalized(merge.aliasSpecimenId))) throw new Error("merge alias specimen is absent from the registry"); + if (!knownSpecimens.has(normalized(merge.canonicalSpecimenId))) throw new Error("merge canonical specimen is absent from the registry"); + const candidate = { ...state, specimenMerges: [...state.specimenMerges, merge].sort((left, right) => left.mergeId.localeCompare(right.mergeId)) }; + resolveCanonicalSpecimenId(candidate, merge.aliasSpecimenId); + return candidate; +} + +export interface AtlasSearchQueryV1 { + readonly text?: string; + readonly material?: string; + readonly objectFamily?: string; + readonly contributorId?: string; +} + +export interface AtlasSearchResultV1 { + readonly atlasRecordId: string; + readonly specimenId: string; + readonly canonicalSpecimenId: string; + readonly label: string; + readonly objectFamily: string; + readonly material: string; + readonly measurementCount: number; + readonly contributorId: string | null; +} + +export function searchAtlas( + state: AtlasRegistryStateV1, + query: AtlasSearchQueryV1, +): readonly AtlasSearchResultV1[] { + const text = normalized(query.text ?? ""); + const material = normalized(query.material ?? ""); + const family = normalized(query.objectFamily ?? ""); + const contributor = normalized(query.contributorId ?? ""); + return state.records + .filter((record) => text.length === 0 || normalized(`${record.specimen.label} ${record.specimen.publicDescription ?? ""} ${record.specimen.objectFamily}`).includes(text)) + .filter((record) => material.length === 0 || normalized(record.specimen.material) === material) + .filter((record) => family.length === 0 || normalized(record.specimen.objectFamily) === family) + .filter((record) => contributor.length === 0 || normalized(record.contributor?.contributorId ?? "") === contributor) + .map((record) => ({ + atlasRecordId: record.atlasRecordId, + specimenId: record.specimen.specimenId, + canonicalSpecimenId: resolveCanonicalSpecimenId(state, record.specimen.specimenId), + label: record.specimen.label, + objectFamily: record.specimen.objectFamily, + material: record.specimen.material, + measurementCount: record.measurements.length, + contributorId: record.contributor?.contributorId ?? null, + })) + .sort((left, right) => left.label.localeCompare(right.label) || left.atlasRecordId.localeCompare(right.atlasRecordId)); +} + +export async function snapshotAtlasRegistry( + state: AtlasRegistryStateV1, + createdAt: string, +): Promise { + if (state.records.length === 0) throw new Error("cannot snapshot an empty Atlas registry"); + for (const record of state.records) { + if (!await verifyAtlasRecord(record)) throw new Error(`cannot snapshot invalid Atlas record ${record.atlasRecordId}`); + } + for (const merge of state.specimenMerges) { + if (!await verifyAtlasSpecimenMerge(merge)) throw new Error(`cannot snapshot invalid Atlas merge ${merge.mergeId}`); + } + return createAtlasSnapshot(state.records.map((record) => record.atlasRecordId), createdAt); +} diff --git a/packages/validation/src/atlas-service.ts b/packages/validation/src/atlas-service.ts new file mode 100644 index 00000000..4b910250 --- /dev/null +++ b/packages/validation/src/atlas-service.ts @@ -0,0 +1,79 @@ +import type { ResonanceAtlasRecordV1, ResonanceAtlasSnapshotV1 } from "./atlas"; +import { + emptyAtlasRegistry, + publishAtlasRecord, + searchAtlas, + snapshotAtlasRegistry, + type AtlasRegistryStateV1, + type AtlasSearchQueryV1, + type AtlasSearchResultV1, +} from "./atlas-registry"; + +export interface ResonanceAtlasServiceV1 { + readonly serviceContractVersion: "resonance-atlas-service-1"; + publish(record: ResonanceAtlasRecordV1): Promise<{ readonly inserted: boolean; readonly atlasRecordId: string }>; + get(atlasRecordId: string): Promise; + search(query: AtlasSearchQueryV1): Promise; + snapshot(createdAt: string): Promise; + count(): Promise; +} + +/** + * Deterministic reference adapter for tests, local research tools, and contract + * conformance. A production network service can implement the same interface. + */ +export class MemoryResonanceAtlasServiceV1 implements ResonanceAtlasServiceV1 { + readonly serviceContractVersion = "resonance-atlas-service-1" as const; + private state: AtlasRegistryStateV1 = emptyAtlasRegistry(); + + async publish(record: ResonanceAtlasRecordV1): Promise<{ readonly inserted: boolean; readonly atlasRecordId: string }> { + const before = this.state.records.length; + this.state = await publishAtlasRecord(this.state, record); + return { inserted: this.state.records.length > before, atlasRecordId: record.atlasRecordId }; + } + + async get(atlasRecordId: string): Promise { + return this.state.records.find((record) => record.atlasRecordId === atlasRecordId) ?? null; + } + + async search(query: AtlasSearchQueryV1): Promise { + return searchAtlas(this.state, query); + } + + async snapshot(createdAt: string): Promise { + return snapshotAtlasRegistry(this.state, createdAt); + } + + async count(): Promise { + return this.state.records.length; + } + + exportState(): AtlasRegistryStateV1 { + return { + ...this.state, + records: [...this.state.records], + specimenMerges: [...this.state.specimenMerges], + }; + } +} + +export async function verifyAtlasServiceConformance( + service: ResonanceAtlasServiceV1, + records: readonly ResonanceAtlasRecordV1[], +): Promise<{ readonly passed: boolean; readonly reasons: readonly string[] }> { + const reasons: string[] = []; + for (const record of records) { + const first = await service.publish(record); + const second = await service.publish(record); + if (!first.inserted) reasons.push(`first publication of ${record.atlasRecordId} was not inserted`); + if (second.inserted) reasons.push(`duplicate publication of ${record.atlasRecordId} was not idempotent`); + const fetched = await service.get(record.atlasRecordId); + if (fetched?.atlasRecordId !== record.atlasRecordId) reasons.push(`record ${record.atlasRecordId} could not be read back`); + } + if (await service.count() !== new Set(records.map((record) => record.atlasRecordId)).size) reasons.push("service count disagrees with unique published records"); + if (records.length > 0) { + const snapshot = await service.snapshot("2026-08-24T16:59:00.000Z"); + if (snapshot.recordIds.length !== new Set(records.map((record) => record.atlasRecordId)).size) reasons.push("service snapshot omitted published records"); + } + return { passed: reasons.length === 0, reasons }; +} diff --git a/packages/validation/src/atlas.ts b/packages/validation/src/atlas.ts new file mode 100644 index 00000000..d5fbb292 --- /dev/null +++ b/packages/validation/src/atlas.ts @@ -0,0 +1,92 @@ +import type { MaterialClass } from "./types"; +import { contentDigest } from "./provenance"; + +export interface AtlasContributorV1 { + readonly contributorId: string; + readonly displayName: string | null; +} + +export interface AtlasSpecimenV1 { + readonly specimenId: string; + readonly label: string; + readonly objectFamily: string; + readonly material: MaterialClass | "unknown"; + readonly publicDescription: string | null; +} + +export interface AtlasMeasurementReferenceV1 { + readonly measurementId: string; + readonly derivationIds: readonly string[]; +} + +export interface ResonanceAtlasRecordV1 { + readonly schemaVersion: 1; + readonly atlasContractVersion: "resonance-atlas-record-1"; + readonly atlasRecordId: string; + readonly createdAt: string; + readonly contributor: AtlasContributorV1 | null; + readonly specimen: AtlasSpecimenV1; + readonly measurements: readonly AtlasMeasurementReferenceV1[]; + readonly rawMicrophoneSamplesIncluded: false; + readonly publicationConsent: true; +} + +export interface ResonanceAtlasSnapshotV1 { + readonly schemaVersion: 1; + readonly snapshotContractVersion: "resonance-atlas-snapshot-1"; + readonly createdAt: string; + readonly recordIds: readonly string[]; + readonly snapshotId: string; +} + +function assertDigest(value: string, field: string): void { + if (!/^sha256:[0-9a-f]{64}$/.test(value)) throw new Error(`${field} must be a sha256 content digest`); +} + +export async function createAtlasRecord( + input: Omit, +): Promise { + if (!Number.isFinite(Date.parse(input.createdAt))) throw new Error("Atlas record createdAt is invalid"); + if (input.specimen.specimenId.trim().length === 0) throw new Error("Atlas specimenId is required"); + if (input.specimen.label.trim().length === 0) throw new Error("Atlas specimen label is required"); + if (input.measurements.length === 0) throw new Error("Atlas record requires at least one measurement reference"); + const seen = new Set(); + for (const measurement of input.measurements) { + assertDigest(measurement.measurementId, "measurementId"); + if (seen.has(measurement.measurementId)) throw new Error("Atlas record contains a duplicate measurement reference"); + seen.add(measurement.measurementId); + measurement.derivationIds.forEach((digest) => assertDigest(digest, "derivationId")); + } + const payload = { + ...input, + schemaVersion: 1 as const, + atlasContractVersion: "resonance-atlas-record-1" as const, + rawMicrophoneSamplesIncluded: false as const, + publicationConsent: true as const, + }; + const atlasRecordId = await contentDigest(payload); + return { ...payload, atlasRecordId }; +} + +export async function createAtlasSnapshot( + recordIds: readonly string[], + createdAt: string, +): Promise { + if (!Number.isFinite(Date.parse(createdAt))) throw new Error("Atlas snapshot createdAt is invalid"); + if (recordIds.length === 0) throw new Error("Atlas snapshot requires at least one record"); + const canonicalIds = [...new Set(recordIds)].sort(); + canonicalIds.forEach((digest) => assertDigest(digest, "atlasRecordId")); + const payload = { + schemaVersion: 1 as const, + snapshotContractVersion: "resonance-atlas-snapshot-1" as const, + createdAt, + recordIds: canonicalIds, + }; + return { ...payload, snapshotId: await contentDigest(payload) }; +} + +export async function verifyAtlasRecord(record: ResonanceAtlasRecordV1): Promise { + const { atlasRecordId, ...payload } = record; + if (record.rawMicrophoneSamplesIncluded !== false || record.publicationConsent !== true) return false; + return atlasRecordId === await contentDigest(payload); +} diff --git a/packages/validation/src/canonical-release.ts b/packages/validation/src/canonical-release.ts new file mode 100644 index 00000000..e56580ce --- /dev/null +++ b/packages/validation/src/canonical-release.ts @@ -0,0 +1,74 @@ +import { buildReleaseVerdictForRevision } from "./current-release"; +import { evaluateCanonicalGateB, evaluateCanonicalGateC, type GateBPlanV1, type GateCPlanV1 } from "./review-plans"; +import type { EmpiricalCampaignProgress } from "./campaign"; +import type { GateBReview, GateCReview, ReleaseVerdict, ValidationEvidenceV5 } from "./types"; + +export interface CanonicalReleaseVerdictV1 { + readonly schemaVersion: 1; + readonly canonicalReleaseContractVersion: "canonical-release-1"; + readonly createdAt: string; + readonly authorizedSoftwareRevision: string; + readonly base: ReleaseVerdict; + readonly campaignAccounted: boolean; + readonly gateBPlanPresent: boolean; + readonly gateCPlanPresent: boolean; + readonly gateB: ReturnType | null; + readonly gateC: ReturnType | null; + readonly releaseReady: boolean; + readonly reasons: readonly string[]; +} + +export function buildCanonicalReleaseVerdictForRevision( + evidence: readonly ValidationEvidenceV5[], + gateBReviews: readonly GateBReview[], + gateCReviews: readonly GateCReview[], + createdAt: string, + expectedSoftwareRevision: string, + campaignProgress: EmpiricalCampaignProgress | undefined, + gateBPlan: GateBPlanV1 | undefined, + gateCPlan: GateCPlanV1 | undefined, +): CanonicalReleaseVerdictV1 { + const base = buildReleaseVerdictForRevision( + evidence, + gateBReviews, + gateCReviews, + createdAt, + expectedSoftwareRevision, + ); + const reasons: string[] = []; + const campaignAccounted = campaignProgress?.collectionComplete === true; + if (!campaignAccounted) reasons.push("the complete precommitted empirical campaign is not accounted for"); + if (!base.gateA.passed) reasons.push("Gate A2 has not passed under the authorized revision"); + if (gateBPlan === undefined) reasons.push("canonical Gate B plan is missing"); + if (gateCPlan === undefined) reasons.push("canonical Gate C plan is missing"); + + const gateB = gateBPlan === undefined + ? null + : evaluateCanonicalGateB(gateBPlan, base.gateA, gateBReviews); + if (gateB !== null && !gateB.passed) reasons.push(...gateB.reasons.map((reason) => `Gate B: ${reason}`)); + + const gateC = gateCPlan === undefined || gateB === null + ? null + : evaluateCanonicalGateC(gateCPlan, gateB.verdict, gateCReviews); + if (gateC !== null && !gateC.passed) reasons.push(...gateC.reasons.map((reason) => `Gate C: ${reason}`)); + + const releaseReady = campaignAccounted + && base.gateA.passed + && gateB?.passed === true + && gateC?.passed === true; + + return { + schemaVersion: 1, + canonicalReleaseContractVersion: "canonical-release-1", + createdAt, + authorizedSoftwareRevision: expectedSoftwareRevision, + base, + campaignAccounted, + gateBPlanPresent: gateBPlan !== undefined, + gateCPlanPresent: gateCPlan !== undefined, + gateB, + gateC, + releaseReady, + reasons: [...new Set(reasons)], + }; +} diff --git a/packages/validation/src/index.ts b/packages/validation/src/index.ts index f2b23b73..cde01632 100644 --- a/packages/validation/src/index.ts +++ b/packages/validation/src/index.ts @@ -11,6 +11,10 @@ export { buildReleaseVerdict, buildReleaseVerdictForRevision, } from "./current-release"; +export { + buildCanonicalReleaseVerdictForRevision, + type CanonicalReleaseVerdictV1, +} from "./canonical-release"; export { empiricalCampaignSignature, evaluateEmpiricalCampaign, @@ -23,6 +27,124 @@ export { type EmpiricalCampaignSpecimenStatus, type EmpiricalCampaignV1, } from "./campaign"; +export { + deriveCampaignCollectionOrder, + validateCampaignSelectionAgainstRegister, + validateCandidateRegister, + type CampaignCollectionOrderEntryV1, + type CampaignCollectionOrderV1, + type CandidateRegisterEntryV1, + type CandidateRegisterV1, +} from "./precommitment"; +export { + createGateBPlan, + createGateCPlan, + evaluateCanonicalGateB, + evaluateCanonicalGateC, + type CanonicalGateBResult, + type CanonicalGateCResult, + type GateBPlanV1, + type GateCDevicePlan, + type GateCPlanV1, + type PlannedReviewTarget, +} from "./review-plans"; +export { + parseGateBPlan, + parseGateBPlanJson, + parseGateCPlan, + parseGateCPlanJson, + type GateBPlanParseResult, + type GateCPlanParseResult, +} from "./review-plan-parse"; +export { + contentDigest, + createDerivationRecord, + createMeasurementRecord, + verifyDerivationRecord, + verifyMeasurementRecord, + type DerivationKind, + type DerivationRecordV1, + type MeasurementRecordV1, + type MeasurementStationV1, +} from "./provenance"; +export { + ArtifactMigrationRegistryV1, + createArtifactEnvelope, + parseArtifactEnvelope, + parseArtifactEnvelopeJson, + verifyArtifactEnvelope, + type ArtifactEnvelopeParseResult, + type ArtifactEnvelopeV1, + type ArtifactMigrationStepV1, + type EverythingRingsArtifactKind, +} from "./artifact-envelope"; +export { + createCapabilityClaim, + createSoftwareQualificationManifest, + verifyCapabilityClaim, + verifySoftwareQualificationManifest, + type AssuranceEvidenceReferenceV1, + type CapabilityClaimV1, + type EvidenceDomain, + type SoftwareQualificationManifestV1, +} from "./assurance"; +export { + createAtlasRecord, + createAtlasSnapshot, + verifyAtlasRecord, + type AtlasContributorV1, + type AtlasMeasurementReferenceV1, + type AtlasSpecimenV1, + type ResonanceAtlasRecordV1, + type ResonanceAtlasSnapshotV1, +} from "./atlas"; +export { + applyAtlasSpecimenMerge, + createAtlasSpecimenMerge, + emptyAtlasRegistry, + publishAtlasRecord, + searchAtlas, + snapshotAtlasRegistry, + verifyAtlasSpecimenMerge, + type AtlasRegistryStateV1, + type AtlasSearchQueryV1, + type AtlasSearchResultV1, + type AtlasSpecimenMergeV1, +} from "./atlas-registry"; +export { + buildAtlasGraph, + createAtlasCollection, + verifyAtlasCollection, + type AtlasCollectionV1, + type AtlasGraphEdgeKind, + type AtlasGraphEdgeV1, + type AtlasGraphNodeKind, + type AtlasGraphNodeV1, + type AtlasGraphSnapshotV1, +} from "./atlas-network"; +export { + MemoryResonanceAtlasServiceV1, + verifyAtlasServiceConformance, + type ResonanceAtlasServiceV1, +} from "./atlas-service"; +export { + emptyResearchRepository, + ingestDerivation, + ingestMeasurement, + publishRepositoryAtlasRecord, + verifyResearchRepositoryIntegrity, + type ResearchRepositoryIntegrityV1, + type ResearchRepositoryStateV1, +} from "./repository"; +export { + createStationCalibrationProtocol, + evaluateStationCalibration, + evaluateVerifiedStationCalibration, + verifyStationCalibrationProtocol, + type StationCalibrationObservationV1, + type StationCalibrationProtocolV1, + type StationCalibrationVerdictV1, +} from "./station-calibration"; export { deriveEvidenceRecurrence, deriveMedianModalDriftCents, diff --git a/packages/validation/src/precommitment.ts b/packages/validation/src/precommitment.ts new file mode 100644 index 00000000..ea1cb7e6 --- /dev/null +++ b/packages/validation/src/precommitment.ts @@ -0,0 +1,157 @@ +import type { EmpiricalCampaignV1 } from "./campaign"; + +export interface CandidateRegisterEntryV1 { + readonly inventoryId: string; + readonly label: string; + readonly objectFamily: string; + readonly material: string; + readonly safetyStatus: "eligible" | "excluded"; + readonly supportDescription: string; + readonly strikeLocationMarkable: boolean; + readonly eligibleSlotIds: readonly string[]; + readonly exclusionReason: string | null; +} + +export interface CandidateRegisterV1 { + readonly schemaVersion: 1; + readonly registerContractVersion: "candidate-register-1"; + readonly createdAt: string; + readonly entries: readonly CandidateRegisterEntryV1[]; + readonly acousticAuditionPerformed: false; +} + +export interface CampaignCollectionOrderEntryV1 { + readonly ordinal: number; + readonly specimenId: string; + readonly cohort: "release-core" | "challenge"; + readonly orderingDigest: string; +} + +export interface CampaignCollectionOrderV1 { + readonly schemaVersion: 1; + readonly orderContractVersion: "campaign-order-1"; + readonly campaignSignature: string; + readonly entries: readonly CampaignCollectionOrderEntryV1[]; +} + +function normalize(value: string): string { + return value.trim().toLocaleLowerCase("en-US"); +} + +function nonempty(value: string): boolean { + return value.trim().length > 0; +} + +export function validateCandidateRegister(register: CandidateRegisterV1): readonly string[] { + const reasons: string[] = []; + if (register.schemaVersion !== 1) reasons.push("candidate register schemaVersion must be 1"); + if (register.registerContractVersion !== "candidate-register-1") reasons.push("candidate register contract is invalid"); + if (!Number.isFinite(Date.parse(register.createdAt))) reasons.push("candidate register createdAt is invalid"); + if (register.acousticAuditionPerformed !== false) reasons.push("candidate register must be frozen before acoustic audition"); + if (register.entries.length === 0) reasons.push("candidate register must contain at least one entry"); + + const seen = new Set(); + for (const entry of register.entries) { + const id = normalize(entry.inventoryId); + if (!nonempty(id)) reasons.push("candidate inventoryId is missing"); + else if (seen.has(id)) reasons.push(`duplicate candidate inventoryId ${entry.inventoryId}`); + else seen.add(id); + if (!nonempty(entry.label)) reasons.push(`candidate ${entry.inventoryId} label is missing`); + if (!nonempty(entry.objectFamily)) reasons.push(`candidate ${entry.inventoryId} objectFamily is missing`); + if (!nonempty(entry.material)) reasons.push(`candidate ${entry.inventoryId} material is missing`); + if (!nonempty(entry.supportDescription)) reasons.push(`candidate ${entry.inventoryId} support description is missing`); + if (entry.safetyStatus === "excluded" && !nonempty(entry.exclusionReason ?? "")) { + reasons.push(`excluded candidate ${entry.inventoryId} requires an exclusion reason`); + } + if (entry.safetyStatus === "eligible" && entry.exclusionReason !== null) { + reasons.push(`eligible candidate ${entry.inventoryId} cannot have an exclusion reason`); + } + if (entry.safetyStatus === "eligible" && entry.eligibleSlotIds.length === 0) { + reasons.push(`eligible candidate ${entry.inventoryId} requires at least one eligible slot`); + } + } + return [...new Set(reasons)]; +} + +export function validateCampaignSelectionAgainstRegister( + campaign: EmpiricalCampaignV1, + register: CandidateRegisterV1, +): readonly string[] { + const reasons = [...validateCandidateRegister(register)]; + const byInventory = new Map(register.entries.map((entry) => [normalize(entry.inventoryId), entry] as const)); + const selectedInventory = new Set(); + const familyByCoreMaterial = new Map>(); + + for (const specimen of campaign.specimens) { + const separator = specimen.specimenId.indexOf("--"); + if (separator < 0) { + reasons.push(`specimen ${specimen.specimenId} does not bind a slot to an inventory ID`); + continue; + } + const slotId = specimen.specimenId.slice(0, separator); + const inventoryId = normalize(specimen.specimenId.slice(separator + 2)); + const entry = byInventory.get(inventoryId); + if (entry === undefined) { + reasons.push(`specimen ${specimen.specimenId} references an inventory ID absent from the candidate register`); + continue; + } + if (selectedInventory.has(inventoryId)) reasons.push(`inventory item ${entry.inventoryId} is assigned to more than one campaign slot`); + selectedInventory.add(inventoryId); + if (entry.safetyStatus !== "eligible") reasons.push(`selected inventory item ${entry.inventoryId} is excluded`); + if (!entry.eligibleSlotIds.includes(slotId)) reasons.push(`inventory item ${entry.inventoryId} was not predeclared eligible for ${slotId}`); + if (normalize(entry.objectFamily) !== normalize(specimen.objectFamily)) reasons.push(`object family mismatch for ${specimen.specimenId}`); + if (normalize(entry.material) !== normalize(specimen.material)) reasons.push(`material mismatch for ${specimen.specimenId}`); + + if (specimen.cohort === "release-core") { + const material = normalize(specimen.material); + const families = familyByCoreMaterial.get(material) ?? new Set(); + families.add(normalize(specimen.objectFamily)); + familyByCoreMaterial.set(material, families); + } + } + + for (const material of ["metal", "glass", "ceramic"]) { + const families = familyByCoreMaterial.get(material); + if (families !== undefined && families.size < 2) { + reasons.push(`release-core ${material} specimens must come from two distinct object families`); + } + } + return [...new Set(reasons)]; +} + +async function sha256Text(value: string): Promise { + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value)); + return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +export async function deriveCampaignCollectionOrder( + campaign: EmpiricalCampaignV1, + campaignSignature: string, +): Promise { + const releaseCore = campaign.specimens.filter((specimen) => specimen.cohort === "release-core"); + const challenge = campaign.specimens.filter((specimen) => specimen.cohort === "challenge"); + if (releaseCore.length !== challenge.length) { + throw new Error("deterministic interleaving requires equal release-core and challenge cohort sizes"); + } + const rank = async (specimens: typeof releaseCore) => { + const values = await Promise.all(specimens.map(async (specimen) => ({ + specimen, + digest: await sha256Text(`gate-a2-order-v1|${campaignSignature}|${specimen.specimenId}`), + }))); + values.sort((left, right) => left.digest.localeCompare(right.digest)); + return values; + }; + const [coreRanked, challengeRanked] = await Promise.all([rank(releaseCore), rank(challenge)]); + const interleaved = coreRanked.flatMap((core, index) => [core, challengeRanked[index]!]); + return { + schemaVersion: 1, + orderContractVersion: "campaign-order-1", + campaignSignature, + entries: interleaved.map((entry, index) => ({ + ordinal: index + 1, + specimenId: entry.specimen.specimenId, + cohort: entry.specimen.cohort, + orderingDigest: `sha256:${entry.digest}`, + })), + }; +} diff --git a/packages/validation/src/provenance.ts b/packages/validation/src/provenance.ts new file mode 100644 index 00000000..a225ce9c --- /dev/null +++ b/packages/validation/src/provenance.ts @@ -0,0 +1,110 @@ +import type { AcousticFingerprintV1 } from "@everything-rings/dsp"; +import type { CaptureSettingsEvidence, FixedSetupProtocol, MaterialClass } from "./types"; + +export interface MeasurementStationV1 { + readonly stationId: string; + readonly deviceDescription: string; + readonly operatingSystem: string; + readonly runtime: string; + readonly microphoneDescription: string | null; + readonly captureSettings: CaptureSettingsEvidence | null; +} + +export interface MeasurementRecordV1 { + readonly schemaVersion: 1; + readonly measurementContractVersion: "measurement-record-1"; + readonly measurementId: string; + readonly createdAt: string; + readonly specimenId: string; + readonly sessionId: string; + readonly attemptId: number; + readonly material: MaterialClass; + readonly acquisitionContractVersion: string; + readonly softwareRevision: string; + readonly fingerprintAlgorithmVersion: string; + readonly setup: FixedSetupProtocol; + readonly station: MeasurementStationV1; + readonly fingerprint: AcousticFingerprintV1; + readonly rawMicrophoneSamplesIncluded: false; +} + +export type DerivationKind = "renderer" | "instrument" | "similarity" | "embedding" | "visualization" | "atlas-record"; + +export interface DerivationRecordV1 { + readonly schemaVersion: 1; + readonly derivationContractVersion: "derivation-record-1"; + readonly derivationId: string; + readonly measurementId: string; + readonly kind: DerivationKind; + readonly algorithmVersion: string; + readonly configDigest: string; + readonly artifactDigest: string; + readonly createdAt: string; +} + +function canonicalize(value: unknown): string { + if (value === undefined) return "undefined"; + if (value === null || typeof value !== "object") { + const encoded = JSON.stringify(value); + return encoded === undefined ? String(value) : encoded; + } + if (Array.isArray(value)) return `[${value.map(canonicalize).join(",")}]`; + const record = value as Record; + return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${canonicalize(record[key])}`).join(",")}}`; +} + +async function sha256(value: string): Promise { + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value)); + return `sha256:${Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("")}`; +} + +export async function contentDigest(value: unknown): Promise { + return sha256(canonicalize(value)); +} + +export async function createMeasurementRecord( + input: Omit, +): Promise { + if (!Number.isFinite(Date.parse(input.createdAt))) throw new Error("measurement createdAt is invalid"); + if (!/^[0-9a-f]{40}$/.test(input.softwareRevision)) throw new Error("measurement softwareRevision must be exact 40-hex Git revision"); + if (input.specimenId.trim().length === 0 || input.sessionId.trim().length === 0) throw new Error("measurement specimen/session identity is required"); + if (!Number.isInteger(input.attemptId) || input.attemptId <= 0) throw new Error("measurement attemptId must be positive"); + if (input.fingerprint.algorithmVersion !== input.fingerprintAlgorithmVersion) { + throw new Error("measurement fingerprint algorithm version mismatch"); + } + const payload = { + ...input, + schemaVersion: 1 as const, + measurementContractVersion: "measurement-record-1" as const, + rawMicrophoneSamplesIncluded: false as const, + }; + const measurementId = await contentDigest(payload); + return { ...payload, measurementId }; +} + +export async function createDerivationRecord( + input: Omit, +): Promise { + if (!/^sha256:[0-9a-f]{64}$/.test(input.measurementId)) throw new Error("derivation measurementId must be a measurement content digest"); + if (!/^sha256:[0-9a-f]{64}$/.test(input.configDigest)) throw new Error("derivation configDigest is invalid"); + if (!/^sha256:[0-9a-f]{64}$/.test(input.artifactDigest)) throw new Error("derivation artifactDigest is invalid"); + if (input.algorithmVersion.trim().length === 0) throw new Error("derivation algorithmVersion is required"); + if (!Number.isFinite(Date.parse(input.createdAt))) throw new Error("derivation createdAt is invalid"); + const payload = { + ...input, + schemaVersion: 1 as const, + derivationContractVersion: "derivation-record-1" as const, + }; + const derivationId = await contentDigest(payload); + return { ...payload, derivationId }; +} + +export async function verifyMeasurementRecord(record: MeasurementRecordV1): Promise { + const { measurementId, ...payload } = record; + return measurementId === await contentDigest(payload); +} + +export async function verifyDerivationRecord(record: DerivationRecordV1): Promise { + const { derivationId, ...payload } = record; + return derivationId === await contentDigest(payload); +} diff --git a/packages/validation/src/repository.ts b/packages/validation/src/repository.ts new file mode 100644 index 00000000..f7330eb5 --- /dev/null +++ b/packages/validation/src/repository.ts @@ -0,0 +1,162 @@ +import { + publishAtlasRecord, + verifyAtlasSpecimenMerge, + type AtlasRegistryStateV1, + emptyAtlasRegistry, +} from "./atlas-registry"; +import { verifyAtlasRecord, type ResonanceAtlasRecordV1 } from "./atlas"; +import { + verifyDerivationRecord, + verifyMeasurementRecord, + type DerivationRecordV1, + type MeasurementRecordV1, +} from "./provenance"; + +export interface ResearchRepositoryStateV1 { + readonly schemaVersion: 1; + readonly repositoryContractVersion: "everything-rings-research-repository-1"; + readonly measurements: readonly MeasurementRecordV1[]; + readonly derivations: readonly DerivationRecordV1[]; + readonly atlas: AtlasRegistryStateV1; +} + +export function emptyResearchRepository(): ResearchRepositoryStateV1 { + return { + schemaVersion: 1, + repositoryContractVersion: "everything-rings-research-repository-1", + measurements: [], + derivations: [], + atlas: emptyAtlasRegistry(), + }; +} + +export async function ingestMeasurement( + state: ResearchRepositoryStateV1, + measurement: MeasurementRecordV1, +): Promise { + if (!await verifyMeasurementRecord(measurement)) throw new Error("measurement failed content verification"); + const existing = state.measurements.find((candidate) => candidate.measurementId === measurement.measurementId); + if (existing !== undefined) return state; + return { + ...state, + measurements: [...state.measurements, measurement].sort((left, right) => left.measurementId.localeCompare(right.measurementId)), + }; +} + +export async function ingestDerivation( + state: ResearchRepositoryStateV1, + derivation: DerivationRecordV1, +): Promise { + if (!await verifyDerivationRecord(derivation)) throw new Error("derivation failed content verification"); + if (!state.measurements.some((measurement) => measurement.measurementId === derivation.measurementId)) { + throw new Error("derivation root measurement is absent from repository"); + } + if (state.derivations.some((candidate) => candidate.derivationId === derivation.derivationId)) return state; + return { + ...state, + derivations: [...state.derivations, derivation].sort((left, right) => left.derivationId.localeCompare(right.derivationId)), + }; +} + +export async function publishRepositoryAtlasRecord( + state: ResearchRepositoryStateV1, + record: ResonanceAtlasRecordV1, +): Promise { + if (!await verifyAtlasRecord(record)) throw new Error("Atlas record failed content verification"); + for (const reference of record.measurements) { + if (!state.measurements.some((measurement) => measurement.measurementId === reference.measurementId)) { + throw new Error(`Atlas measurement ${reference.measurementId} is absent from repository`); + } + for (const derivationId of reference.derivationIds) { + const derivation = state.derivations.find((candidate) => candidate.derivationId === derivationId); + if (derivation === undefined) throw new Error(`Atlas derivation ${derivationId} is absent from repository`); + if (derivation.measurementId !== reference.measurementId) { + throw new Error(`Atlas derivation ${derivationId} is bound to a different measurement`); + } + } + } + return { ...state, atlas: await publishAtlasRecord(state.atlas, record) }; +} + +export interface ResearchRepositoryIntegrityV1 { + readonly valid: boolean; + readonly measurementCount: number; + readonly derivationCount: number; + readonly atlasRecordCount: number; + readonly atlasMergeCount: number; + readonly reasons: readonly string[]; +} + +function normalize(value: string): string { + return value.trim().toLocaleLowerCase("en-US"); +} + +export async function verifyResearchRepositoryIntegrity( + state: ResearchRepositoryStateV1, +): Promise { + const reasons: string[] = []; + if (state.schemaVersion !== 1 || state.repositoryContractVersion !== "everything-rings-research-repository-1") { + reasons.push("research repository contract metadata is invalid"); + } + if (state.atlas.schemaVersion !== 1 || state.atlas.registryContractVersion !== "resonance-atlas-registry-1") { + reasons.push("Atlas registry contract metadata is invalid"); + } + + const measurementIds = new Set(); + for (const measurement of state.measurements) { + if (measurementIds.has(measurement.measurementId)) reasons.push(`duplicate measurement ${measurement.measurementId}`); + measurementIds.add(measurement.measurementId); + if (!await verifyMeasurementRecord(measurement)) reasons.push(`measurement ${measurement.measurementId} failed content verification`); + } + + const derivationIds = new Set(); + for (const derivation of state.derivations) { + if (derivationIds.has(derivation.derivationId)) reasons.push(`duplicate derivation ${derivation.derivationId}`); + derivationIds.add(derivation.derivationId); + if (!await verifyDerivationRecord(derivation)) reasons.push(`derivation ${derivation.derivationId} failed content verification`); + if (!measurementIds.has(derivation.measurementId)) reasons.push(`derivation ${derivation.derivationId} has missing root measurement`); + } + + const atlasRecordIds = new Set(); + const measurementOwners = new Map(); + for (const record of state.atlas.records) { + if (atlasRecordIds.has(record.atlasRecordId)) reasons.push(`duplicate Atlas record ${record.atlasRecordId}`); + atlasRecordIds.add(record.atlasRecordId); + if (!await verifyAtlasRecord(record)) reasons.push(`Atlas record ${record.atlasRecordId} failed content verification`); + const owner = normalize(record.specimen.specimenId); + for (const reference of record.measurements) { + const priorOwner = measurementOwners.get(reference.measurementId); + if (priorOwner !== undefined && priorOwner !== owner) reasons.push(`Atlas measurement ${reference.measurementId} is assigned to multiple specimens`); + measurementOwners.set(reference.measurementId, owner); + if (!measurementIds.has(reference.measurementId)) reasons.push(`Atlas record ${record.atlasRecordId} references missing measurement ${reference.measurementId}`); + for (const derivationId of reference.derivationIds) { + const derivation = state.derivations.find((candidate) => candidate.derivationId === derivationId); + if (derivation === undefined) reasons.push(`Atlas record ${record.atlasRecordId} references missing derivation ${derivationId}`); + else if (derivation.measurementId !== reference.measurementId) reasons.push(`Atlas derivation ${derivationId} points to wrong measurement`); + } + } + } + + const mergeIds = new Set(); + const mergeAliases = new Set(); + for (const merge of state.atlas.specimenMerges) { + if (mergeIds.has(merge.mergeId)) reasons.push(`duplicate Atlas merge ${merge.mergeId}`); + mergeIds.add(merge.mergeId); + if (!await verifyAtlasSpecimenMerge(merge)) reasons.push(`Atlas merge ${merge.mergeId} failed content verification`); + const alias = normalize(merge.aliasSpecimenId); + if (mergeAliases.has(alias)) reasons.push(`Atlas specimen ${alias} has multiple canonical merges`); + mergeAliases.add(alias); + for (const recordId of merge.supportingRecordIds) { + if (!atlasRecordIds.has(recordId)) reasons.push(`Atlas merge ${merge.mergeId} references missing record ${recordId}`); + } + } + + return { + valid: reasons.length === 0, + measurementCount: state.measurements.length, + derivationCount: state.derivations.length, + atlasRecordCount: state.atlas.records.length, + atlasMergeCount: state.atlas.specimenMerges.length, + reasons: [...new Set(reasons)], + }; +} diff --git a/packages/validation/src/review-plan-parse.ts b/packages/validation/src/review-plan-parse.ts new file mode 100644 index 00000000..ddf2b6e7 --- /dev/null +++ b/packages/validation/src/review-plan-parse.ts @@ -0,0 +1,105 @@ +import type { DeviceClass } from "./types"; +import type { GateBPlanV1, GateCDevicePlan, GateCPlanV1, PlannedReviewTarget } from "./review-plans"; + +export type GateBPlanParseResult = { readonly ok: true; readonly plan: GateBPlanV1 } | { readonly ok: false; readonly error: string }; +export type GateCPlanParseResult = { readonly ok: true; readonly plan: GateCPlanV1 } | { readonly ok: false; readonly error: string }; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function nonempty(value: unknown, field: string): string { + if (typeof value !== "string" || value.trim().length === 0) throw new TypeError(`${field} must be a non-empty string`); + return value.trim(); +} + +function sha256Digest(value: unknown, field: string): string { + const digest = nonempty(value, field); + if (!/^sha256:[0-9a-f]{64}$/.test(digest)) throw new TypeError(`${field} must be sha256:<64 lowercase hex>`); + return digest; +} + +function positiveInteger(value: unknown, field: string): number { + if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) throw new TypeError(`${field} must be a positive integer`); + return value; +} + +function parseTarget(value: unknown, field: string): PlannedReviewTarget { + if (!isRecord(value)) throw new TypeError(`${field} must be an object`); + return { + specimenId: nonempty(value.specimenId, `${field}.specimenId`), + objectLabel: nonempty(value.objectLabel, `${field}.objectLabel`), + sessionId: nonempty(value.sessionId, `${field}.sessionId`), + attemptId: positiveInteger(value.attemptId, `${field}.attemptId`), + }; +} + +function parseFixedReviewers(value: unknown, field: string): readonly [string, string] { + if (!Array.isArray(value) || value.length !== 2) throw new TypeError(`${field} must contain exactly two reviewer IDs`); + const reviewers: [string, string] = [nonempty(value[0], `${field}[0]`), nonempty(value[1], `${field}[1]`)]; + if (reviewers[0].toLocaleLowerCase("en-US") === reviewers[1].toLocaleLowerCase("en-US")) throw new TypeError(`${field} reviewer IDs must be distinct`); + return reviewers; +} + +function uniqueTargets(targets: readonly PlannedReviewTarget[], expected: number, field: string): void { + if (targets.length !== expected) throw new TypeError(`${field} must contain exactly ${expected} targets`); + const specimens = new Set(targets.map((target) => target.specimenId.trim().toLocaleLowerCase("en-US"))); + const targetKeys = new Set(targets.map((target) => `${target.sessionId}\u0000${target.attemptId}`)); + if (specimens.size !== expected) throw new TypeError(`${field} specimen IDs must be distinct`); + if (targetKeys.size !== expected) throw new TypeError(`${field} session/attempt targets must be distinct`); +} + +export function parseGateBPlan(value: unknown): GateBPlanParseResult { + try { + if (!isRecord(value)) throw new TypeError("Gate B plan must be an object"); + if (value.schemaVersion !== 1) throw new TypeError("Gate B plan schemaVersion must be 1"); + if (value.planContractVersion !== "gate-b-plan-1") throw new TypeError("Gate B plan contract must be gate-b-plan-1"); + const gateARevision = nonempty(value.gateARevision, "gateARevision"); + if (!/^[0-9a-f]{40}$/.test(gateARevision)) throw new TypeError("gateARevision must be an exact 40-hex Git revision"); + const gateAVerdictSha256 = sha256Digest(value.gateAVerdictSha256, "gateAVerdictSha256"); + const reviewerIds = parseFixedReviewers(value.reviewerIds, "reviewerIds"); + if (!Array.isArray(value.targets)) throw new TypeError("targets must be an array"); + const targets = value.targets.map((target, index) => parseTarget(target, `targets[${index}]`)); + uniqueTargets(targets, 5, "targets"); + return { ok: true, plan: { schemaVersion: 1, planContractVersion: "gate-b-plan-1", gateARevision, gateAVerdictSha256, reviewerIds, targets } }; + } catch (error) { + return { ok: false, error: error instanceof Error ? error.message : String(error) }; + } +} + +export function parseGateBPlanJson(json: string): GateBPlanParseResult { + try { return parseGateBPlan(JSON.parse(json) as unknown); } + catch (error) { return { ok: false, error: `invalid JSON: ${error instanceof Error ? error.message : String(error)}` }; } +} + +function parseDevice(value: unknown, field: string): GateCDevicePlan { + if (!isRecord(value)) throw new TypeError(`${field} must be an object`); + const deviceClass = nonempty(value.deviceClass, `${field}.deviceClass`) as DeviceClass; + if (!["desktop", "mobile", "tablet", "other"].includes(deviceClass)) throw new TypeError(`${field}.deviceClass is invalid`); + return { deviceId: nonempty(value.deviceId, `${field}.deviceId`), deviceClass }; +} + +export function parseGateCPlan(value: unknown): GateCPlanParseResult { + try { + if (!isRecord(value)) throw new TypeError("Gate C plan must be an object"); + if (value.schemaVersion !== 1) throw new TypeError("Gate C plan schemaVersion must be 1"); + if (value.planContractVersion !== "gate-c-plan-1") throw new TypeError("Gate C plan contract must be gate-c-plan-1"); + const gateBVerdictSha256 = sha256Digest(value.gateBVerdictSha256, "gateBVerdictSha256"); + const reviewerIds = parseFixedReviewers(value.reviewerIds, "reviewerIds"); + if (!Array.isArray(value.devices) || value.devices.length !== 2) throw new TypeError("devices must contain exactly two planned devices"); + const devices: [GateCDevicePlan, GateCDevicePlan] = [parseDevice(value.devices[0], "devices[0]"), parseDevice(value.devices[1], "devices[1]")]; + if (devices[0].deviceId.trim().toLocaleLowerCase("en-US") === devices[1].deviceId.trim().toLocaleLowerCase("en-US")) throw new TypeError("planned device IDs must be distinct"); + if (!devices.some((device) => device.deviceClass === "mobile")) throw new TypeError("Gate C plan requires a mobile device"); + if (!Array.isArray(value.targets)) throw new TypeError("targets must be an array"); + const targets = value.targets.map((target, index) => parseTarget(target, `targets[${index}]`)); + uniqueTargets(targets, 4, "targets"); + return { ok: true, plan: { schemaVersion: 1, planContractVersion: "gate-c-plan-1", gateBVerdictSha256, reviewerIds, devices, targets } }; + } catch (error) { + return { ok: false, error: error instanceof Error ? error.message : String(error) }; + } +} + +export function parseGateCPlanJson(json: string): GateCPlanParseResult { + try { return parseGateCPlan(JSON.parse(json) as unknown); } + catch (error) { return { ok: false, error: `invalid JSON: ${error instanceof Error ? error.message : String(error)}` }; } +} diff --git a/packages/validation/src/review-plans.ts b/packages/validation/src/review-plans.ts new file mode 100644 index 00000000..2b1e1334 --- /dev/null +++ b/packages/validation/src/review-plans.ts @@ -0,0 +1,267 @@ +import type { + GateAReleaseVerdict, + GateBReleaseVerdict, + GateBReview, + GateCReleaseVerdict, + GateCReview, + ReviewTarget, +} from "./types"; +import { evaluateGateBRelease, evaluateGateCRelease } from "./evaluate"; +import { stableSha256Hex } from "./stable-sha256"; + +export interface PlannedReviewTarget extends ReviewTarget { + readonly specimenId: string; + readonly objectLabel: string; +} + +export interface GateBPlanV1 { + readonly schemaVersion: 1; + readonly planContractVersion: "gate-b-plan-1"; + readonly gateARevision: string; + readonly gateAVerdictSha256: string; + readonly reviewerIds: readonly [string, string]; + readonly targets: readonly PlannedReviewTarget[]; +} + +export interface GateCDevicePlan { + readonly deviceId: string; + readonly deviceClass: "desktop" | "mobile" | "tablet" | "other"; +} + +export interface GateCPlanV1 { + readonly schemaVersion: 1; + readonly planContractVersion: "gate-c-plan-1"; + readonly gateBVerdictSha256: string; + readonly reviewerIds: readonly [string, string]; + readonly devices: readonly [GateCDevicePlan, GateCDevicePlan]; + readonly targets: readonly PlannedReviewTarget[]; +} + +export interface CanonicalGateBResult { + readonly passed: boolean; + readonly expectedJudgmentCount: 10; + readonly observedJudgmentCount: number; + readonly reasons: readonly string[]; + readonly verdict: GateBReleaseVerdict; +} + +export interface CanonicalGateCResult { + readonly passed: boolean; + readonly expectedJudgmentCount: 16; + readonly observedJudgmentCount: number; + readonly reasons: readonly string[]; + readonly verdict: GateCReleaseVerdict; +} + +function normalize(value: string): string { + return value.trim().toLocaleLowerCase("en-US"); +} + +function targetKey(target: ReviewTarget): string { + return `${target.sessionId}\u0000${target.attemptId}`; +} + +function canonicalize(value: unknown): string { + if (value === undefined) return "undefined"; + if (value === null || typeof value !== "object") { + const encoded = JSON.stringify(value); + return encoded === undefined ? String(value) : encoded; + } + if (Array.isArray(value)) return `[${value.map(canonicalize).join(",")}]`; + const record = value as Record; + return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${canonicalize(record[key])}`).join(",")}}`; +} + +function verdictDigest(value: unknown): string { + return `sha256:${stableSha256Hex(canonicalize(value))}`; +} + +function rankTargets( + domain: string, + targets: readonly PlannedReviewTarget[], +): readonly PlannedReviewTarget[] { + return targets.map((target) => ({ + target, + digest: stableSha256Hex(`${domain}|${normalize(target.specimenId)}|${target.sessionId}|${target.attemptId}`), + })).sort((left, right) => left.digest.localeCompare(right.digest)).map(({ target }) => target); +} + +function gateATargets(gateA: GateAReleaseVerdict): PlannedReviewTarget[] { + return gateA.sessions + .filter((session) => session.passed && session.reviewAttemptId !== null) + .map((session) => ({ + specimenId: session.specimenId, + objectLabel: session.objectLabel, + sessionId: session.sessionId, + attemptId: session.reviewAttemptId as number, + })); +} + +function uniqueSpecimenTargets(targets: readonly PlannedReviewTarget[]): PlannedReviewTarget[] { + const bySpecimen = new Map(); + for (const target of targets) { + const key = normalize(target.specimenId); + if (!bySpecimen.has(key)) bySpecimen.set(key, target); + } + return [...bySpecimen.values()]; +} + +function assertFixedPair(values: readonly string[], field: string): readonly [string, string] { + if (values.length !== 2) throw new Error(`${field} must contain exactly two values`); + const normalized = values.map(normalize); + if (normalized.some((value) => value.length === 0)) throw new Error(`${field} values must be non-empty`); + if (normalized[0] === normalized[1]) throw new Error(`${field} values must be distinct after normalization`); + return [values[0]!.trim(), values[1]!.trim()]; +} + +export function createGateBPlan( + gateA: GateAReleaseVerdict, + reviewerIds: readonly string[], +): GateBPlanV1 { + if (!gateA.passed || gateA.softwareRevision === null) throw new Error("Gate A must pass before Gate B planning"); + const reviewers = assertFixedPair(reviewerIds, "reviewerIds"); + const all = uniqueSpecimenTargets(rankTargets("gate-b-target-v1", gateATargets(gateA))); + const byMaterial = new Map(gateA.sessions + .filter((session) => session.passed) + .map((session) => [normalize(session.specimenId), session.material] as const)); + + const selected: PlannedReviewTarget[] = []; + for (const material of ["metal", "glass", "ceramic"] as const) { + const target = all.find((candidate) => byMaterial.get(normalize(candidate.specimenId)) === material); + if (target === undefined) throw new Error(`Gate B planning requires a passing ${material} target`); + selected.push(target); + } + const selectedKeys = new Set(selected.map((target) => normalize(target.specimenId))); + selected.push(...all.filter((target) => !selectedKeys.has(normalize(target.specimenId))).slice(0, 2)); + if (selected.length !== 5) throw new Error("Gate B planning requires exactly five passing specimens"); + + return { + schemaVersion: 1, + planContractVersion: "gate-b-plan-1", + gateARevision: gateA.softwareRevision, + gateAVerdictSha256: verdictDigest(gateA), + reviewerIds: reviewers, + targets: selected, + }; +} + +export function createGateCPlan( + gateB: GateBReleaseVerdict, + reviewerIds: readonly string[], + devices: readonly GateCDevicePlan[], +): GateCPlanV1 { + if (!gateB.passed) throw new Error("Gate B must pass before Gate C planning"); + const reviewers = assertFixedPair(reviewerIds, "reviewerIds"); + if (devices.length !== 2) throw new Error("Gate C planning requires exactly two devices"); + const deviceIds = devices.map((device) => normalize(device.deviceId)); + if (deviceIds.some((value) => value.length === 0) || deviceIds[0] === deviceIds[1]) { + throw new Error("Gate C devices must have two distinct non-empty IDs"); + } + if (!devices.some((device) => device.deviceClass === "mobile")) { + throw new Error("Gate C device plan must include a mobile device"); + } + const passingTargets = gateB.objects + .filter((object) => object.passed && object.selectedTarget !== null) + .map((object) => ({ + specimenId: object.specimenId, + objectLabel: object.objectLabel, + sessionId: object.selectedTarget!.sessionId, + attemptId: object.selectedTarget!.attemptId, + })); + const ranked = rankTargets("gate-c-target-v1", uniqueSpecimenTargets(passingTargets)); + const targets = ranked.slice(0, 4); + if (targets.length !== 4) throw new Error("Gate C planning requires four passing Gate B specimens"); + return { + schemaVersion: 1, + planContractVersion: "gate-c-plan-1", + gateBVerdictSha256: verdictDigest(gateB), + reviewerIds: reviewers, + devices: [devices[0]!, devices[1]!], + targets, + }; +} + +export function evaluateCanonicalGateB( + plan: GateBPlanV1, + gateA: GateAReleaseVerdict, + reviews: readonly GateBReview[], +): CanonicalGateBResult { + const reasons: string[] = []; + if (!gateA.passed) reasons.push("Gate A has not passed"); + if (gateA.softwareRevision !== plan.gateARevision) reasons.push("Gate B plan revision does not match Gate A"); + if (verdictDigest(gateA) !== plan.gateAVerdictSha256) reasons.push("Gate B plan does not bind the exact Gate A verdict"); + if (plan.targets.length !== 5) reasons.push("Gate B plan must contain exactly five targets"); + const expectedReviewers = new Set(plan.reviewerIds.map(normalize)); + const expectedTargets = new Map(plan.targets.map((target) => [targetKey(target), target] as const)); + const canonical = reviews.filter((review) => expectedTargets.has(targetKey(review))); + + for (const review of canonical) { + if (!expectedReviewers.has(normalize(review.reviewerId))) reasons.push(`unexpected Gate B reviewer ${review.reviewerId}`); + if (!review.blinded) reasons.push(`Gate B review ${review.reviewId} is not blinded`); + } + for (const target of plan.targets) { + const targetReviews = canonical.filter((review) => targetKey(review) === targetKey(target)); + const reviewerSet = new Set(targetReviews.map((review) => normalize(review.reviewerId))); + if (targetReviews.length !== 2 || reviewerSet.size !== 2 || [...expectedReviewers].some((id) => !reviewerSet.has(id))) { + reasons.push(`Gate B target ${target.specimenId} does not have the exact fixed two-reviewer panel`); + } + } + if (canonical.length !== 10) reasons.push("Gate B requires exactly ten canonical judgments"); + + const verdict = evaluateGateBRelease(gateA, canonical); + if (!verdict.passed) reasons.push(...verdict.reasons); + return { + passed: reasons.length === 0, + expectedJudgmentCount: 10, + observedJudgmentCount: canonical.length, + reasons: [...new Set(reasons)], + verdict, + }; +} + +export function evaluateCanonicalGateC( + plan: GateCPlanV1, + gateB: GateBReleaseVerdict, + reviews: readonly GateCReview[], +): CanonicalGateCResult { + const reasons: string[] = []; + if (!gateB.passed) reasons.push("Gate B has not passed"); + if (verdictDigest(gateB) !== plan.gateBVerdictSha256) reasons.push("Gate C plan does not bind the exact Gate B verdict"); + if (plan.targets.length !== 4) reasons.push("Gate C plan must contain exactly four targets"); + const expectedReviewers = new Set(plan.reviewerIds.map(normalize)); + const expectedDevices = new Map(plan.devices.map((device) => [normalize(device.deviceId), device] as const)); + const expectedTargets = new Map(plan.targets.map((target) => [targetKey(target), target] as const)); + const canonical = reviews.filter((review) => expectedTargets.has(targetKey(review))); + + for (const review of canonical) { + if (!expectedReviewers.has(normalize(review.reviewerId))) reasons.push(`unexpected Gate C reviewer ${review.reviewerId}`); + const device = expectedDevices.get(normalize(review.deviceId)); + if (device === undefined) reasons.push(`unexpected Gate C device ${review.deviceId}`); + else if (device.deviceClass !== review.deviceClass) reasons.push(`Gate C device class mismatch for ${review.deviceId}`); + } + + for (const target of plan.targets) { + const cells = new Set(canonical + .filter((review) => targetKey(review) === targetKey(target)) + .map((review) => `${normalize(review.reviewerId)}\u0000${normalize(review.deviceId)}`)); + for (const reviewerId of expectedReviewers) { + for (const deviceId of expectedDevices.keys()) { + if (!cells.has(`${reviewerId}\u0000${deviceId}`)) { + reasons.push(`Gate C target ${target.specimenId} is missing reviewer/device cell ${reviewerId}/${deviceId}`); + } + } + } + if (cells.size !== 4) reasons.push(`Gate C target ${target.specimenId} must contain exactly four canonical cells`); + } + if (canonical.length !== 16) reasons.push("Gate C requires exactly sixteen canonical judgments"); + + const verdict = evaluateGateCRelease(gateB, canonical); + if (!verdict.passed) reasons.push(...verdict.reasons); + return { + passed: reasons.length === 0, + expectedJudgmentCount: 16, + observedJudgmentCount: canonical.length, + reasons: [...new Set(reasons)], + verdict, + }; +} diff --git a/packages/validation/src/runtime-globals.d.ts b/packages/validation/src/runtime-globals.d.ts new file mode 100644 index 00000000..0939e50d --- /dev/null +++ b/packages/validation/src/runtime-globals.d.ts @@ -0,0 +1,11 @@ +interface ValidationSubtleCrypto { + digest(algorithm: string, data: ArrayBuffer | ArrayBufferView): Promise; +} + +declare const crypto: { + readonly subtle: ValidationSubtleCrypto; +}; + +declare class TextEncoder { + encode(input?: string): Uint8Array; +} diff --git a/packages/validation/src/stable-sha256.ts b/packages/validation/src/stable-sha256.ts new file mode 100644 index 00000000..22a0b0f2 --- /dev/null +++ b/packages/validation/src/stable-sha256.ts @@ -0,0 +1,83 @@ +function utf8(value: string): Uint8Array { + const output: number[] = []; + for (const character of value) { + const code = character.codePointAt(0) ?? 0; + if (code <= 0x7f) output.push(code); + else if (code <= 0x7ff) output.push(0xc0 | (code >>> 6), 0x80 | (code & 0x3f)); + else if (code <= 0xffff) output.push(0xe0 | (code >>> 12), 0x80 | ((code >>> 6) & 0x3f), 0x80 | (code & 0x3f)); + else output.push(0xf0 | (code >>> 18), 0x80 | ((code >>> 12) & 0x3f), 0x80 | ((code >>> 6) & 0x3f), 0x80 | (code & 0x3f)); + } + return Uint8Array.from(output); +} + +const INITIAL: readonly number[] = [ + 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, + 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19, +]; + +const K: readonly number[] = [ + 0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5, + 0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174, + 0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da, + 0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967, + 0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85, + 0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070, + 0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3, + 0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2, +]; + +function rotr(value: number, shift: number): number { + return (value >>> shift) | (value << (32 - shift)); +} + +export function stableSha256Hex(value: string): string { + const source = utf8(value); + const bitLength = source.length * 8; + const padding = (64 - ((source.length + 9) % 64)) % 64; + const bytes = new Uint8Array(source.length + 1 + padding + 8); + bytes.set(source); + bytes[source.length] = 0x80; + const view = new DataView(bytes.buffer); + view.setUint32(bytes.length - 8, Math.floor(bitLength / 0x1_0000_0000), false); + view.setUint32(bytes.length - 4, bitLength >>> 0, false); + + const hash: number[] = [...INITIAL]; + const words = new Uint32Array(64); + for (let offset = 0; offset < bytes.length; offset += 64) { + for (let index = 0; index < 16; index += 1) words[index] = view.getUint32(offset + index * 4, false); + for (let index = 16; index < 64; index += 1) { + const x = words[index - 15] ?? 0; + const y = words[index - 2] ?? 0; + const s0 = rotr(x, 7) ^ rotr(x, 18) ^ (x >>> 3); + const s1 = rotr(y, 17) ^ rotr(y, 19) ^ (y >>> 10); + words[index] = ((words[index - 16] ?? 0) + s0 + (words[index - 7] ?? 0) + s1) >>> 0; + } + + let a = hash[0] ?? 0; + let b = hash[1] ?? 0; + let c = hash[2] ?? 0; + let d = hash[3] ?? 0; + let e = hash[4] ?? 0; + let f = hash[5] ?? 0; + let g = hash[6] ?? 0; + let h = hash[7] ?? 0; + for (let index = 0; index < 64; index += 1) { + const s1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25); + const ch = (e & f) ^ (~e & g); + const temp1 = (h + s1 + ch + (K[index] ?? 0) + (words[index] ?? 0)) >>> 0; + const s0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22); + const maj = (a & b) ^ (a & c) ^ (b & c); + const temp2 = (s0 + maj) >>> 0; + h = g; g = f; f = e; e = (d + temp1) >>> 0; d = c; c = b; b = a; a = (temp1 + temp2) >>> 0; + } + hash[0] = ((hash[0] ?? 0) + a) >>> 0; + hash[1] = ((hash[1] ?? 0) + b) >>> 0; + hash[2] = ((hash[2] ?? 0) + c) >>> 0; + hash[3] = ((hash[3] ?? 0) + d) >>> 0; + hash[4] = ((hash[4] ?? 0) + e) >>> 0; + hash[5] = ((hash[5] ?? 0) + f) >>> 0; + hash[6] = ((hash[6] ?? 0) + g) >>> 0; + hash[7] = ((hash[7] ?? 0) + h) >>> 0; + } + return hash.map((word) => word.toString(16).padStart(8, "0")).join(""); +} diff --git a/packages/validation/src/station-calibration.ts b/packages/validation/src/station-calibration.ts new file mode 100644 index 00000000..150f212a --- /dev/null +++ b/packages/validation/src/station-calibration.ts @@ -0,0 +1,112 @@ +import type { AcousticFingerprintV1 } from "@everything-rings/dsp"; +import { + compareFingerprintToObjectModel, + type AcousticObjectModelV1, + type FingerprintToObjectModelComparisonV1, +} from "@everything-rings/fingerprint"; +import { contentDigest } from "./provenance"; + +export interface StationCalibrationProtocolV1 { + readonly schemaVersion: 1; + readonly stationCalibrationContractVersion: "station-calibration-1"; + readonly protocolId: string; + readonly createdAt: string; + readonly referencePopulation: string; + readonly referenceSpecimenId: string; + readonly referenceObjectModelDigest: string; + readonly referenceObjectModel: AcousticObjectModelV1; + readonly minimumCoverage: number; + readonly maximumMedianFrequencyDistanceCents: number; + readonly minimumMatchedModes: number; +} + +export async function createStationCalibrationProtocol( + input: Omit, +): Promise { + if (!Number.isFinite(Date.parse(input.createdAt))) throw new Error("station calibration createdAt is invalid"); + if (input.referencePopulation.trim().length === 0) throw new Error("station calibration referencePopulation is required"); + if (input.referenceSpecimenId.trim().toLocaleLowerCase("en-US") !== input.referenceObjectModel.specimenId.trim().toLocaleLowerCase("en-US")) { + throw new Error("station calibration reference specimen does not match object model"); + } + if (!(input.minimumCoverage > 0 && input.minimumCoverage <= 1)) throw new Error("minimumCoverage must be in (0,1]"); + if (!(input.maximumMedianFrequencyDistanceCents > 0) || !Number.isFinite(input.maximumMedianFrequencyDistanceCents)) { + throw new Error("maximumMedianFrequencyDistanceCents must be finite and positive"); + } + if (!Number.isInteger(input.minimumMatchedModes) || input.minimumMatchedModes <= 0) throw new Error("minimumMatchedModes must be a positive integer"); + const referenceObjectModelDigest = await contentDigest(input.referenceObjectModel); + const payload = { + ...input, + schemaVersion: 1 as const, + stationCalibrationContractVersion: "station-calibration-1" as const, + referenceObjectModelDigest, + }; + return { ...payload, protocolId: await contentDigest(payload) }; +} + +export async function verifyStationCalibrationProtocol(protocol: StationCalibrationProtocolV1): Promise { + if (protocol.schemaVersion !== 1 || protocol.stationCalibrationContractVersion !== "station-calibration-1") return false; + if (!/^sha256:[0-9a-f]{64}$/.test(protocol.protocolId) || !/^sha256:[0-9a-f]{64}$/.test(protocol.referenceObjectModelDigest)) return false; + if (!Number.isFinite(Date.parse(protocol.createdAt)) || protocol.referencePopulation.trim().length === 0) return false; + if (protocol.referenceSpecimenId.trim().toLocaleLowerCase("en-US") !== protocol.referenceObjectModel.specimenId.trim().toLocaleLowerCase("en-US")) return false; + if (!(protocol.minimumCoverage > 0 && protocol.minimumCoverage <= 1)) return false; + if (!(protocol.maximumMedianFrequencyDistanceCents > 0) || !Number.isFinite(protocol.maximumMedianFrequencyDistanceCents)) return false; + if (!Number.isInteger(protocol.minimumMatchedModes) || protocol.minimumMatchedModes <= 0) return false; + if (protocol.referenceObjectModelDigest !== await contentDigest(protocol.referenceObjectModel)) return false; + const { protocolId, ...payload } = protocol; + return protocolId === await contentDigest(payload); +} + +export interface StationCalibrationObservationV1 { + readonly stationId: string; + readonly createdAt: string; + readonly specimenId: string; + readonly fingerprint: AcousticFingerprintV1; +} + +export interface StationCalibrationVerdictV1 { + readonly schemaVersion: 1; + readonly verdictContractVersion: "station-calibration-verdict-1"; + readonly protocolId: string; + readonly stationId: string; + readonly createdAt: string; + readonly passed: boolean; + readonly comparison: FingerprintToObjectModelComparisonV1; + readonly reasons: readonly string[]; +} + +/** Diagnostic evaluator. Use evaluateVerifiedStationCalibration for authoritative station qualification. */ +export function evaluateStationCalibration( + protocol: StationCalibrationProtocolV1, + observation: StationCalibrationObservationV1, +): StationCalibrationVerdictV1 { + if (!Number.isFinite(Date.parse(observation.createdAt))) throw new Error("station calibration observation createdAt is invalid"); + if (observation.stationId.trim().length === 0) throw new Error("station calibration stationId is required"); + const reasons: string[] = []; + if (observation.specimenId.trim().toLocaleLowerCase("en-US") !== protocol.referenceSpecimenId.trim().toLocaleLowerCase("en-US")) { + reasons.push("observation specimen does not match the calibration reference specimen"); + } + const comparison = compareFingerprintToObjectModel(observation.fingerprint, protocol.referenceObjectModel); + if (comparison.coverage < protocol.minimumCoverage) reasons.push(`mode coverage ${comparison.coverage.toFixed(3)} is below ${protocol.minimumCoverage}`); + if (comparison.matchedModelModes < protocol.minimumMatchedModes) reasons.push(`matched modes ${comparison.matchedModelModes} is below ${protocol.minimumMatchedModes}`); + if (comparison.medianFrequencyDistanceCents === null || comparison.medianFrequencyDistanceCents > protocol.maximumMedianFrequencyDistanceCents) { + reasons.push(`median modal drift exceeds ${protocol.maximumMedianFrequencyDistanceCents} cents`); + } + return { + schemaVersion: 1, + verdictContractVersion: "station-calibration-verdict-1", + protocolId: protocol.protocolId, + stationId: observation.stationId.trim(), + createdAt: observation.createdAt, + passed: reasons.length === 0, + comparison, + reasons, + }; +} + +export async function evaluateVerifiedStationCalibration( + protocol: StationCalibrationProtocolV1, + observation: StationCalibrationObservationV1, +): Promise { + if (!await verifyStationCalibrationProtocol(protocol)) throw new Error("station calibration protocol failed content verification"); + return evaluateStationCalibration(protocol, observation); +} diff --git a/packages/validation/test/artifact-envelope.test.ts b/packages/validation/test/artifact-envelope.test.ts new file mode 100644 index 00000000..4f6ecda0 --- /dev/null +++ b/packages/validation/test/artifact-envelope.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { ArtifactMigrationRegistryV1, createArtifactEnvelope, parseArtifactEnvelopeJson, verifyArtifactEnvelope } from "../src"; + +describe("versioned artifact transport", () => { + it("detects payload tampering through content-addressed envelopes", async () => { + const envelope = await createArtifactEnvelope("software-qualification", "qualification-1", { passed: true, checks: ["test", "build"] }, "2026-08-24T17:00:00.000Z"); + expect(await verifyArtifactEnvelope(envelope)).toBe(true); + const parsed = await parseArtifactEnvelopeJson(JSON.stringify(envelope)); + expect(parsed.ok).toBe(true); + const tampered = { ...envelope, payload: { passed: false, checks: ["test", "build"] } }; + expect(await verifyArtifactEnvelope(tampered)).toBe(false); + }); + + it("migrates explicit artifact versions without mutating the original payload", () => { + const registry = new ArtifactMigrationRegistryV1(); + registry.register({ artifactKind: "atlas-record", fromContractVersion: "atlas-0", toContractVersion: "atlas-1", migrate: (payload) => ({ ...(payload as object), migrated: 1 }) }); + registry.register({ artifactKind: "atlas-record", fromContractVersion: "atlas-1", toContractVersion: "atlas-2", migrate: (payload) => ({ ...(payload as object), migrated: 2 }) }); + const original = { specimen: "a" }; + const migrated = registry.migrate("atlas-record", "atlas-0", "atlas-2", original) as { specimen: string; migrated: number }; + expect(migrated).toEqual({ specimen: "a", migrated: 2 }); + expect(original).toEqual({ specimen: "a" }); + expect(() => registry.migrate("measurement", "measurement-0", "measurement-1", {})).toThrow(/no migration path/); + }); +}); diff --git a/packages/validation/test/assurance.test.ts b/packages/validation/test/assurance.test.ts new file mode 100644 index 00000000..2058f4af --- /dev/null +++ b/packages/validation/test/assurance.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import { + createCapabilityClaim, + createSoftwareQualificationManifest, + verifyCapabilityClaim, + verifySoftwareQualificationManifest, +} from "../src"; + +const REVISION = "0123456789abcdef0123456789abcdef01234567"; + +describe("machine-readable assurance", () => { + it("binds a software-qualified proposition to explicit evidence and exclusions", async () => { + const claim = await createCapabilityClaim({ + createdAt: "2026-08-24T16:00:00.000Z", + capability: "sonic-twin-retrieval", + proposition: "The deterministic retrieval implementation ranks the correct digital specimen first in the named software qualification corpus.", + scope: "built-in digital qualification corpus only", + exclusions: ["physical-object identity", "material identity", "human perceptual validity"], + evidence: [{ evidenceId: REVISION, domain: "software", description: "exact source revision" }], + maturity: "software-qualified", + }); + expect(await verifyCapabilityClaim(claim)).toBe(true); + expect(await verifyCapabilityClaim({ ...claim, proposition: "mutated" })).toBe(false); + }); + + it("creates and verifies a non-empirical qualification manifest with non-empty claim coverage", async () => { + const claim = await createCapabilityClaim({ + createdAt: "2026-08-24T16:00:00.000Z", + capability: "atlas-integrity", + proposition: "Content-addressed Atlas artifacts detect mutation in software tests.", + scope: "software qualification", + exclusions: ["physical provenance truth"], + evidence: [{ evidenceId: "github-run:12345", domain: "software", description: "qualification workflow" }], + maturity: "software-qualified", + }); + const manifest = await createSoftwareQualificationManifest({ + sourceRevision: REVISION, + createdAt: "2026-08-24T16:05:00.000Z", + testRunIds: ["github-run:12345"], + capabilityClaimIds: [claim.claimId], + }); + expect(await verifySoftwareQualificationManifest(manifest)).toBe(true); + expect(manifest.physicalObjectTested).toBe(false); + expect(manifest.releaseGateEquivalent).toBe(false); + expect(await verifySoftwareQualificationManifest({ ...manifest, sourceRevision: "f".repeat(40) })).toBe(false); + }); + + it("refuses qualification manifests without explicit capability claims", async () => { + await expect(createSoftwareQualificationManifest({ + sourceRevision: REVISION, + createdAt: "2026-08-24T16:05:00.000Z", + testRunIds: ["github-run:12345"], + capabilityClaimIds: [], + })).rejects.toThrow(/capability claim/); + }); +}); diff --git a/packages/validation/test/atlas-network.test.ts b/packages/validation/test/atlas-network.test.ts new file mode 100644 index 00000000..bad649af --- /dev/null +++ b/packages/validation/test/atlas-network.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import { + buildAtlasGraph, + contentDigest, + createAtlasCollection, + createAtlasRecord, + emptyAtlasRegistry, + publishAtlasRecord, + verifyAtlasCollection, +} from "../src"; + +describe("Atlas collection and graph model", () => { + it("projects verified public records and collections into an explicit non-identity graph", async () => { + const first = await createAtlasRecord({ + createdAt: "2026-08-24T16:00:00.000Z", + contributor: { contributorId: "alice", displayName: "Alice" }, + specimen: { specimenId: "spec-a", label: "Digital Bell", objectFamily: "bell", material: "other", publicDescription: null }, + measurements: [{ measurementId: await contentDigest({ m: "a" }), derivationIds: [] }], + }); + const second = await createAtlasRecord({ + createdAt: "2026-08-24T16:01:00.000Z", + contributor: { contributorId: "bob", displayName: "Bob" }, + specimen: { specimenId: "spec-b", label: "Digital Plate", objectFamily: "plate", material: "other", publicDescription: null }, + measurements: [{ measurementId: await contentDigest({ m: "b" }), derivationIds: [] }], + }); + let registry = await publishAtlasRecord(emptyAtlasRegistry(), first); + registry = await publishAtlasRecord(registry, second); + const collection = await createAtlasCollection({ + createdAt: "2026-08-24T16:02:00.000Z", + contributorId: "alice", + title: "Digital resonators", + description: "Software-only examples", + atlasRecordIds: [first.atlasRecordId, second.atlasRecordId], + }); + expect(await verifyAtlasCollection(collection)).toBe(true); + const graph = await buildAtlasGraph(registry, [collection]); + expect(graph.nodes.some((node) => node.kind === "collection")).toBe(true); + expect(graph.edges.filter((edge) => edge.kind === "member-of")).toHaveLength(2); + expect(graph.edges.filter((edge) => edge.kind === "created-by")).toHaveLength(1); + expect(graph.edges.every((edge) => edge.kind !== ("same-object" as never))).toBe(true); + }); + + it("rejects tampered collections before graph projection", async () => { + const record = await createAtlasRecord({ + createdAt: "2026-08-24T16:00:00.000Z", + contributor: null, + specimen: { specimenId: "spec-a", label: "A", objectFamily: "bell", material: "other", publicDescription: null }, + measurements: [{ measurementId: await contentDigest({ m: "a" }), derivationIds: [] }], + }); + const registry = await publishAtlasRecord(emptyAtlasRegistry(), record); + const collection = await createAtlasCollection({ + createdAt: "2026-08-24T16:02:00.000Z", + contributorId: "alice", + title: "Original", + description: null, + atlasRecordIds: [record.atlasRecordId], + }); + const tampered = { ...collection, title: "Changed" }; + expect(await verifyAtlasCollection(tampered)).toBe(false); + await expect(buildAtlasGraph(registry, [tampered])).rejects.toThrow(/content verification/); + }); +}); diff --git a/packages/validation/test/atlas-registry.test.ts b/packages/validation/test/atlas-registry.test.ts new file mode 100644 index 00000000..9effce5b --- /dev/null +++ b/packages/validation/test/atlas-registry.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from "vitest"; +import { + applyAtlasSpecimenMerge, + contentDigest, + createAtlasRecord, + createAtlasSpecimenMerge, + emptyAtlasRegistry, + publishAtlasRecord, + searchAtlas, + snapshotAtlasRegistry, + verifyAtlasSpecimenMerge, +} from "../src"; + +async function atlasRecord(specimenId: string, label: string, measurementSeed: string) { + return createAtlasRecord({ + createdAt: "2026-08-24T15:30:00.000Z", + contributor: { contributorId: "contributor-1", displayName: null }, + specimen: { + specimenId, + label, + objectFamily: "digital-reference", + material: "other", + publicDescription: "software qualification record", + }, + measurements: [{ measurementId: await contentDigest({ measurementSeed }), derivationIds: [] }], + }); +} + +describe("Resonance Atlas registry", () => { + it("publishes verified records append-only and creates deterministic snapshots", async () => { + const first = await atlasRecord("specimen-a", "Digital Bell A", "a"); + const second = await atlasRecord("specimen-b", "Digital Bell B", "b"); + let registry = emptyAtlasRegistry(); + registry = await publishAtlasRecord(registry, first); + registry = await publishAtlasRecord(registry, second); + expect(registry.records).toHaveLength(2); + const repeated = await publishAtlasRecord(registry, first); + expect(repeated.records).toHaveLength(2); + + const results = searchAtlas(registry, { text: "bell", material: "other" }); + expect(results).toHaveLength(2); + const snapshot = await snapshotAtlasRegistry(registry, "2026-08-24T16:00:00.000Z"); + expect(snapshot.recordIds).toHaveLength(2); + expect(snapshot.snapshotId).toMatch(/^sha256:/); + }); + + it("prevents one immutable measurement from being published under two specimens", async () => { + const measurementId = await contentDigest({ same: "measurement" }); + const first = await createAtlasRecord({ + createdAt: "2026-08-24T15:30:00.000Z", + contributor: null, + specimen: { specimenId: "specimen-a", label: "A", objectFamily: "bell", material: "other", publicDescription: null }, + measurements: [{ measurementId, derivationIds: [] }], + }); + const second = await createAtlasRecord({ + createdAt: "2026-08-24T15:31:00.000Z", + contributor: null, + specimen: { specimenId: "specimen-b", label: "B", objectFamily: "bell", material: "other", publicDescription: null }, + measurements: [{ measurementId, derivationIds: [] }], + }); + const registry = await publishAtlasRecord(emptyAtlasRegistry(), first); + await expect(publishAtlasRecord(registry, second)).rejects.toThrow(/another specimen/); + }); + + it("supports verified evidence-backed specimen merges without rewriting historical records", async () => { + const first = await atlasRecord("alias-a", "Alias A", "a"); + const second = await atlasRecord("canonical-a", "Canonical A", "b"); + let registry = await publishAtlasRecord(emptyAtlasRegistry(), first); + registry = await publishAtlasRecord(registry, second); + const merge = await createAtlasSpecimenMerge({ + createdAt: "2026-08-24T16:05:00.000Z", + aliasSpecimenId: "alias-a", + canonicalSpecimenId: "canonical-a", + rationale: "software test of explicit merge policy", + supportingRecordIds: [first.atlasRecordId, second.atlasRecordId], + }); + expect(await verifyAtlasSpecimenMerge(merge)).toBe(true); + registry = await applyAtlasSpecimenMerge(registry, merge); + expect(registry.records).toHaveLength(2); + expect(registry.specimenMerges).toHaveLength(1); + const alias = searchAtlas(registry, { text: "Alias" })[0]; + expect(alias?.canonicalSpecimenId).toBe("canonical-a"); + }); + + it("rejects a content-tampered merge", async () => { + const first = await atlasRecord("alias-a", "Alias A", "a"); + const second = await atlasRecord("canonical-a", "Canonical A", "b"); + let registry = await publishAtlasRecord(emptyAtlasRegistry(), first); + registry = await publishAtlasRecord(registry, second); + const merge = await createAtlasSpecimenMerge({ + createdAt: "2026-08-24T16:05:00.000Z", + aliasSpecimenId: "alias-a", + canonicalSpecimenId: "canonical-a", + rationale: "original rationale", + supportingRecordIds: [first.atlasRecordId, second.atlasRecordId], + }); + const tampered = { ...merge, rationale: "mutated after signing" }; + expect(await verifyAtlasSpecimenMerge(tampered)).toBe(false); + await expect(applyAtlasSpecimenMerge(registry, tampered)).rejects.toThrow(/content verification/); + }); +}); diff --git a/packages/validation/test/atlas-service.test.ts b/packages/validation/test/atlas-service.test.ts new file mode 100644 index 00000000..e96e09f9 --- /dev/null +++ b/packages/validation/test/atlas-service.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { contentDigest, createAtlasRecord, MemoryResonanceAtlasServiceV1, verifyAtlasServiceConformance } from "../src"; + +async function record(specimenId: string, seed: string) { + return createAtlasRecord({ + createdAt: "2026-08-24T16:00:00.000Z", + contributor: { contributorId: "service-test", displayName: null }, + specimen: { specimenId, label: specimenId, objectFamily: "digital", material: "other", publicDescription: "service conformance fixture" }, + measurements: [{ measurementId: await contentDigest({ seed }), derivationIds: [] }], + }); +} + +describe("Resonance Atlas service contract", () => { + it("is idempotent, readable, searchable, and snapshot-capable", async () => { + const service = new MemoryResonanceAtlasServiceV1(); + const records = [await record("service-a", "a"), await record("service-b", "b")]; + const report = await verifyAtlasServiceConformance(service, records); + expect(report.passed).toBe(true); + expect(await service.count()).toBe(2); + expect(await service.get(records[0]!.atlasRecordId)).not.toBeNull(); + expect(await service.search({ text: "service" })).toHaveLength(2); + const snapshot = await service.snapshot("2026-08-24T16:59:00.000Z"); + expect(snapshot.recordIds).toHaveLength(2); + }); +}); diff --git a/packages/validation/test/atlas.test.ts b/packages/validation/test/atlas.test.ts new file mode 100644 index 00000000..f3fd1e84 --- /dev/null +++ b/packages/validation/test/atlas.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { + contentDigest, + createAtlasRecord, + createAtlasSnapshot, + verifyAtlasRecord, +} from "../src"; + +describe("Resonance Atlas provenance schema", () => { + it("publishes only content-addressed measurement references with explicit consent", async () => { + const measurementId = await contentDigest({ measurement: 1 }); + const derivationId = await contentDigest({ renderer: 1 }); + const record = await createAtlasRecord({ + createdAt: "2026-08-24T15:00:00.000Z", + contributor: { contributorId: "contributor-001", displayName: null }, + specimen: { + specimenId: "specimen-001", + label: "Digital twin specimen", + objectFamily: "reference-object", + material: "unknown", + publicDescription: "Synthetic validation specimen", + }, + measurements: [{ measurementId, derivationIds: [derivationId] }], + }); + expect(record.rawMicrophoneSamplesIncluded).toBe(false); + expect(record.publicationConsent).toBe(true); + expect(record.atlasRecordId).toMatch(/^sha256:[0-9a-f]{64}$/); + expect(await verifyAtlasRecord(record)).toBe(true); + }); + + it("builds deterministic corpus snapshots from a canonical record set", async () => { + const ids = [await contentDigest({ record: 2 }), await contentDigest({ record: 1 })]; + const first = await createAtlasSnapshot(ids, "2026-08-24T16:00:00.000Z"); + const second = await createAtlasSnapshot([...ids].reverse(), "2026-08-24T16:00:00.000Z"); + expect(first).toEqual(second); + expect(first.recordIds).toEqual([...ids].sort()); + }); +}); diff --git a/packages/validation/test/canonical-release.test.ts b/packages/validation/test/canonical-release.test.ts new file mode 100644 index 00000000..d6eef3d2 --- /dev/null +++ b/packages/validation/test/canonical-release.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from "vitest"; +import { + buildCanonicalReleaseVerdictForRevision, + createGateBPlan, + createGateCPlan, + evaluateCanonicalGateB, + evaluateGateARelease, + type EmpiricalCampaignProgress, + type GateBReview, + type GateCReview, +} from "../src"; +import { evidence, SOFTWARE_REVISION } from "./helpers"; + +function sixEvidence() { + return [ + evidence("metal-a", "metal", { specimenId: "metal-a", softwareRevision: SOFTWARE_REVISION }), + evidence("metal-b", "metal", { specimenId: "metal-b", softwareRevision: SOFTWARE_REVISION }), + evidence("glass-a", "glass", { specimenId: "glass-a", softwareRevision: SOFTWARE_REVISION }), + evidence("glass-b", "glass", { specimenId: "glass-b", softwareRevision: SOFTWARE_REVISION }), + evidence("ceramic-a", "ceramic", { specimenId: "ceramic-a", softwareRevision: SOFTWARE_REVISION }), + evidence("ceramic-b", "ceramic", { specimenId: "ceramic-b", softwareRevision: SOFTWARE_REVISION }), + ]; +} + +function progress(complete: boolean): EmpiricalCampaignProgress { + return { + campaignId: "campaign-1", + campaignSignature: "erc1-0000000000000000", + authorizedSoftwareRevision: SOFTWARE_REVISION, + plannedSpecimenCount: 12, + plannedSessionCount: 12, + collectedPlannedSessionCount: complete ? 12 : 11, + conformingCompleteSessionCount: complete ? 12 : 11, + passingSessionCount: 6, + analyticalFailureCount: 0, + unplannedSpecimenIds: [], + materialCoverage: ["metal", "glass", "ceramic"], + collectionComplete: complete, + specimens: [], + reasons: complete ? [] : ["one planned specimen is incomplete"], + }; +} + +function bReviews(plan: Awaited>): GateBReview[] { + return plan.targets.flatMap((target) => plan.reviewerIds.map((reviewerId, index) => ({ + reviewId: `b-${target.specimenId}-${index}`, + reviewerId, + objectLabel: target.objectLabel, + sessionId: target.sessionId, + attemptId: target.attemptId, + blinded: true, + presentationOrder: index === 0 ? "original-model" as const : "model-original" as const, + identity: 4 as const, + brightness: 4 as const, + decayCharacter: 4 as const, + artifactSeverity: 2 as const, + }))); +} + +function cReviews(plan: Awaited>): GateCReview[] { + return plan.targets.flatMap((target) => plan.reviewerIds.flatMap((reviewerId) => plan.devices.map((device) => ({ + reviewId: `c-${target.specimenId}-${reviewerId}-${device.deviceId}`, + reviewerId, + objectLabel: target.objectLabel, + sessionId: target.sessionId, + attemptId: target.attemptId, + deviceId: device.deviceId, + deviceClass: device.deviceClass, + identityAcrossRange: 4 as const, + timbreContinuity: 4 as const, + usefulSemitoneSpan: 12, + latencyAcceptable: true, + })))); +} + +describe("canonical release authority", () => { + it("cannot become releaseReady without campaign accounting and both frozen plans", () => { + const result = buildCanonicalReleaseVerdictForRevision( + sixEvidence(), [], [], "2026-08-24T16:00:00.000Z", SOFTWARE_REVISION, progress(false), undefined, undefined, + ); + expect(result.base.gateA.passed).toBe(true); + expect(result.releaseReady).toBe(false); + expect(result.reasons.join("\n")).toMatch(/campaign|Gate B plan|Gate C plan/); + }); + + it("becomes ready only with the exact B and C matrices", async () => { + const evidenceSet = sixEvidence(); + const gateA = evaluateGateARelease(evidenceSet); + const bPlan = await createGateBPlan(gateA, ["b-1", "b-2"]); + const gateBReviews = bReviews(bPlan); + const b = evaluateCanonicalGateB(bPlan, gateA, gateBReviews); + expect(b.passed).toBe(true); + const cPlan = await createGateCPlan(b.verdict, ["c-1", "c-2"], [ + { deviceId: "desktop-1", deviceClass: "desktop" }, + { deviceId: "mobile-1", deviceClass: "mobile" }, + ]); + const gateCReviews = cReviews(cPlan); + + const ready = buildCanonicalReleaseVerdictForRevision( + evidenceSet, + gateBReviews, + gateCReviews, + "2026-08-24T16:00:00.000Z", + SOFTWARE_REVISION, + progress(true), + bPlan, + cPlan, + ); + expect(ready.releaseReady).toBe(true); + expect(ready.gateB?.observedJudgmentCount).toBe(10); + expect(ready.gateC?.observedJudgmentCount).toBe(16); + + const missing = buildCanonicalReleaseVerdictForRevision( + evidenceSet, + gateBReviews, + gateCReviews.slice(0, -1), + "2026-08-24T16:00:00.000Z", + SOFTWARE_REVISION, + progress(true), + bPlan, + cPlan, + ); + expect(missing.releaseReady).toBe(false); + }); +}); diff --git a/packages/validation/test/precommitment.test.ts b/packages/validation/test/precommitment.test.ts new file mode 100644 index 00000000..a50d72e7 --- /dev/null +++ b/packages/validation/test/precommitment.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from "vitest"; +import { + deriveCampaignCollectionOrder, + empiricalCampaignSignature, + validateCampaignSelectionAgainstRegister, + validateCandidateRegister, + type CandidateRegisterV1, + type EmpiricalCampaignV1, +} from "../src"; +import { SOFTWARE_REVISION } from "./helpers"; + +function campaign(): EmpiricalCampaignV1 { + const core = [ + ["core-metal-1", "inv-001", "metal", "bell"], + ["core-metal-2", "inv-002", "metal", "bowl"], + ["core-glass-1", "inv-003", "glass", "glass"], + ["core-glass-2", "inv-004", "glass", "bottle"], + ["core-ceramic-1", "inv-005", "ceramic", "mug"], + ["core-ceramic-2", "inv-006", "ceramic", "plate"], + ] as const; + const challenge = [ + ["challenge-short-decay", "inv-007", "wood", "block"], + ["challenge-broad", "inv-008", "plastic", "container"], + ["challenge-coupled", "inv-009", "composite", "assembly"], + ["challenge-high-q", "inv-010", "glass", "vessel"], + ["challenge-low-snr", "inv-011", "other", "small-object"], + ["challenge-degenerate", "inv-012", "other", "symmetric-object"], + ] as const; + return { + schemaVersion: 1, + campaignContractVersion: "empirical-campaign-1", + campaignId: "digital-precommitment-test", + createdAt: "2026-08-24T12:00:00.000Z", + authorizedSoftwareRevision: SOFTWARE_REVISION, + specimens: [ + ...core.map(([slot, inventory, material, family]) => ({ + specimenId: `${slot}--${inventory}`, + label: inventory, + material, + cohort: "release-core" as const, + objectFamily: family, + targetSessions: 1, + protocol: { fixedSetup: true as const, microphoneDistanceCm: 20, striker: "wooden dowel", strikeLocation: "mark", supportCondition: "fixed" }, + })), + ...challenge.map(([slot, inventory, material, family]) => ({ + specimenId: `${slot}--${inventory}`, + label: inventory, + material, + cohort: "challenge" as const, + objectFamily: family, + targetSessions: 1, + protocol: { fixedSetup: true as const, microphoneDistanceCm: 20, striker: "wooden dowel", strikeLocation: "mark", supportCondition: "fixed" }, + })), + ], + }; +} + +function register(): CandidateRegisterV1 { + return { + schemaVersion: 1, + registerContractVersion: "candidate-register-1", + createdAt: "2026-08-24T11:00:00.000Z", + acousticAuditionPerformed: false, + entries: campaign().specimens.map((specimen) => { + const separator = specimen.specimenId.indexOf("--"); + const slot = specimen.specimenId.slice(0, separator); + const inventory = specimen.specimenId.slice(separator + 2); + return { + inventoryId: inventory, + label: specimen.label, + objectFamily: specimen.objectFamily, + material: specimen.material, + safetyStatus: "eligible" as const, + supportDescription: "safe fixed support", + strikeLocationMarkable: true, + eligibleSlotIds: [slot], + exclusionReason: null, + }; + }), + }; +} + +describe("physical precommitment artifacts", () => { + it("validates a non-acoustic candidate register and its campaign bindings", () => { + const candidates = register(); + expect(validateCandidateRegister(candidates)).toEqual([]); + expect(validateCampaignSelectionAgainstRegister(campaign(), candidates)).toEqual([]); + }); + + it("rejects reuse of one physical inventory item", () => { + const value = campaign(); + const mutated: EmpiricalCampaignV1 = { + ...value, + specimens: value.specimens.map((specimen, index) => index === 1 + ? { ...specimen, specimenId: "core-metal-2--inv-001", objectFamily: "bell" } + : specimen), + }; + expect(validateCampaignSelectionAgainstRegister(mutated, register()).join("\n")).toMatch(/more than one campaign slot|not predeclared eligible/); + }); + + it("derives a stable 12-entry release-core/challenge interleaving", async () => { + const value = campaign(); + const signature = empiricalCampaignSignature(value); + const first = await deriveCampaignCollectionOrder(value, signature); + const second = await deriveCampaignCollectionOrder(value, signature); + expect(first).toEqual(second); + expect(first.entries).toHaveLength(12); + expect(first.entries.map((entry) => entry.cohort)).toEqual(Array.from({ length: 12 }, (_, index) => index % 2 === 0 ? "release-core" : "challenge")); + expect(new Set(first.entries.map((entry) => entry.specimenId)).size).toBe(12); + expect(first.entries.every((entry) => /^sha256:[0-9a-f]{64}$/.test(entry.orderingDigest))).toBe(true); + }); +}); diff --git a/packages/validation/test/provenance.test.ts b/packages/validation/test/provenance.test.ts new file mode 100644 index 00000000..0fc0241d --- /dev/null +++ b/packages/validation/test/provenance.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; +import { + contentDigest, + createDerivationRecord, + createMeasurementRecord, + verifyDerivationRecord, + verifyMeasurementRecord, +} from "../src"; +import { fingerprint, SOFTWARE_REVISION } from "./helpers"; + +describe("measurement provenance", () => { + it("content-addresses one immutable measurement independently of later derivations", async () => { + const fp = fingerprint(); + const measurement = await createMeasurementRecord({ + createdAt: "2026-08-24T12:00:00.000Z", + specimenId: "specimen-001", + sessionId: "session-001", + attemptId: 5, + material: "metal", + acquisitionContractVersion: "acquisition-browser-1", + softwareRevision: SOFTWARE_REVISION, + fingerprintAlgorithmVersion: fp.algorithmVersion, + setup: { + fixedSetup: true, + microphoneDistanceCm: 20, + striker: "wooden dowel", + strikeLocation: "marked rim", + supportCondition: "fixed support", + }, + station: { + stationId: "station-001", + deviceDescription: "digital station", + operatingSystem: "test-os", + runtime: "test-browser", + microphoneDescription: "simulated microphone", + captureSettings: null, + }, + fingerprint: fp, + }); + + expect(measurement.rawMicrophoneSamplesIncluded).toBe(false); + expect(measurement.measurementId).toMatch(/^sha256:[0-9a-f]{64}$/); + expect(await verifyMeasurementRecord(measurement)).toBe(true); + + const renderer = await createDerivationRecord({ + measurementId: measurement.measurementId, + kind: "renderer", + algorithmVersion: "modal-renderer-2", + configDigest: await contentDigest({ attackMs: 5, normalization: "peak" }), + artifactDigest: await contentDigest({ renderedFingerprint: measurement.fingerprint }), + createdAt: "2026-08-24T13:00:00.000Z", + }); + const instrument = await createDerivationRecord({ + measurementId: measurement.measurementId, + kind: "instrument", + algorithmVersion: "modal-instrument-3", + configDigest: await contentDigest({ anchor: "dominant-mode" }), + artifactDigest: await contentDigest({ playable: true }), + createdAt: "2026-08-24T14:00:00.000Z", + }); + + expect(renderer.measurementId).toBe(measurement.measurementId); + expect(instrument.measurementId).toBe(measurement.measurementId); + expect(renderer.derivationId).not.toBe(instrument.derivationId); + expect(await verifyDerivationRecord(renderer)).toBe(true); + expect(await verifyDerivationRecord(instrument)).toBe(true); + }); + + it("detects tampering without mutable provenance state", async () => { + const digest = await contentDigest({ a: 1, b: [2, 3] }); + const reordered = await contentDigest({ b: [2, 3], a: 1 }); + expect(digest).toBe(reordered); + }); +}); diff --git a/packages/validation/test/repository.test.ts b/packages/validation/test/repository.test.ts new file mode 100644 index 00000000..405d8e4b --- /dev/null +++ b/packages/validation/test/repository.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from "vitest"; +import type { AcousticFingerprintV1 } from "@everything-rings/dsp"; +import { + contentDigest, + createAtlasRecord, + createDerivationRecord, + createMeasurementRecord, + emptyResearchRepository, + ingestDerivation, + ingestMeasurement, + publishRepositoryAtlasRecord, + verifyResearchRepositoryIntegrity, +} from "../src"; + +const fingerprint: AcousticFingerprintV1 = { + version: 1, + algorithmVersion: "er-dsp-2", + sampleRate: 48_000, + durationSeconds: 2, + modes: [440, 880, 1320].map((frequencyHz, index) => ({ + frequencyHz, + relativeAmplitude: 1 / (index + 1), + decaySeconds: 0.8 / (index + 1), + q: 100, + confidence: 0.9, + diagnostics: { prominenceDb: 20, persistenceSeconds: 0.2, frequencyStdCents: 2, decayFitScore: 0.95, observationCount: 12 }, + })), +}; + +async function measurement() { + return createMeasurementRecord({ + createdAt: "2026-08-24T15:30:00.000Z", + specimenId: "specimen-repo", + sessionId: "session-repo", + attemptId: 1, + material: "other", + acquisitionContractVersion: "digital-acquisition-1", + softwareRevision: "0123456789abcdef0123456789abcdef01234567", + fingerprintAlgorithmVersion: "er-dsp-2", + setup: { fixedSetup: true, microphoneDistanceCm: 20, striker: "digital", strikeLocation: "A", supportCondition: "fixed" }, + station: { + stationId: "digital-station", + deviceDescription: "software", + operatingSystem: "ci", + runtime: "vitest", + microphoneDescription: null, + captureSettings: { sampleRate: 48_000, channelCount: 1 }, + }, + fingerprint, + }); +} + +describe("research repository integrity", () => { + it("closes measurement -> derivation -> Atlas provenance before publication", async () => { + const m = await measurement(); + let repository = await ingestMeasurement(emptyResearchRepository(), m); + const d = await createDerivationRecord({ + measurementId: m.measurementId, + kind: "similarity", + algorithmVersion: "sonic-twin-baseline-1", + configDigest: await contentDigest({ thresholds: "default" }), + artifactDigest: await contentDigest({ score: 0.8 }), + createdAt: "2026-08-24T15:31:00.000Z", + }); + repository = await ingestDerivation(repository, d); + const record = await createAtlasRecord({ + createdAt: "2026-08-24T15:32:00.000Z", + contributor: null, + specimen: { specimenId: m.specimenId, label: "Digital reference", objectFamily: "reference", material: "other", publicDescription: null }, + measurements: [{ measurementId: m.measurementId, derivationIds: [d.derivationId] }], + }); + repository = await publishRepositoryAtlasRecord(repository, record); + const integrity = await verifyResearchRepositoryIntegrity(repository); + expect(integrity.valid).toBe(true); + expect(integrity.measurementCount).toBe(1); + expect(integrity.derivationCount).toBe(1); + expect(integrity.atlasRecordCount).toBe(1); + expect(integrity.atlasMergeCount).toBe(0); + }); + + it("rejects derivations before their immutable measurement root exists", async () => { + const m = await measurement(); + const d = await createDerivationRecord({ + measurementId: m.measurementId, + kind: "renderer", + algorithmVersion: "renderer-test", + configDigest: await contentDigest({ config: 1 }), + artifactDigest: await contentDigest({ artifact: 1 }), + createdAt: "2026-08-24T15:31:00.000Z", + }); + await expect(ingestDerivation(emptyResearchRepository(), d)).rejects.toThrow(/root measurement/); + }); + + it("rejects Atlas publication when a referenced derivation is absent", async () => { + const m = await measurement(); + const repository = await ingestMeasurement(emptyResearchRepository(), m); + const missingDerivation = await contentDigest({ missing: true }); + const record = await createAtlasRecord({ + createdAt: "2026-08-24T15:32:00.000Z", + contributor: null, + specimen: { specimenId: m.specimenId, label: "Digital reference", objectFamily: "reference", material: "other", publicDescription: null }, + measurements: [{ measurementId: m.measurementId, derivationIds: [missingDerivation] }], + }); + await expect(publishRepositoryAtlasRecord(repository, record)).rejects.toThrow(/absent/); + }); + + it("detects post-ingest Atlas content tampering during whole-repository verification", async () => { + const m = await measurement(); + let repository = await ingestMeasurement(emptyResearchRepository(), m); + const record = await createAtlasRecord({ + createdAt: "2026-08-24T15:32:00.000Z", + contributor: null, + specimen: { specimenId: m.specimenId, label: "Original", objectFamily: "reference", material: "other", publicDescription: null }, + measurements: [{ measurementId: m.measurementId, derivationIds: [] }], + }); + repository = await publishRepositoryAtlasRecord(repository, record); + const tamperedRecord = { ...record, specimen: { ...record.specimen, label: "Tampered" } }; + const tampered = { ...repository, atlas: { ...repository.atlas, records: [tamperedRecord] } }; + const integrity = await verifyResearchRepositoryIntegrity(tampered); + expect(integrity.valid).toBe(false); + expect(integrity.reasons.some((reason) => reason.includes("failed content verification"))).toBe(true); + }); +}); diff --git a/packages/validation/test/review-plan-parse.test.ts b/packages/validation/test/review-plan-parse.test.ts new file mode 100644 index 00000000..319c8c73 --- /dev/null +++ b/packages/validation/test/review-plan-parse.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; +import { parseGateBPlan, parseGateCPlan } from "../src"; +import { SOFTWARE_REVISION } from "./helpers"; + +const DIGEST = `sha256:${"0".repeat(64)}`; +const targets = Array.from({ length: 5 }, (_, index) => ({ + specimenId: `specimen-${index + 1}`, + objectLabel: `object ${index + 1}`, + sessionId: `session-${index + 1}`, + attemptId: 5, +})); + +describe("canonical review plan parsers", () => { + it("accepts exactly five distinct Gate B targets, two reviewers, and an upstream verdict digest", () => { + const result = parseGateBPlan({ + schemaVersion: 1, + planContractVersion: "gate-b-plan-1", + gateARevision: SOFTWARE_REVISION, + gateAVerdictSha256: DIGEST, + reviewerIds: ["reviewer-a", "reviewer-b"], + targets, + }); + expect(result.ok).toBe(true); + }); + + it("rejects duplicate Gate B specimens and non-exact matrix size", () => { + const result = parseGateBPlan({ + schemaVersion: 1, + planContractVersion: "gate-b-plan-1", + gateARevision: SOFTWARE_REVISION, + gateAVerdictSha256: DIGEST, + reviewerIds: ["reviewer-a", "reviewer-b"], + targets: [...targets.slice(0, 4), { ...targets[4], specimenId: targets[0]?.specimenId }], + }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toMatch(/distinct/); + }); + + it("accepts exactly four Gate C targets, two reviewers, and two devices including mobile", () => { + const result = parseGateCPlan({ + schemaVersion: 1, + planContractVersion: "gate-c-plan-1", + gateBVerdictSha256: DIGEST, + reviewerIds: ["reviewer-c", "reviewer-d"], + devices: [ + { deviceId: "desktop-1", deviceClass: "desktop" }, + { deviceId: "phone-1", deviceClass: "mobile" }, + ], + targets: targets.slice(0, 4), + }); + expect(result.ok).toBe(true); + }); + + it("rejects Gate C plans without a mobile device", () => { + const result = parseGateCPlan({ + schemaVersion: 1, + planContractVersion: "gate-c-plan-1", + gateBVerdictSha256: DIGEST, + reviewerIds: ["reviewer-c", "reviewer-d"], + devices: [ + { deviceId: "desktop-1", deviceClass: "desktop" }, + { deviceId: "desktop-2", deviceClass: "desktop" }, + ], + targets: targets.slice(0, 4), + }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toMatch(/mobile/); + }); + + it("rejects missing or malformed upstream verdict digests", () => { + const result = parseGateBPlan({ + schemaVersion: 1, + planContractVersion: "gate-b-plan-1", + gateARevision: SOFTWARE_REVISION, + gateAVerdictSha256: "not-a-digest", + reviewerIds: ["reviewer-a", "reviewer-b"], + targets, + }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toMatch(/sha256/); + }); +}); diff --git a/packages/validation/test/review-plans.test.ts b/packages/validation/test/review-plans.test.ts new file mode 100644 index 00000000..01d330ad --- /dev/null +++ b/packages/validation/test/review-plans.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from "vitest"; +import { + createGateBPlan, + createGateCPlan, + evaluateCanonicalGateB, + evaluateCanonicalGateC, + evaluateGateARelease, + evaluateGateBRelease, + type GateBReview, + type GateCReview, +} from "../src"; +import { evidence } from "./helpers"; + +function sixObjects() { + return [ + evidence("metal-a", "metal", { specimenId: "metal-a" }), + evidence("metal-b", "metal", { specimenId: "metal-b" }), + evidence("glass-a", "glass", { specimenId: "glass-a" }), + evidence("glass-b", "glass", { specimenId: "glass-b" }), + evidence("ceramic-a", "ceramic", { specimenId: "ceramic-a" }), + evidence("ceramic-b", "ceramic", { specimenId: "ceramic-b" }), + ]; +} + +function gateBReviews(plan: ReturnType): GateBReview[] { + return plan.targets.flatMap((target) => plan.reviewerIds.map((reviewerId, index) => ({ + reviewId: `b-${target.specimenId}-${index}`, + reviewerId, + objectLabel: target.objectLabel, + sessionId: target.sessionId, + attemptId: target.attemptId, + blinded: true, + presentationOrder: index % 2 === 0 ? "original-model" as const : "model-original" as const, + identity: 4 as const, + brightness: 4 as const, + decayCharacter: 4 as const, + artifactSeverity: 2 as const, + }))); +} + +function gateCReviews(plan: ReturnType): GateCReview[] { + return plan.targets.flatMap((target) => plan.reviewerIds.flatMap((reviewerId) => plan.devices.map((device) => ({ + reviewId: `c-${target.specimenId}-${reviewerId}-${device.deviceId}`, + reviewerId, + objectLabel: target.objectLabel, + sessionId: target.sessionId, + attemptId: target.attemptId, + deviceId: device.deviceId, + deviceClass: device.deviceClass, + identityAcrossRange: 4 as const, + timbreContinuity: 4 as const, + usefulSemitoneSpan: 12, + latencyAcceptable: true, + })))); +} + +describe("canonical post-collection plans", () => { + it("selects exactly five Gate B targets including metal, glass, and ceramic", () => { + const gateA = evaluateGateARelease(sixObjects()); + expect(gateA.passed).toBe(true); + const plan = createGateBPlan(gateA, ["reviewer-a", "reviewer-b"]); + expect(plan.targets).toHaveLength(5); + const materialBySpecimen = new Map(gateA.sessions.map((session) => [session.specimenId, session.material])); + const materials = new Set(plan.targets.map((target) => materialBySpecimen.get(target.specimenId))); + expect(materials.has("metal")).toBe(true); + expect(materials.has("glass")).toBe(true); + expect(materials.has("ceramic")).toBe(true); + }); + + it("rejects Gate B when one fixed-panel judgment is missing", () => { + const gateA = evaluateGateARelease(sixObjects()); + const plan = createGateBPlan(gateA, ["reviewer-a", "reviewer-b"]); + const reviews = gateBReviews(plan); + expect(evaluateCanonicalGateB(plan, gateA, reviews).passed).toBe(true); + const missing = reviews.slice(0, -1); + const result = evaluateCanonicalGateB(plan, gateA, missing); + expect(result.passed).toBe(false); + expect(result.observedJudgmentCount).toBe(9); + }); + + it("requires the full four-target by two-reviewer by two-device Gate C matrix", () => { + const gateA = evaluateGateARelease(sixObjects()); + const gateBPlan = createGateBPlan(gateA, ["reviewer-a", "reviewer-b"]); + const gateBReviewsAll = gateBReviews(gateBPlan); + const canonicalB = evaluateCanonicalGateB(gateBPlan, gateA, gateBReviewsAll); + expect(canonicalB.passed).toBe(true); + + const gateCPlan = createGateCPlan(canonicalB.verdict, ["reviewer-c", "reviewer-d"], [ + { deviceId: "desktop-1", deviceClass: "desktop" }, + { deviceId: "phone-1", deviceClass: "mobile" }, + ]); + const reviews = gateCReviews(gateCPlan); + expect(reviews).toHaveLength(16); + expect(evaluateCanonicalGateC(gateCPlan, canonicalB.verdict, reviews).passed).toBe(true); + + const missingCell = reviews.filter((_, index) => index !== 0); + const result = evaluateCanonicalGateC(gateCPlan, canonicalB.verdict, missingCell); + expect(result.passed).toBe(false); + expect(result.observedJudgmentCount).toBe(15); + }); + + it("rejects a Gate C plan without a mobile device", () => { + const gateA = evaluateGateARelease(sixObjects()); + const bPlan = createGateBPlan(gateA, ["reviewer-a", "reviewer-b"]); + const bVerdict = evaluateGateBRelease(gateA, gateBReviews(bPlan)); + expect(() => createGateCPlan(bVerdict, ["reviewer-c", "reviewer-d"], [ + { deviceId: "desktop-1", deviceClass: "desktop" }, + { deviceId: "desktop-2", deviceClass: "desktop" }, + ])).toThrow(/mobile/); + }); +}); diff --git a/packages/validation/test/stable-sha256.test.ts b/packages/validation/test/stable-sha256.test.ts new file mode 100644 index 00000000..5fa4a15d --- /dev/null +++ b/packages/validation/test/stable-sha256.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; +import { stableSha256Hex } from "../src/stable-sha256"; + +describe("stable SHA-256", () => { + it("matches standard ASCII test vectors", () => { + expect(stableSha256Hex("")).toBe("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"); + expect(stableSha256Hex("abc")).toBe("ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"); + }); + + it("is deterministic for unicode input", () => { + expect(stableSha256Hex("everything rings — 🔔")).toBe(stableSha256Hex("everything rings — 🔔")); + expect(stableSha256Hex("everything rings — 🔔")).not.toBe(stableSha256Hex("everything rings")); + }); +}); diff --git a/packages/validation/test/station-calibration.test.ts b/packages/validation/test/station-calibration.test.ts new file mode 100644 index 00000000..97952f5e --- /dev/null +++ b/packages/validation/test/station-calibration.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "vitest"; +import type { AcousticFingerprintV1 } from "@everything-rings/dsp"; +import { buildAcousticObjectModel } from "@everything-rings/fingerprint"; +import { + createStationCalibrationProtocol, + evaluateStationCalibration, + evaluateVerifiedStationCalibration, + verifyStationCalibrationProtocol, +} from "../src"; + +function fp(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 }, + })), + }; +} + +async function protocol() { + const model = buildAcousticObjectModel([ + { observationId: "r1", specimenId: "reference-1", fingerprint: fp([440, 880, 1320]) }, + { observationId: "r2", specimenId: "reference-1", fingerprint: fp([441, 882, 1323]) }, + ]); + return createStationCalibrationProtocol({ + createdAt: "2026-08-24T15:30:00.000Z", + referencePopulation: "digital-station-reference-v1", + referenceSpecimenId: "reference-1", + referenceObjectModel: model, + minimumCoverage: 1, + maximumMedianFrequencyDistanceCents: 35, + minimumMatchedModes: 3, + }); +} + +describe("measurement-station calibration", () => { + it("passes a digital station observation close to its verified frozen reference object model", async () => { + const frozen = await protocol(); + expect(await verifyStationCalibrationProtocol(frozen)).toBe(true); + const verdict = await evaluateVerifiedStationCalibration(frozen, { + stationId: "station-digital-a", + createdAt: "2026-08-24T15:31:00.000Z", + specimenId: "reference-1", + fingerprint: fp([440.5, 881, 1321.5]), + }); + expect(verdict.passed).toBe(true); + expect(verdict.comparison.coverage).toBe(1); + }); + + it("fails a digital station observation that drifts outside its frozen contract", async () => { + const frozen = await protocol(); + const verdict = evaluateStationCalibration(frozen, { + stationId: "station-digital-b", + createdAt: "2026-08-24T15:31:00.000Z", + specimenId: "reference-1", + fingerprint: fp([500, 1000, 1500]), + }); + expect(verdict.passed).toBe(false); + expect(verdict.reasons.length).toBeGreaterThan(0); + }); + + it("rejects a protocol mutated after its content address was created", async () => { + const frozen = await protocol(); + const tampered = { ...frozen, maximumMedianFrequencyDistanceCents: 500 }; + expect(await verifyStationCalibrationProtocol(tampered)).toBe(false); + await expect(evaluateVerifiedStationCalibration(tampered, { + stationId: "station-digital-c", + createdAt: "2026-08-24T15:31:00.000Z", + specimenId: "reference-1", + fingerprint: fp([440.5, 881, 1321.5]), + })).rejects.toThrow(/content verification/); + }); +}); diff --git a/scripts/browser-smoke.sh b/scripts/browser-smoke.sh index 23b2ac26..0758ec6e 100755 --- a/scripts/browser-smoke.sh +++ b/scripts/browser-smoke.sh @@ -64,6 +64,8 @@ check_route "/?campaign=1" "Precommitted physical collection" "campaign" check_route "/?release=1" "Empirical release gates" "release" check_route "/?gate-b=1" "Post-collection blinded reconstruction" "gate-b" check_route "/?gate-c=1" "Post-Gate-B playable identity" "gate-c" +check_route "/?twin=1" "Object models, retrieval, held-out calibration, abstention" "twin" +check_route "/?atlas=1" "Content-addressed acoustic provenance" "atlas" check_route "/?campaign-author=1&release=1" "Empirical release gates" "release-author-precedence" check_route "/?gate-b=1&release=1" "Empirical release gates" "release-review-precedence" check_route "/?campaign-author=1&campaign=1" "Freeze the experiment before the first strike." "author-precedence" diff --git a/scripts/session-lifecycle-e2e.mjs b/scripts/session-lifecycle-e2e.mjs index a990e427..92da43c3 100644 --- a/scripts/session-lifecycle-e2e.mjs +++ b/scripts/session-lifecycle-e2e.mjs @@ -5,8 +5,8 @@ const browser = process.env.BROWSER; const baseUrl = process.env.BASE_URL; if (!browser || !baseUrl) throw new Error("BROWSER and BASE_URL are required"); -const profileDir = `/tmp/everything-rings-session-lifecycle-${process.pid}`; -const debuggingPort = 10120 + (process.pid % 120); +const profileDir = `/tmp/everything-rings-empirical-lifecycle-${process.pid}`; +const debuggingPort = 10300 + (process.pid % 100); const chrome = spawn(browser, [ "--headless=new", "--no-sandbox", @@ -20,6 +20,7 @@ const chrome = spawn(browser, [ `--user-data-dir=${profileDir}`, `${baseUrl}/`, ], { stdio: ["ignore", "ignore", "pipe"] }); +const chromeExit = new Promise((resolve) => chrome.once("exit", resolve)); let stderr = ""; chrome.stderr.on("data", (chunk) => { stderr += String(chunk); }); @@ -37,7 +38,7 @@ async function browserTarget() { } await sleep(100); } - throw new Error(`Could not connect to session lifecycle browser target. ${stderr}`); + throw new Error(`Could not connect to empirical lifecycle browser target. ${stderr}`); } const target = await browserTarget(); @@ -103,7 +104,16 @@ async function clickButton(label) { async function assertLanding(label) { await waitFor(`document.body?.innerText.includes("START LISTENING") ?? false`, label); const body = await evaluate("document.body?.innerText ?? ''"); - if (body.includes("microphone could not be started")) throw new Error(`${label} surfaced a cancellation as a microphone failure`); + if (body.includes("microphone could not be started")) { + throw new Error(`${label} surfaced cancellation as a microphone failure`); + } +} + +async function dispatchPageHide() { + await evaluate(`(() => { + window.dispatchEvent(new Event("pagehide")); + return true; + })()`); } try { @@ -112,12 +122,7 @@ try { await command("Page.addScriptToEvaluateOnNewDocument", { source: `(() => { const mediaDevices = navigator.mediaDevices; - window.__everythingRingsLifecycle = { - calls: 0, - streams: [], - gate: null, - release: null, - }; + window.__everythingRingsLifecycle = { calls: 0, streams: [], gate: null, release: null }; window.__everythingRingsHoldNextMic = () => { let release; const promise = new Promise((resolve) => { release = resolve; }); @@ -160,28 +165,40 @@ try { await clickButton("START LISTENING"); await waitFor(`window.__everythingRingsLifecycle.calls === 2`, "second microphone request"); await waitFor(`window.__everythingRingsLifecycle.streams.length === 2`, "second microphone stream"); - await waitFor(`window.__everythingRingsLifecycle.streams[1].getTracks().some((track) => track.readyState === "live")`, "second generation owns a live microphone"); + await waitFor(`window.__everythingRingsLifecycle.streams[1].getTracks().some((track) => track.readyState === "live")`, "second session owns a live microphone"); await clickButton("CANCEL"); - await assertLanding("landing after second-generation cancel"); - await waitFor(`window.__everythingRingsLifecycle.streams.every((stream) => stream.getTracks().every((track) => track.readyState === "ended"))`, "second-generation cleanup"); + await assertLanding("landing after second-session cancel"); + await waitFor(`window.__everythingRingsLifecycle.streams[1].getTracks().every((track) => track.readyState === "ended")`, "second-session cleanup"); await evaluate("window.__everythingRingsHoldNextMic()"); await clickButton("START LISTENING"); await waitFor(`window.__everythingRingsLifecycle.calls === 3`, "third microphone request"); - await command("Page.navigate", { url: `${baseUrl}/#ring=%%%` }); - await waitFor(`location.hash === "#ring=%%%" && document.body?.innerText.includes("shared ring link could not be opened")`, "shared route supersedes pending startup"); + await dispatchPageHide(); + await assertLanding("landing after pagehide during unresolved startup"); await evaluate("window.__everythingRingsReleaseMic()"); - await waitFor(`window.__everythingRingsLifecycle.streams.length === 3`, "late route-transition microphone resolution"); - await waitFor(`window.__everythingRingsLifecycle.streams[2].getTracks().every((track) => track.readyState === "ended")`, "route-transition microphone cleanup"); - await waitFor(`document.body?.innerText.includes("shared ring link could not be opened") && document.body?.innerText.includes("START LISTENING")`, "shared recovery remains authoritative"); - if (await evaluate(`document.body?.innerText.includes("microphone could not be started") ?? false`)) { - throw new Error("Route cancellation surfaced as microphone startup failure"); - } + await waitFor(`window.__everythingRingsLifecycle.streams.length === 3`, "late pagehide microphone resolution"); + await waitFor(`window.__everythingRingsLifecycle.streams[2].getTracks().every((track) => track.readyState === "ended")`, "late pagehide microphone cleanup"); + await assertLanding("landing after late pagehide cleanup"); - console.log("Session lifecycle E2E passed: unresolved start → cancel → late cleanup → new owner → cancel → shared-route supersession with no stale microphone state."); + await clickButton("START LISTENING"); + await waitFor(`window.__everythingRingsLifecycle.calls === 4`, "fourth microphone request"); + await waitFor(`window.__everythingRingsLifecycle.streams.length === 4`, "fourth microphone stream"); + await waitFor(`window.__everythingRingsLifecycle.streams[3].getTracks().some((track) => track.readyState === "live")`, "fourth session owns a live microphone"); + await dispatchPageHide(); + await assertLanding("landing after active pagehide"); + await waitFor(`window.__everythingRingsLifecycle.streams[3].getTracks().every((track) => track.readyState === "ended")`, "active pagehide microphone cleanup"); + + const body = await evaluate("document.body?.innerText ?? ''"); + if (body.includes("microphone could not be started")) throw new Error("Lifecycle cancellation surfaced as microphone startup failure"); + + console.log("Empirical session lifecycle E2E passed: unresolved start → cancel → late cleanup → new owner → cancel → pagehide teardown → zero stale microphone state."); } finally { try { socket.close(); } catch { /* already closed */ } chrome.kill("SIGTERM"); - await sleep(100); - rmSync(profileDir, { recursive: true, force: true }); + await Promise.race([chromeExit, sleep(2_000)]); + try { + rmSync(profileDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + } catch { + // Runner-local browser profile cleanup is not an empirical assertion. + } }