File size: 6,336 Bytes
879c715
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
import { createHash } from "node:crypto";

/**
 * Semantic hashes that decide whether a stored enrichment result is current.
 *
 * `input_hash` covers the ordered semantic input we feed the model. Metrics
 * (captured_at, like counts, contributor stamp) do not participate.
 *
 * `contract_hash` covers the classifier's behavior: taxonomy, prompt template,
 * output schema, normalization rules, model and processor version. Any change
 * to any of these must alter the hash so existing results stop counting as
 * current until they are re-inferred.
 */

import type { PooledTweet } from "./tweet.js";
import type { LabelConfig } from "./enrichment.js";

/**
 * Bump when prompt/schema/normalization change in a way that requires
 * re-inference of previously classified units.
 */
export const PROCESSOR_VERSION = 1;

/**
 * Freezes the fields of a tweet that affect classification. Metrics or
 * per-capture attribution are excluded so a re-capture with the same text
 * does not invalidate the current result.
 */
export type SemanticTweetFields = {
  id: string;
  text: string;
  conversation_id: string | undefined;
  author_id: string | undefined;
  author_username: string;
  reply_to: string | undefined;
  quoted_status_id: string | undefined;
  expanded_urls: readonly string[];
  is_subscriber_only: boolean;
  is_retweet: boolean;
};

function firstStringField(tweet: PooledTweet, keys: readonly string[]): string | undefined {
  for (const key of keys) {
    const value = tweet[key];
    if (typeof value === "string" && value.length > 0) return value;
    if (typeof value === "number") return String(value);
  }
  return undefined;
}

function firstBooleanField(tweet: PooledTweet, keys: readonly string[]): boolean {
  for (const key of keys) {
    const value = tweet[key];
    if (value === true) return true;
  }
  return false;
}

function collectStringArray(value: unknown, into: Set<string>): void {
  if (!Array.isArray(value)) return;
  for (const entry of value) {
    if (typeof entry === "string" && entry.length > 0) into.add(entry);
  }
}

function collectEntityUrls(entities: unknown, into: Set<string>): void {
  if (typeof entities !== "object" || entities === null) return;
  const urls = (entities as Record<string, unknown>)["urls"];
  if (!Array.isArray(urls)) return;
  for (const entry of urls) addEntityUrl(entry, into);
}

function addEntityUrl(entry: unknown, into: Set<string>): void {
  if (typeof entry !== "object" || entry === null) return;
  const value = (entry as Record<string, unknown>)["expanded_url"];
  if (typeof value === "string" && value.length > 0) into.add(value);
}

function expandedUrls(tweet: PooledTweet): string[] {
  const collected = new Set<string>();
  collectStringArray(tweet["expanded_urls"], collected);
  collectEntityUrls(tweet["entities"], collected);
  return [...collected].sort();
}

/** Extract the classification-relevant fields of one tweet. */
export function semanticTweetFields(tweet: PooledTweet): SemanticTweetFields {
  return {
    id: tweet.id,
    text: tweet.text,
    conversation_id: firstStringField(tweet, ["conversation_id"]),
    author_id: typeof tweet.author.id === "string" ? tweet.author.id : undefined,
    author_username: tweet.author.username.toLowerCase(),
    reply_to: firstStringField(tweet, [
      "in_reply_to_status_id",
      "in_reply_to_tweet_id",
      "reply_to_status_id",
    ]),
    quoted_status_id: firstStringField(tweet, [
      "quoted_status_id",
      "quoted_tweet_id",
      "quote_status_id",
    ]),
    expanded_urls: expandedUrls(tweet),
    is_subscriber_only: firstBooleanField(tweet, ["is_subscriber_only"]),
    is_retweet: firstBooleanField(tweet, ["is_retweet"]),
  };
}

/**
 * Deterministic canonical JSON: object keys are sorted, arrays retain order.
 * `undefined` values are dropped so the presence of an optional field does
 * not depend on whether the caller passed the key.
 */
export function canonicalJson(value: unknown): string {
  if (value === null || typeof value !== "object") return JSON.stringify(value);
  if (Array.isArray(value)) return `[${value.map((entry) => canonicalJson(entry)).join(",")}]`;
  const entries = Object.entries(value as Record<string, unknown>)
    .filter(([, entry]) => entry !== undefined)
    .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
  return `{${entries
    .map(([key, entry]) => `${JSON.stringify(key)}:${canonicalJson(entry)}`)
    .join(",")}}`;
}

function sha256Hex(value: string): string {
  return createHash("sha256").update(value).digest("hex");
}

/**
 * Semantic hash of a unit's classification input. Members are sorted by
 * tweet id so shard replay order cannot change the hash.
 */
export function computeInputHash(unitId: string, members: readonly SemanticTweetFields[]): string {
  const sorted = [...members].sort((left, right) =>
    left.id < right.id ? -1 : left.id > right.id ? 1 : 0,
  );
  return sha256Hex(canonicalJson({ unit_id: unitId, members: sorted }));
}

/** Same, but built from raw tweets — a convenience for the hot path. */
export function computeInputHashFromTweets(unitId: string, tweets: readonly PooledTweet[]): string {
  return computeInputHash(
    unitId,
    tweets.map((tweet) => semanticTweetFields(tweet)),
  );
}

/** Fields that together identify the classifier contract. */
export type ContractInput = {
  taxonomy_version: number;
  labels: readonly LabelConfig[];
  model: string;
  processor_version: number;
  prompt_template_id: string;
  output_schema_id: string;
  normalization_id: string;
};

/**
 * Deterministic hash of the classifier contract. Labels are sorted by name so
 * cosmetic reordering does not invalidate the whole backlog.
 */
export function computeContractHash(contract: ContractInput): string {
  const canonical = {
    taxonomy_version: contract.taxonomy_version,
    model: contract.model,
    processor_version: contract.processor_version,
    prompt_template_id: contract.prompt_template_id,
    output_schema_id: contract.output_schema_id,
    normalization_id: contract.normalization_id,
    labels: [...contract.labels]
      .map((label) => ({ name: label.name, description: label.description }))
      .sort((left, right) => (left.name < right.name ? -1 : left.name > right.name ? 1 : 0)),
  };
  return sha256Hex(canonicalJson(canonical));
}