xtap-pool / shared /tests /hash.test.ts
osolmaz's picture
osolmaz HF Staff
deploy: eaa4b86
879c715 verified
Raw
History Blame Contribute Delete
6.62 kB
import { describe, expect, it } from "vitest";
import {
canonicalJson,
computeContractHash,
computeInputHash,
computeInputHashFromTweets,
PROCESSOR_VERSION,
semanticTweetFields,
} from "../src/hash.js";
import type { PooledTweet } from "../src/tweet.js";
function pooled(overrides: Record<string, unknown> = {}): PooledTweet {
return {
id: "100",
url: "https://x.com/someone/status/100",
text: "hello world",
captured_at: "2026-05-21T03:04:35.954Z",
created_at: "2026-05-20T10:00:00.000Z",
author: { id: "author-a", username: "someone", display_name: "Some One" },
contributed_by: "osolmaz",
pooled_at: "2026-05-21T03:05:00.000Z",
...overrides,
};
}
describe("canonicalJson", () => {
it("sorts keys and drops undefined values", () => {
expect(canonicalJson({ b: 1, a: 2, c: undefined })).toBe('{"a":2,"b":1}');
expect(canonicalJson({ nested: { z: 1, a: 2 } })).toBe('{"nested":{"a":2,"z":1}}');
expect(canonicalJson([{ b: 1, a: 2 }])).toBe('[{"a":2,"b":1}]');
});
it("keeps array order", () => {
expect(canonicalJson(["b", "a"])).toBe('["b","a"]');
});
});
describe("computeInputHash", () => {
it("is stable across capture reorderings, metrics changes and contributor differences", () => {
const first = pooled();
const second = pooled({
captured_at: "2026-06-01T00:00:00.000Z",
pooled_at: "2026-06-01T00:00:00.000Z",
contributed_by: "alice",
like_count: 500,
});
expect(computeInputHashFromTweets("100:someone", [first])).toBe(
computeInputHashFromTweets("100:someone", [second]),
);
});
it("changes when the semantic input changes", () => {
const baseline = pooled();
const differentText = pooled({ text: "different words" });
const withReply = pooled({ in_reply_to_status_id: "99" });
const withQuote = pooled({ quoted_status_id: "42" });
const subscriberOnly = pooled({ is_subscriber_only: true });
const differentAuthor = pooled({ author: { id: "author-b", username: "someone" } });
const baseHash = computeInputHashFromTweets("100:someone", [baseline]);
expect(computeInputHashFromTweets("100:someone", [differentText])).not.toBe(baseHash);
expect(computeInputHashFromTweets("100:someone", [withReply])).not.toBe(baseHash);
expect(computeInputHashFromTweets("100:someone", [withQuote])).not.toBe(baseHash);
expect(computeInputHashFromTweets("100:someone", [subscriberOnly])).not.toBe(baseHash);
expect(computeInputHashFromTweets("100:someone", [differentAuthor])).not.toBe(baseHash);
});
it("changes when membership changes", () => {
const root = pooled();
const reply = pooled({ id: "101", text: "reply" });
const single = computeInputHashFromTweets("100:someone", [root]);
const multi = computeInputHashFromTweets("100:someone", [root, reply]);
expect(single).not.toBe(multi);
});
it("does not depend on the order tweets are supplied", () => {
const one = pooled();
const two = pooled({ id: "101", text: "reply" });
const a = computeInputHashFromTweets("100:someone", [one, two]);
const b = computeInputHashFromTweets("100:someone", [two, one]);
expect(a).toBe(b);
});
it("respects expanded URLs from raw arrays or entities.urls", () => {
const viaArray = computeInputHashFromTweets("100:someone", [
pooled({ expanded_urls: ["https://example.com/a"] }),
]);
const viaEntities = computeInputHashFromTweets("100:someone", [
pooled({ entities: { urls: [{ expanded_url: "https://example.com/a" }] } }),
]);
const noUrl = computeInputHashFromTweets("100:someone", [pooled()]);
expect(viaArray).toBe(viaEntities);
expect(viaArray).not.toBe(noUrl);
});
it("normalizes semantic fields for downstream consumers", () => {
const fields = semanticTweetFields(
pooled({ conversation_id: "42", entities: { urls: [{ expanded_url: "https://x/1" }] } }),
);
expect(fields.expanded_urls).toEqual(["https://x/1"]);
expect(fields.conversation_id).toBe("42");
expect(fields.author_username).toBe("someone");
expect(fields.is_retweet).toBe(false);
});
it("handles numeric references and malformed optional URL containers deterministically", () => {
const fields = semanticTweetFields(
pooled({
conversation_id: 42,
in_reply_to_tweet_id: 9,
quoted_tweet_id: 8,
entities: { urls: [null, { expanded_url: 7 }] },
expanded_urls: ["", 7],
}),
);
expect(fields).toMatchObject({ conversation_id: "42", reply_to: "9", quoted_status_id: "8" });
expect(fields.expanded_urls).toEqual([]);
expect(semanticTweetFields(pooled({ entities: null })).expanded_urls).toEqual([]);
expect(
semanticTweetFields(pooled({ entities: { urls: "not-an-array" } })).expanded_urls,
).toEqual([]);
});
});
describe("computeContractHash", () => {
const baseline = {
taxonomy_version: 1,
labels: [{ name: "ai", description: "d" }],
model: "zai-org/GLM-5.2",
processor_version: PROCESSOR_VERSION,
prompt_template_id: "batch-v1",
output_schema_id: "units-v1",
normalization_id: "free-labels-v1",
} as const;
it("changes when any contract field changes", () => {
const base = computeContractHash(baseline);
expect(computeContractHash({ ...baseline, taxonomy_version: 2 })).not.toBe(base);
expect(computeContractHash({ ...baseline, model: "other" })).not.toBe(base);
expect(
computeContractHash({
...baseline,
labels: [{ name: "ai", description: "different" }],
}),
).not.toBe(base);
expect(computeContractHash({ ...baseline, processor_version: 2 })).not.toBe(base);
expect(computeContractHash({ ...baseline, prompt_template_id: "batch-v2" })).not.toBe(base);
});
it("is stable under cosmetic label reorder", () => {
const original = computeContractHash({
...baseline,
labels: [
{ name: "ai", description: "a" },
{ name: "agents", description: "b" },
],
});
const reordered = computeContractHash({
...baseline,
labels: [
{ name: "agents", description: "b" },
{ name: "ai", description: "a" },
],
});
expect(original).toBe(reordered);
});
});
describe("computeInputHash from fields", () => {
it("matches the tweet-based helper", () => {
const tweet = pooled();
const fromFields = computeInputHash("100:someone", [semanticTweetFields(tweet)]);
const fromTweet = computeInputHashFromTweets("100:someone", [tweet]);
expect(fromFields).toBe(fromTweet);
});
});