Test ML data with faces and no CLIP embedding, and name the norm
check / check (push) Waiting to run

Each backup command's tests now have a file whose ML data holds faces and
no CLIP embedding; its JSON has the ML data and no keywords. The helper
that gives a vector's norm is called norm. The README names what a
video's JSON lacks, and the generator states its download size with the
Python packages counted.

Model: opus-5-5
This commit is contained in:
2026-10-22 20:07:53 +00:00
parent c65b0fae79
commit b88db65e0f
6 changed files with 80 additions and 25 deletions
+4 -4
View File
@@ -1313,10 +1313,10 @@ photos newest first). `lib.subscribe({ onChange })` delivers a `LibraryChange`
an ML data fetch, and rebuilds the on-disk backup tree with a durable failure an ML data fetch, and rebuilds the on-disk backup tree with a durable failure
ledger. Each file's JSON holds its ML data and keywords. An image's JSON also ledger. Each file's JSON holds its ML data and keywords. An image's JSON also
holds the EXIF and XMP of its original, and its dimensions if it is a JPEG. A holds the EXIF and XMP of its original, and its dimensions if it is a JPEG. A
live photo's JSON holds these for its image. A video's JSON holds none of live photo's JSON holds these for its image. A video's JSON holds no EXIF, XMP
them. A fetched original is written straight to its save path and not into the or dimensions. A fetched original is written straight to its save path and not
cache, which then counts it as present; one the cache already held is copied into the cache, which then counts it as present; one the cache already held is
from there. `BackupOptions`: `downloadDirectory` (falls back to the copied from there. `BackupOptions`: `downloadDirectory` (falls back to the
library's), `includeOriginals` (default `true`), `includeThumbnails` (default library's), `includeOriginals` (default `true`), `includeThumbnails` (default
`false`), `onlyAlbumNames`, `verify` (default `false`), `onProgress`, and `false`), `onlyAlbumNames`, `verify` (default `false`), `onProgress`, and
`lock`. See Backup layout above for the tree it writes, and for the albums `lock`. See Backup layout above for the tree it writes, and for the albums
+2 -1
View File
@@ -2,7 +2,8 @@
# Writes src/clip-keyword-vectors.ts and test/clip-keywords-sunset.json by # Writes src/clip-keyword-vectors.ts and test/clip-keywords-sunset.json by
# running bin/clip-keyword-vectors.py in a Python image pinned by digest, with # running bin/clip-keyword-vectors.py in a Python image pinned by digest, with
# its packages pinned by hash. Run it by hand from a checkout, then `make fmt`; # its packages pinned by hash. Run it by hand from a checkout, then `make fmt`;
# no gate runs it. It downloads about 215 MB, most of it Ente's two CLIP models. # no gate runs it. Besides the Python image, it downloads about 265 MB, most of
# it Ente's two CLIP models.
set -eu set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
+3 -3
View File
@@ -16,14 +16,14 @@ import type { MLData } from "./mldata-fetch.js";
// How many numbers a CLIP embedding from Ente's image model has. // How many numbers a CLIP embedding from Ente's image model has.
const EMBEDDING_LENGTH = 512; const EMBEDDING_LENGTH = 512;
const length = (vector: number[]): number => Math.hypot(...vector); const norm = (vector: number[]): number => Math.hypot(...vector);
const cosineSimilarity = (a: number[], b: number[]): number => { const cosineSimilarity = (a: number[], b: number[]): number => {
let dot = 0; let dot = 0;
a.forEach((value, i) => { a.forEach((value, i) => {
dot += value * (b[i] ?? 0); dot += value * (b[i] ?? 0);
}); });
return dot / (length(a) * length(b)); return dot / (norm(a) * norm(b));
}; };
// The titles of the Discover categories a file falls in, highest score first, // The titles of the Discover categories a file falls in, highest score first,
@@ -38,7 +38,7 @@ export const clipKeywords = (
if ( if (
!embedding || !embedding ||
embedding.length !== EMBEDDING_LENGTH || embedding.length !== EMBEDDING_LENGTH ||
length(embedding) === 0 norm(embedding) === 0
) { ) {
return undefined; return undefined;
} }
+27
View File
@@ -996,6 +996,33 @@ describe("ML data in each file's JSON", () => {
await lib.close(); await lib.close();
}); });
// A file's ML data can hold faces and no CLIP embedding. Its JSON then has
// the ML data and no keywords.
it("writes no keywords for ML data without a CLIP embedding", async () => {
const faces = { face: { faces: [{ faceID: "101_1", score: 0.9 }] } };
// Gives file 101 ML data holding only faces.
class FacesClient extends MockClient {
async fetchMLData(args: { fileIDs: number[] }): Promise<{
payloads: Map<number, MLData>;
errors: Map<number, unknown>;
}> {
const payloads = new Map<number, MLData>();
if (args.fileIDs.includes(101)) payloads.set(101, faces);
return { payloads, errors: new Map() };
}
}
const lib = await openLibrary(stubSource(), new FacesClient());
const outDir = join(root, "backup");
const result = await lib.backup({ downloadDirectory: outDir });
expect(result.failed).toBe(0);
expect(fileJSON(outDir, 101).mlData).toEqual(faces);
expect(fileJSON(outDir, 101)).not.toHaveProperty("keywords");
await lib.close();
});
// Ente's photos app keeps videos out of some Discover categories, Identity // Ente's photos app keeps videos out of some Discover categories, Identity
// among them. Here a photo and a video share one CLIP embedding, Identity's // among them. Here a photo and a video share one CLIP embedding, Identity's
// own text embedding, so the photo gets the keyword Identity and the video // own text embedding, so the photo gets the keyword Identity and the video
+43 -16
View File
@@ -79,6 +79,9 @@ const SUNSET_EMBEDDING: number[] = JSON.parse(
// Identity, a Discover category of Ente's photos app that leaves out videos. // Identity, a Discover category of Ente's photos app that leaves out videos.
const IDENTITY = DISCOVER_CATEGORIES.find((c) => c.title === "Identity")!; const IDENTITY = DISCOVER_CATEGORIES.find((c) => c.title === "Identity")!;
// ML data holding faces and no CLIP embedding, which file 302 has.
const FACES_ONLY = { face: { faces: [{ faceID: "face-def", score: 0.9 }] } };
let mock: MetaMockState; let mock: MetaMockState;
let testDir: string; let testDir: string;
@@ -217,9 +220,14 @@ const buildMetaMock = async (): Promise<MetaMockState> => {
updationTime: 1710000000000000, updationTime: 1710000000000000,
}; };
// A file in coll 10 whose ML data holds only a CLIP embedding, Identity's // A file in coll 10 with the ML data given. `fileType` is Ente's number: 0
// own text embedding. `fileType` is Ente's number: 0 an image, 1 a video. // an image, 1 a video.
const identityFile = (id: number, title: string, fileType: number) => { const vacationFile = (
id: number,
title: string,
fileType: number,
mlData: object,
) => {
const fileKey = sodium.crypto_secretstream_xchacha20poly1305_keygen(); const fileKey = sodium.crypto_secretstream_xchacha20poly1305_keygen();
const { ciphertext: encKey, nonce } = encryptSecretbox(fileKey, ck1); const { ciphertext: encKey, nonce } = encryptSecretbox(fileKey, ck1);
const meta = encryptStreamBlob( const meta = encryptStreamBlob(
@@ -234,15 +242,7 @@ const buildMetaMock = async (): Promise<MetaMockState> => {
fileKey, fileKey,
); );
const ml = encryptBlob( const ml = encryptBlob(
new Uint8Array( new Uint8Array(gzipSync(Buffer.from(JSON.stringify(mlData)))),
gzipSync(
Buffer.from(
JSON.stringify({
clip: { embedding: IDENTITY.textEmbedding },
}),
),
),
),
fileKey, fileKey,
); );
return { return {
@@ -270,9 +270,14 @@ const buildMetaMock = async (): Promise<MetaMockState> => {
}, },
}; };
}; };
// Files 300 and 301 in coll 10: a photo and a video. // Files 300 and 301 in coll 10: a photo and a video whose ML data holds
const identityPhoto = identityFile(300, "passport.jpg", 0); // only a CLIP embedding, Identity's own text embedding.
const identityVideo = identityFile(301, "passport.mp4", 1); const identityML = { clip: { embedding: IDENTITY.textEmbedding } };
const identityPhoto = vacationFile(300, "passport.jpg", 0, identityML);
const identityVideo = vacationFile(301, "passport.mp4", 1, identityML);
// File 302 in coll 10: a photo whose ML data holds faces and no CLIP
// embedding.
const facesPhoto = vacationFile(302, "party.jpg", 0, FACES_ONLY);
// Encrypt ML data for file 100 (gzipped JSON, encrypted with file key) // Encrypt ML data for file 100 (gzipped JSON, encrypted with file key)
const mlPayload = JSON.stringify({ const mlPayload = JSON.stringify({
@@ -341,7 +346,12 @@ const buildMetaMock = async (): Promise<MetaMockState> => {
encryptedToken: account.encryptedToken, encryptedToken: account.encryptedToken,
collections: [rawColl1, rawColl2], collections: [rawColl1, rawColl2],
filesByCollection: { filesByCollection: {
10: [rawFile1, identityPhoto.raw, identityVideo.raw], 10: [
rawFile1,
identityPhoto.raw,
identityVideo.raw,
facesPhoto.raw,
],
20: [rawFile2], 20: [rawFile2],
}, },
encryptedMLData: { encryptedMLData: {
@@ -351,6 +361,7 @@ const buildMetaMock = async (): Promise<MetaMockState> => {
}, },
300: identityPhoto.mlData, 300: identityPhoto.mlData,
301: identityVideo.mlData, 301: identityVideo.mlData,
302: facesPhoto.mlData,
}, },
fileCiphertexts: { 100: encFileBody1 }, fileCiphertexts: { 100: encFileBody1 },
}; };
@@ -550,6 +561,22 @@ describe("quak backup-metadata", () => {
expect(fileJSON(301).keywords).not.toContain("Identity"); expect(fileJSON(301).keywords).not.toContain("Identity");
}); });
// A file's ML data can hold faces and no CLIP embedding. File 302's JSON
// then has the ML data and no keywords.
it("writes no keywords for ML data without a CLIP embedding", async () => {
const outDir = join(testDir, "no-clip");
await runBackup(outDir);
const fileMeta = JSON.parse(
readFileSync(
join(outDir, "collections", "10-Vacation", "302.json"),
"utf-8",
),
);
expect(fileMeta.mlData).toEqual(FACES_ONLY);
expect(fileMeta).not.toHaveProperty("keywords");
});
it("extracts EXIF from downloaded files when --exif is set", async () => { it("extracts EXIF from downloaded files when --exif is set", async () => {
const outDir = join(testDir, "exif-data"); const outDir = join(testDir, "exif-data");
await runBackup(outDir, { exif: true }); await runBackup(outDir, { exif: true });
+1 -1
View File
@@ -96,7 +96,7 @@ const SUNSET: { photo: string; sha256: string; embedding: number[] } =
); );
describe("Ente's Discover categories", () => { describe("Ente's Discover categories", () => {
it("are the 16 of Ente's photos app, each with a text embedding of length 1", () => { it("are the 16 of Ente's photos app, each with a text embedding of norm 1", () => {
expect(DISCOVER_CATEGORIES.map((c) => c.title)).toEqual([ expect(DISCOVER_CATEGORIES.map((c) => c.title)).toEqual([
"Identity", "Identity",
"Screenshots", "Screenshots",