@hyperfixation/core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/dist/activity.d.ts +73 -0
- package/dist/activity.js +84 -0
- package/dist/define-app.d.ts +171 -0
- package/dist/define-app.js +283 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.js +19 -0
- package/dist/labels.d.ts +41 -0
- package/dist/labels.js +62 -0
- package/dist/outcomes.d.ts +35 -0
- package/dist/outcomes.js +50 -0
- package/dist/pages.d.ts +11 -0
- package/dist/pages.js +1 -0
- package/dist/pause.d.ts +41 -0
- package/dist/pause.js +52 -0
- package/dist/records.d.ts +67 -0
- package/dist/records.js +111 -0
- package/dist/registry.d.ts +46 -0
- package/dist/registry.js +75 -0
- package/dist/resolution.d.ts +57 -0
- package/dist/resolution.js +221 -0
- package/dist/resolvers.d.ts +23 -0
- package/dist/resolvers.js +9 -0
- package/dist/schedules.d.ts +40 -0
- package/dist/schedules.js +32 -0
- package/dist/scorers.d.ts +17 -0
- package/dist/scorers.js +3 -0
- package/dist/scores.d.ts +80 -0
- package/dist/scores.js +113 -0
- package/dist/sources.d.ts +13 -0
- package/dist/sources.js +3 -0
- package/dist/specs.d.ts +12 -0
- package/dist/specs.js +7 -0
- package/dist/status-route.d.ts +29 -0
- package/dist/status-route.js +61 -0
- package/dist/status-token.d.ts +19 -0
- package/dist/status-token.js +40 -0
- package/dist/status.d.ts +45 -0
- package/dist/status.js +99 -0
- package/dist/step-client.d.ts +8 -0
- package/dist/step-client.js +8 -0
- package/dist/tasks.d.ts +96 -0
- package/dist/tasks.js +151 -0
- package/dist/workspace-views.d.ts +143 -0
- package/dist/workspace-views.js +0 -0
- package/dist/workspace.d.ts +91 -0
- package/dist/workspace.js +136 -0
- package/package.json +52 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { type StepDatabase } from "@hyperfixation/db";
|
|
2
|
+
import type { ResolverDefinition } from "./resolvers.js";
|
|
3
|
+
export declare const DEFAULT_RESOLVE_LIMIT = 500;
|
|
4
|
+
export declare const DEFAULT_RESOLVE_MAX_ATTEMPTS = 3;
|
|
5
|
+
/** How many trigram candidates the re-ranker looks at; the GIN index orders nothing. */
|
|
6
|
+
export declare const FUZZY_CANDIDATE_LIMIT = 20;
|
|
7
|
+
/**
|
|
8
|
+
* The candidate query, exported so a test can `EXPLAIN` the statement resolution really issues
|
|
9
|
+
* rather than a hand-copied lookalike. `%` is what the GIN trigram index answers; the
|
|
10
|
+
* `ORDER BY` is a sort over the bitmap heap scan's output, not an index walk.
|
|
11
|
+
*/
|
|
12
|
+
export declare function fuzzyCandidateStatement(table: string, field: string): string;
|
|
13
|
+
export interface ResolveBatchOptions {
|
|
14
|
+
resolver: ResolverDefinition;
|
|
15
|
+
/** The app table the resolver's `recordType` lives in; `exactKeys` are columns on it. */
|
|
16
|
+
table: string;
|
|
17
|
+
source: string;
|
|
18
|
+
/** Rows per call, and per transaction: the caller loops until `done`. Defaults to 500. */
|
|
19
|
+
limit?: number | undefined;
|
|
20
|
+
maxAttempts?: number | undefined;
|
|
21
|
+
}
|
|
22
|
+
export interface ResolveBatchResult {
|
|
23
|
+
scanned: number;
|
|
24
|
+
linkedExact: number;
|
|
25
|
+
linkedFuzzy: number;
|
|
26
|
+
created: number;
|
|
27
|
+
/** Rows that already carried a link: the record was updated and the link left alone. */
|
|
28
|
+
updated: number;
|
|
29
|
+
review: number;
|
|
30
|
+
error: number;
|
|
31
|
+
/**
|
|
32
|
+
* False while the scan filled its `limit` *and* the batch moved at least one row out of the
|
|
33
|
+
* scan, so the caller has another batch to run.
|
|
34
|
+
*/
|
|
35
|
+
done: boolean;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Dice coefficient over bigram multisets, in [0, 1]. Postgres's `similarity()` already ordered
|
|
39
|
+
* the candidates; this re-ranks them in process, where the comparison is free and a resolver's
|
|
40
|
+
* `review()` threshold can be read against one scale that does not move with a Postgres upgrade.
|
|
41
|
+
*/
|
|
42
|
+
export declare function bigramDice(a: string, b: string): number;
|
|
43
|
+
/**
|
|
44
|
+
* Links, creates or parks every unlinked `hf_source_record` row of `source`, inside the
|
|
45
|
+
* caller's `ctx.tx`. One call is one batch of at most `limit` rows and one transaction, so a
|
|
46
|
+
* 200k load is many calls: the alternative — one transaction for the whole load — holds
|
|
47
|
+
* `hf_run FOR SHARE` for as long as resolution takes.
|
|
48
|
+
*
|
|
49
|
+
* A row that already carries a link is never re-decided, whatever the link's method: only its
|
|
50
|
+
* record is updated. That is the whole of "a `manual` or `human_confirmed` link is never
|
|
51
|
+
* re-decided" — no method is special-cased, because none has to be.
|
|
52
|
+
*
|
|
53
|
+
* Records resolve one at a time so a later row sees an earlier row's create, and each one runs
|
|
54
|
+
* in its own savepoint: a `create`/`update` that throws marks that row `error` and the batch
|
|
55
|
+
* carries on.
|
|
56
|
+
*/
|
|
57
|
+
export declare function resolveBatch(tx: StepDatabase, options: ResolveBatchOptions): Promise<ResolveBatchResult>;
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { quoteIdent } from "@hyperfixation/db";
|
|
2
|
+
import { InvalidDefinition } from "./registry.js";
|
|
3
|
+
export const DEFAULT_RESOLVE_LIMIT = 500;
|
|
4
|
+
export const DEFAULT_RESOLVE_MAX_ATTEMPTS = 3;
|
|
5
|
+
/** How many trigram candidates the re-ranker looks at; the GIN index orders nothing. */
|
|
6
|
+
export const FUZZY_CANDIDATE_LIMIT = 20;
|
|
7
|
+
/** Reused per row rather than named per id: `RELEASE` always releases the innermost one. */
|
|
8
|
+
const SAVEPOINT = "hf_resolve_row";
|
|
9
|
+
const SCAN_STATEMENT = "SELECT id, payload FROM hf_source_record " +
|
|
10
|
+
"WHERE source = $1 AND status <> 'linked' AND attempts < $2 ORDER BY id LIMIT $3 FOR UPDATE";
|
|
11
|
+
const LINKS_STATEMENT = "SELECT source_record_id, record_id FROM hf_record_link WHERE source_record_id = ANY($1::bigint[])";
|
|
12
|
+
const INSERT_LINK_STATEMENT = "INSERT INTO hf_record_link (source_record_id, record_type, record_id, confidence, method) " +
|
|
13
|
+
"VALUES ($1, $2, $3, $4, $5)";
|
|
14
|
+
const LINKED_STATEMENT = "UPDATE hf_source_record SET status = 'linked', error = NULL WHERE id = $1";
|
|
15
|
+
const REVIEW_STATEMENT = "UPDATE hf_source_record SET status = 'review', error = NULL WHERE id = $1";
|
|
16
|
+
const ERROR_STATEMENT = "UPDATE hf_source_record SET status = 'error', attempts = attempts + 1, error = $2 WHERE id = $1";
|
|
17
|
+
/**
|
|
18
|
+
* The candidate query, exported so a test can `EXPLAIN` the statement resolution really issues
|
|
19
|
+
* rather than a hand-copied lookalike. `%` is what the GIN trigram index answers; the
|
|
20
|
+
* `ORDER BY` is a sort over the bitmap heap scan's output, not an index walk.
|
|
21
|
+
*/
|
|
22
|
+
export function fuzzyCandidateStatement(table, field) {
|
|
23
|
+
const column = quoteIdent(field);
|
|
24
|
+
return (`SELECT id, ${column} FROM ${quoteIdent(table)} ` +
|
|
25
|
+
`WHERE ${column} % $1 AND archived_at IS NULL ` +
|
|
26
|
+
`ORDER BY similarity(${column}, $1) DESC LIMIT ${FUZZY_CANDIDATE_LIMIT}`);
|
|
27
|
+
}
|
|
28
|
+
function bigrams(value) {
|
|
29
|
+
const grams = new Map();
|
|
30
|
+
for (let i = 0; i + 1 < value.length; i += 1) {
|
|
31
|
+
const gram = value.slice(i, i + 2);
|
|
32
|
+
grams.set(gram, (grams.get(gram) ?? 0) + 1);
|
|
33
|
+
}
|
|
34
|
+
return grams;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Dice coefficient over bigram multisets, in [0, 1]. Postgres's `similarity()` already ordered
|
|
38
|
+
* the candidates; this re-ranks them in process, where the comparison is free and a resolver's
|
|
39
|
+
* `review()` threshold can be read against one scale that does not move with a Postgres upgrade.
|
|
40
|
+
*/
|
|
41
|
+
export function bigramDice(a, b) {
|
|
42
|
+
if (a === b)
|
|
43
|
+
return 1;
|
|
44
|
+
const left = bigrams(a);
|
|
45
|
+
let total = 0;
|
|
46
|
+
let shared = 0;
|
|
47
|
+
for (const count of left.values())
|
|
48
|
+
total += count;
|
|
49
|
+
for (const [gram, count] of bigrams(b)) {
|
|
50
|
+
total += count;
|
|
51
|
+
shared += Math.min(count, left.get(gram) ?? 0);
|
|
52
|
+
}
|
|
53
|
+
return total === 0 ? 0 : (2 * shared) / total;
|
|
54
|
+
}
|
|
55
|
+
function thresholdLiteral(resolver, threshold) {
|
|
56
|
+
if (!(threshold > 0 && threshold <= 1)) {
|
|
57
|
+
throw new InvalidDefinition("resolver", resolver.name, `has a fuzzy.threshold of ${threshold}, which is not in (0, 1]`);
|
|
58
|
+
}
|
|
59
|
+
// `SET` takes no bind parameters, so the value is interpolated; `toFixed` never produces
|
|
60
|
+
// exponent notation, which is not a numeric literal Postgres accepts here.
|
|
61
|
+
return threshold.toFixed(10);
|
|
62
|
+
}
|
|
63
|
+
/** Null or missing in any exact key means the row groups with nothing and joins on nothing. */
|
|
64
|
+
function exactValues(payload, keys) {
|
|
65
|
+
const values = [];
|
|
66
|
+
for (const key of keys) {
|
|
67
|
+
const value = payload[key];
|
|
68
|
+
if (value === null || value === undefined)
|
|
69
|
+
return undefined;
|
|
70
|
+
values.push(String(value));
|
|
71
|
+
}
|
|
72
|
+
return values;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Links, creates or parks every unlinked `hf_source_record` row of `source`, inside the
|
|
76
|
+
* caller's `ctx.tx`. One call is one batch of at most `limit` rows and one transaction, so a
|
|
77
|
+
* 200k load is many calls: the alternative — one transaction for the whole load — holds
|
|
78
|
+
* `hf_run FOR SHARE` for as long as resolution takes.
|
|
79
|
+
*
|
|
80
|
+
* A row that already carries a link is never re-decided, whatever the link's method: only its
|
|
81
|
+
* record is updated. That is the whole of "a `manual` or `human_confirmed` link is never
|
|
82
|
+
* re-decided" — no method is special-cased, because none has to be.
|
|
83
|
+
*
|
|
84
|
+
* Records resolve one at a time so a later row sees an earlier row's create, and each one runs
|
|
85
|
+
* in its own savepoint: a `create`/`update` that throws marks that row `error` and the batch
|
|
86
|
+
* carries on.
|
|
87
|
+
*/
|
|
88
|
+
export async function resolveBatch(tx, options) {
|
|
89
|
+
const { resolver, table, source } = options;
|
|
90
|
+
const limit = options.limit ?? DEFAULT_RESOLVE_LIMIT;
|
|
91
|
+
const maxAttempts = options.maxAttempts ?? DEFAULT_RESOLVE_MAX_ATTEMPTS;
|
|
92
|
+
const client = tx.$client;
|
|
93
|
+
const result = {
|
|
94
|
+
scanned: 0,
|
|
95
|
+
linkedExact: 0,
|
|
96
|
+
linkedFuzzy: 0,
|
|
97
|
+
created: 0,
|
|
98
|
+
updated: 0,
|
|
99
|
+
review: 0,
|
|
100
|
+
error: 0,
|
|
101
|
+
done: true,
|
|
102
|
+
};
|
|
103
|
+
const scanned = await client.query(SCAN_STATEMENT, [source, maxAttempts, limit]);
|
|
104
|
+
const rows = scanned.rows;
|
|
105
|
+
result.scanned = rows.length;
|
|
106
|
+
if (rows.length === 0)
|
|
107
|
+
return result;
|
|
108
|
+
const linked = await client.query(LINKS_STATEMENT, [rows.map((row) => row.id)]);
|
|
109
|
+
const existing = new Map(linked.rows.map((row) => [row.source_record_id, row.record_id]));
|
|
110
|
+
const { fuzzy } = resolver;
|
|
111
|
+
if (fuzzy) {
|
|
112
|
+
// Transaction-scoped, so it is set once and survives every ROLLBACK TO SAVEPOINT below.
|
|
113
|
+
await client.query(`SET LOCAL pg_trgm.similarity_threshold = ${thresholdLiteral(resolver, fuzzy.threshold)}`);
|
|
114
|
+
}
|
|
115
|
+
const candidateStatement = fuzzy ? fuzzyCandidateStatement(table, fuzzy.field) : undefined;
|
|
116
|
+
const payloadKey = fuzzy?.payloadKey ?? fuzzy?.field;
|
|
117
|
+
const exactStatement = resolver.exactKeys.length === 0
|
|
118
|
+
? undefined
|
|
119
|
+
: `SELECT id FROM ${quoteIdent(table)} WHERE ` +
|
|
120
|
+
resolver.exactKeys.map((key, i) => `${quoteIdent(key)} = $${i + 1}`).join(" AND ") +
|
|
121
|
+
" AND archived_at IS NULL LIMIT 1";
|
|
122
|
+
const link = (id, recordId, method, confidence) => client.query(INSERT_LINK_STATEMENT, [
|
|
123
|
+
id,
|
|
124
|
+
resolver.recordType,
|
|
125
|
+
recordId,
|
|
126
|
+
confidence,
|
|
127
|
+
method,
|
|
128
|
+
]);
|
|
129
|
+
const groups = new Map();
|
|
130
|
+
for (const row of rows) {
|
|
131
|
+
const recordId = existing.get(row.id);
|
|
132
|
+
const keys = exactValues(row.payload, resolver.exactKeys);
|
|
133
|
+
const groupKey = recordId === undefined && keys ? JSON.stringify(keys) : undefined;
|
|
134
|
+
const leaderOutcome = groupKey === undefined ? undefined : groups.get(groupKey);
|
|
135
|
+
await client.query(`SAVEPOINT ${SAVEPOINT}`);
|
|
136
|
+
try {
|
|
137
|
+
if (recordId !== undefined) {
|
|
138
|
+
await resolver.update(recordId, row.payload, tx);
|
|
139
|
+
await client.query(LINKED_STATEMENT, [row.id]);
|
|
140
|
+
result.updated += 1;
|
|
141
|
+
}
|
|
142
|
+
else if (leaderOutcome !== undefined) {
|
|
143
|
+
// A duplicate within the batch: the leader already decided this entity, so the
|
|
144
|
+
// follower takes its outcome rather than resolving — and never its own `update`.
|
|
145
|
+
if (leaderOutcome.kind === "record") {
|
|
146
|
+
await link(row.id, leaderOutcome.recordId, "exact", 1);
|
|
147
|
+
await client.query(LINKED_STATEMENT, [row.id]);
|
|
148
|
+
result.linkedExact += 1;
|
|
149
|
+
}
|
|
150
|
+
else if (leaderOutcome.kind === "review") {
|
|
151
|
+
await client.query(REVIEW_STATEMENT, [row.id]);
|
|
152
|
+
result.review += 1;
|
|
153
|
+
}
|
|
154
|
+
else {
|
|
155
|
+
throw new Error("the first row of this batch group failed to resolve");
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
const outcome = await resolveRow(row, keys);
|
|
160
|
+
if (groupKey !== undefined)
|
|
161
|
+
groups.set(groupKey, outcome);
|
|
162
|
+
}
|
|
163
|
+
await client.query(`RELEASE SAVEPOINT ${SAVEPOINT}`);
|
|
164
|
+
}
|
|
165
|
+
catch (error) {
|
|
166
|
+
await client.query(`ROLLBACK TO SAVEPOINT ${SAVEPOINT}`);
|
|
167
|
+
await client.query(`RELEASE SAVEPOINT ${SAVEPOINT}`);
|
|
168
|
+
await client.query(ERROR_STATEMENT, [row.id, String(error.message ?? error)]);
|
|
169
|
+
result.error += 1;
|
|
170
|
+
if (groupKey !== undefined)
|
|
171
|
+
groups.set(groupKey, { kind: "error" });
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
// A `review` row stays scannable, so a full batch that moved nothing would be handed back
|
|
175
|
+
// identically forever and a `while (!done)` loop would never end. An `error` row counts as
|
|
176
|
+
// movement: its `attempts` climbs towards `maxAttempts`, which does take it out of the scan.
|
|
177
|
+
const moved = result.linkedExact + result.linkedFuzzy + result.created + result.updated + result.error;
|
|
178
|
+
result.done = rows.length < limit || moved === 0;
|
|
179
|
+
return result;
|
|
180
|
+
async function resolveRow(row, keys) {
|
|
181
|
+
if (exactStatement && keys) {
|
|
182
|
+
const hit = await client.query(exactStatement, keys);
|
|
183
|
+
const match = hit.rows[0];
|
|
184
|
+
if (match) {
|
|
185
|
+
await resolver.update(match.id, row.payload, tx);
|
|
186
|
+
await link(row.id, match.id, "exact", 1);
|
|
187
|
+
await client.query(LINKED_STATEMENT, [row.id]);
|
|
188
|
+
result.linkedExact += 1;
|
|
189
|
+
return { kind: "record", recordId: match.id };
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
const needle = payloadKey === undefined ? undefined : row.payload[payloadKey];
|
|
193
|
+
if (candidateStatement && typeof needle === "string" && needle !== "") {
|
|
194
|
+
const candidates = await client.query(candidateStatement, [needle]);
|
|
195
|
+
let best;
|
|
196
|
+
for (const candidate of candidates.rows) {
|
|
197
|
+
const value = candidate[fuzzy.field];
|
|
198
|
+
const score = typeof value === "string" ? bigramDice(needle, value) : 0;
|
|
199
|
+
if (!best || score > best.score)
|
|
200
|
+
best = { id: candidate.id, score };
|
|
201
|
+
}
|
|
202
|
+
if (best) {
|
|
203
|
+
if (resolver.review?.(best.score) === true) {
|
|
204
|
+
await client.query(REVIEW_STATEMENT, [row.id]);
|
|
205
|
+
result.review += 1;
|
|
206
|
+
return { kind: "review" };
|
|
207
|
+
}
|
|
208
|
+
await resolver.update(best.id, row.payload, tx);
|
|
209
|
+
await link(row.id, best.id, "fuzzy", best.score);
|
|
210
|
+
await client.query(LINKED_STATEMENT, [row.id]);
|
|
211
|
+
result.linkedFuzzy += 1;
|
|
212
|
+
return { kind: "record", recordId: best.id };
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
const created = await resolver.create(row.payload, tx);
|
|
216
|
+
await link(row.id, created.id, "created", null);
|
|
217
|
+
await client.query(LINKED_STATEMENT, [row.id]);
|
|
218
|
+
result.created += 1;
|
|
219
|
+
return { kind: "record", recordId: created.id };
|
|
220
|
+
}
|
|
221
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { StepDatabase } from "@hyperfixation/db";
|
|
2
|
+
export interface ResolverFuzzy {
|
|
3
|
+
/** The record column the candidate query matches on, `normalized_name` in every app so far. */
|
|
4
|
+
readonly field: string;
|
|
5
|
+
/** The payload key compared against `field`, already normalized. Defaults to `field`. */
|
|
6
|
+
readonly payloadKey?: string;
|
|
7
|
+
/** What `pg_trgm.similarity_threshold` is set to for the candidate query; in (0, 1]. */
|
|
8
|
+
readonly threshold: number;
|
|
9
|
+
}
|
|
10
|
+
export interface ResolverDefinition<P = unknown> {
|
|
11
|
+
readonly name: string;
|
|
12
|
+
readonly recordType: string;
|
|
13
|
+
/** Payload keys joined on before any fuzzy pass; a match on all of them is a link. */
|
|
14
|
+
readonly exactKeys: readonly string[];
|
|
15
|
+
readonly fuzzy?: ResolverFuzzy;
|
|
16
|
+
/** True sends the row to `review` rather than linking it at that similarity. */
|
|
17
|
+
review?(similarity: number): boolean;
|
|
18
|
+
create(payload: P, db: StepDatabase): Promise<{
|
|
19
|
+
id: string;
|
|
20
|
+
}>;
|
|
21
|
+
update(id: string, payload: P, db: StepDatabase): Promise<void>;
|
|
22
|
+
}
|
|
23
|
+
export declare function defineResolver<P>(definition: ResolverDefinition<P>): ResolverDefinition<P>;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { InvalidDefinition } from "./registry.js";
|
|
2
|
+
export function defineResolver(definition) {
|
|
3
|
+
const threshold = definition.fuzzy?.threshold;
|
|
4
|
+
// 0 matches everything and anything above 1 matches nothing; neither is a resolver anyone meant.
|
|
5
|
+
if (threshold !== undefined && !(threshold > 0 && threshold <= 1)) {
|
|
6
|
+
throw new InvalidDefinition("resolver", definition.name, `has a fuzzy.threshold of ${threshold}, which is not in (0, 1]`);
|
|
7
|
+
}
|
|
8
|
+
return definition;
|
|
9
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { Flow, StartedRun } from "@hyperfixation/workflows";
|
|
2
|
+
import type { Pool } from "pg";
|
|
3
|
+
export interface ScheduleDefinition<I = unknown> {
|
|
4
|
+
readonly name: string;
|
|
5
|
+
readonly flow: Flow<I, unknown>;
|
|
6
|
+
/** An interval in milliseconds, not a cron expression. */
|
|
7
|
+
readonly every: number;
|
|
8
|
+
/** The flow's input, built per firing; a flow taking `undefined` needs none. */
|
|
9
|
+
input?(): I;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* A schedule of any shape, as `AnyFlow` is a flow of any shape: the registry holds these, so
|
|
13
|
+
* what it holds says nothing about any one flow's input type.
|
|
14
|
+
*/
|
|
15
|
+
export interface AnySchedule {
|
|
16
|
+
readonly name: string;
|
|
17
|
+
readonly flow: Flow<never, unknown>;
|
|
18
|
+
readonly every: number;
|
|
19
|
+
input?(): unknown;
|
|
20
|
+
}
|
|
21
|
+
export type ScheduleFired = {
|
|
22
|
+
started: false;
|
|
23
|
+
reason: "paused";
|
|
24
|
+
} | {
|
|
25
|
+
started: true;
|
|
26
|
+
run: StartedRun;
|
|
27
|
+
};
|
|
28
|
+
export declare function defineSchedule<I>(definition: ScheduleDefinition<I>): ScheduleDefinition<I>;
|
|
29
|
+
/**
|
|
30
|
+
* One firing: a schedule starts a run and never sleeps durably, which is why this is a plain
|
|
31
|
+
* call a timer outside DBOS makes — `runs.start` is a control-plane operation that
|
|
32
|
+
* `assertNotInWorkflow()` refuses from inside a run, the same reason `startReconciler` is a
|
|
33
|
+
* `setInterval`.
|
|
34
|
+
*
|
|
35
|
+
* A paused app starts nothing. The flag is read before the run row is written rather than left
|
|
36
|
+
* to the step gate, so a pause does not accumulate runs that suspend at their first step.
|
|
37
|
+
*/
|
|
38
|
+
export declare function fireSchedule(pool: Pool, schedule: AnySchedule, start: (flow: Flow<unknown, unknown>, input: unknown) => Promise<StartedRun>): Promise<ScheduleFired>;
|
|
39
|
+
/** The names due at `now`: never fired, or last fired at least `every` ms ago. */
|
|
40
|
+
export declare function schedulesDue(schedules: readonly AnySchedule[], now: Date, lastFired: ReadonlyMap<string, Date>): string[];
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { appPaused } from "@hyperfixation/db";
|
|
2
|
+
import { InvalidDefinition } from "./registry.js";
|
|
3
|
+
export function defineSchedule(definition) {
|
|
4
|
+
if (!(definition.every > 0)) {
|
|
5
|
+
throw new InvalidDefinition("schedule", definition.name, `fires every ${definition.every}ms, which is not a positive interval`);
|
|
6
|
+
}
|
|
7
|
+
return definition;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* One firing: a schedule starts a run and never sleeps durably, which is why this is a plain
|
|
11
|
+
* call a timer outside DBOS makes — `runs.start` is a control-plane operation that
|
|
12
|
+
* `assertNotInWorkflow()` refuses from inside a run, the same reason `startReconciler` is a
|
|
13
|
+
* `setInterval`.
|
|
14
|
+
*
|
|
15
|
+
* A paused app starts nothing. The flag is read before the run row is written rather than left
|
|
16
|
+
* to the step gate, so a pause does not accumulate runs that suspend at their first step.
|
|
17
|
+
*/
|
|
18
|
+
export async function fireSchedule(pool, schedule, start) {
|
|
19
|
+
if (await appPaused(pool))
|
|
20
|
+
return { started: false, reason: "paused" };
|
|
21
|
+
const run = await start(schedule.flow, schedule.input?.());
|
|
22
|
+
return { started: true, run };
|
|
23
|
+
}
|
|
24
|
+
/** The names due at `now`: never fired, or last fired at least `every` ms ago. */
|
|
25
|
+
export function schedulesDue(schedules, now, lastFired) {
|
|
26
|
+
return schedules
|
|
27
|
+
.filter((schedule) => {
|
|
28
|
+
const last = lastFired.get(schedule.name);
|
|
29
|
+
return last === undefined || now.getTime() - last.getTime() >= schedule.every;
|
|
30
|
+
})
|
|
31
|
+
.map((schedule) => schedule.name);
|
|
32
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { StepContext } from "@hyperfixation/workflows";
|
|
2
|
+
import type { SpecDefinition } from "./specs.js";
|
|
3
|
+
/** What a scorer returns; `writeScore` takes the same fields. */
|
|
4
|
+
export interface Scored {
|
|
5
|
+
readonly score: number;
|
|
6
|
+
readonly explanation?: string;
|
|
7
|
+
/** The `hf_llm_call` row behind an LLM-assigned score; a rule-based score has none. */
|
|
8
|
+
readonly llmCallId?: number;
|
|
9
|
+
}
|
|
10
|
+
export interface ScorerDefinition<R = unknown, C = unknown> {
|
|
11
|
+
readonly name: string;
|
|
12
|
+
readonly recordType: string;
|
|
13
|
+
readonly spec: SpecDefinition<C>;
|
|
14
|
+
/** `ctx` is what an LLM-assigned score needs: `llm.run` ledgers against it. */
|
|
15
|
+
score(record: R, criteria: C, ctx: StepContext): Promise<Scored>;
|
|
16
|
+
}
|
|
17
|
+
export declare function defineScorer<R, C>(definition: ScorerDefinition<R, C>): ScorerDefinition<R, C>;
|
package/dist/scorers.js
ADDED
package/dist/scores.d.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { type RecordTable } from "@hyperfixation/db";
|
|
2
|
+
import type { StepContext } from "@hyperfixation/workflows";
|
|
3
|
+
import type { ClientBase, Pool } from "pg";
|
|
4
|
+
import type { Registry } from "./registry.js";
|
|
5
|
+
import type { SpecDefinition } from "./specs.js";
|
|
6
|
+
/**
|
|
7
|
+
* `DO NOTHING` fires only for a row that carries a `run_id`/`key` pair, which is the partial
|
|
8
|
+
* unique index's predicate: a web-side or untracked write passes null and always inserts.
|
|
9
|
+
*/
|
|
10
|
+
export declare const WRITE_SCORE_STATEMENT: string;
|
|
11
|
+
export declare const EXISTING_SCORE_STATEMENT = "SELECT id FROM hf_score WHERE run_id = $1 AND key = $2 AND spec_name = $3";
|
|
12
|
+
/**
|
|
13
|
+
* The newest row per spec, not per record: two specs scoring one record each keep their own
|
|
14
|
+
* answer, and a later run of one never supersedes the other.
|
|
15
|
+
*/
|
|
16
|
+
export declare const LATEST_SCORES_STATEMENT: string;
|
|
17
|
+
export interface WriteScoreOptions {
|
|
18
|
+
recordType: string;
|
|
19
|
+
recordId: string | number;
|
|
20
|
+
spec: SpecDefinition;
|
|
21
|
+
score: number;
|
|
22
|
+
explanation?: string;
|
|
23
|
+
llmCallId?: number;
|
|
24
|
+
/** Set together: `(run_id, key, spec_name)` is what makes a step-side write survive a replay. */
|
|
25
|
+
runId?: string;
|
|
26
|
+
key?: string;
|
|
27
|
+
}
|
|
28
|
+
export interface ScoreWritten {
|
|
29
|
+
id: number;
|
|
30
|
+
/** False when a replay found the row its own key had already written. */
|
|
31
|
+
created: boolean;
|
|
32
|
+
}
|
|
33
|
+
/** What a step hands `scores.write`; the run and the key come from the step. */
|
|
34
|
+
export interface StepWriteScoreOptions {
|
|
35
|
+
recordType: string;
|
|
36
|
+
recordId: string | number;
|
|
37
|
+
spec: SpecDefinition;
|
|
38
|
+
score: number;
|
|
39
|
+
explanation?: string;
|
|
40
|
+
llmCallId?: number;
|
|
41
|
+
/** Distinguishes two scores one step writes; defaults to the step's key. */
|
|
42
|
+
key?: string;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Always an INSERT, never an UPDATE: `hf_score` is the history of what each spec version
|
|
46
|
+
* thought, so scoring a record again — under this version or the next — adds a row and leaves
|
|
47
|
+
* every earlier one standing.
|
|
48
|
+
*
|
|
49
|
+
* Takes a bare queryable so the step-side `scores.write` can hand it the open `ctx.tx` client;
|
|
50
|
+
* this function neither opens a transaction nor touches the record's mixin columns.
|
|
51
|
+
*/
|
|
52
|
+
export declare function writeScore(queryable: Pool | ClientBase, options: WriteScoreOptions): Promise<ScoreWritten>;
|
|
53
|
+
/** One row per spec, newest first by spec name; a spec that never scored the record has none. */
|
|
54
|
+
export interface LatestScoreRow {
|
|
55
|
+
id: number;
|
|
56
|
+
/** Null for a row written before `spec_name` existed. */
|
|
57
|
+
specName: string | null;
|
|
58
|
+
specVersion: number;
|
|
59
|
+
score: number;
|
|
60
|
+
explanation: string | null;
|
|
61
|
+
llmCallId: number | null;
|
|
62
|
+
createdAt: Date;
|
|
63
|
+
}
|
|
64
|
+
export interface LatestScoresOptions {
|
|
65
|
+
recordType: string;
|
|
66
|
+
recordId: string | number;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* What each spec currently says about one record, out of `hf_score`'s history.
|
|
70
|
+
*
|
|
71
|
+
* The record's own mixin columns hold whichever spec scored last, so they cannot answer this
|
|
72
|
+
* for a record two specs score; the table can, and this is the read that does it.
|
|
73
|
+
*/
|
|
74
|
+
export declare function latestScores(queryable: Pool | ClientBase, options: LatestScoresOptions): Promise<LatestScoreRow[]>;
|
|
75
|
+
/**
|
|
76
|
+
* A scorer's write, inside `ctx.tx`: the `hf_score` row, the record's mixin columns and the
|
|
77
|
+
* timeline entry commit together. The mixin UPDATE is idempotent by construction — it writes the
|
|
78
|
+
* same three values a replay would — so only the insert needs the key.
|
|
79
|
+
*/
|
|
80
|
+
export declare function writeStepScore(ctx: StepContext, records: Registry<RecordTable>, options: StepWriteScoreOptions): Promise<ScoreWritten>;
|
package/dist/scores.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { quoteIdent } from "@hyperfixation/db";
|
|
2
|
+
import { insertActivity } from "./activity.js";
|
|
3
|
+
import { stepClient } from "./step-client.js";
|
|
4
|
+
/**
|
|
5
|
+
* `DO NOTHING` fires only for a row that carries a `run_id`/`key` pair, which is the partial
|
|
6
|
+
* unique index's predicate: a web-side or untracked write passes null and always inserts.
|
|
7
|
+
*/
|
|
8
|
+
export const WRITE_SCORE_STATEMENT = "INSERT INTO hf_score (record_type, record_id, spec_name, spec_version, score, explanation, " +
|
|
9
|
+
"llm_call_id, run_id, key) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) " +
|
|
10
|
+
"ON CONFLICT (run_id, key, spec_name) WHERE key IS NOT NULL DO NOTHING RETURNING id";
|
|
11
|
+
export const EXISTING_SCORE_STATEMENT = "SELECT id FROM hf_score WHERE run_id = $1 AND key = $2 AND spec_name = $3";
|
|
12
|
+
/**
|
|
13
|
+
* The newest row per spec, not per record: two specs scoring one record each keep their own
|
|
14
|
+
* answer, and a later run of one never supersedes the other.
|
|
15
|
+
*/
|
|
16
|
+
export const LATEST_SCORES_STATEMENT = "SELECT DISTINCT ON (spec_name) id, spec_name, spec_version, score, explanation, llm_call_id, " +
|
|
17
|
+
"created_at FROM hf_score WHERE record_type = $1 AND record_id = $2 " +
|
|
18
|
+
"ORDER BY spec_name, id DESC";
|
|
19
|
+
/**
|
|
20
|
+
* The mixin's three score columns — the record's current answer, over `hf_score`'s history.
|
|
21
|
+
* They hold whichever spec scored last; `latestScores` is what answers per spec.
|
|
22
|
+
*/
|
|
23
|
+
const scoreMixinStatement = (table) => `UPDATE ${quoteIdent(table)} SET score = $1, score_explanation = $2, spec_version = $3 ` +
|
|
24
|
+
"WHERE id = $4";
|
|
25
|
+
/**
|
|
26
|
+
* Always an INSERT, never an UPDATE: `hf_score` is the history of what each spec version
|
|
27
|
+
* thought, so scoring a record again — under this version or the next — adds a row and leaves
|
|
28
|
+
* every earlier one standing.
|
|
29
|
+
*
|
|
30
|
+
* Takes a bare queryable so the step-side `scores.write` can hand it the open `ctx.tx` client;
|
|
31
|
+
* this function neither opens a transaction nor touches the record's mixin columns.
|
|
32
|
+
*/
|
|
33
|
+
export async function writeScore(queryable, options) {
|
|
34
|
+
const runId = options.runId ?? null;
|
|
35
|
+
const key = options.key ?? null;
|
|
36
|
+
const inserted = await queryable.query(WRITE_SCORE_STATEMENT, [
|
|
37
|
+
options.recordType,
|
|
38
|
+
String(options.recordId),
|
|
39
|
+
options.spec.name,
|
|
40
|
+
options.spec.version,
|
|
41
|
+
options.score,
|
|
42
|
+
options.explanation ?? null,
|
|
43
|
+
options.llmCallId ?? null,
|
|
44
|
+
runId,
|
|
45
|
+
key,
|
|
46
|
+
]);
|
|
47
|
+
if (inserted.rows[0] !== undefined)
|
|
48
|
+
return { id: Number(inserted.rows[0].id), created: true };
|
|
49
|
+
const found = await queryable.query(EXISTING_SCORE_STATEMENT, [
|
|
50
|
+
runId,
|
|
51
|
+
key,
|
|
52
|
+
options.spec.name,
|
|
53
|
+
]);
|
|
54
|
+
return { id: Number(found.rows[0].id), created: false };
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* What each spec currently says about one record, out of `hf_score`'s history.
|
|
58
|
+
*
|
|
59
|
+
* The record's own mixin columns hold whichever spec scored last, so they cannot answer this
|
|
60
|
+
* for a record two specs score; the table can, and this is the read that does it.
|
|
61
|
+
*/
|
|
62
|
+
export async function latestScores(queryable, options) {
|
|
63
|
+
const { rows } = await queryable.query(LATEST_SCORES_STATEMENT, [
|
|
64
|
+
options.recordType,
|
|
65
|
+
String(options.recordId),
|
|
66
|
+
]);
|
|
67
|
+
return rows.map((row) => ({
|
|
68
|
+
id: Number(row.id),
|
|
69
|
+
specName: row.spec_name,
|
|
70
|
+
specVersion: row.spec_version,
|
|
71
|
+
score: row.score,
|
|
72
|
+
explanation: row.explanation,
|
|
73
|
+
llmCallId: row.llm_call_id === null ? null : Number(row.llm_call_id),
|
|
74
|
+
createdAt: row.created_at,
|
|
75
|
+
}));
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* A scorer's write, inside `ctx.tx`: the `hf_score` row, the record's mixin columns and the
|
|
79
|
+
* timeline entry commit together. The mixin UPDATE is idempotent by construction — it writes the
|
|
80
|
+
* same three values a replay would — so only the insert needs the key.
|
|
81
|
+
*/
|
|
82
|
+
export async function writeStepScore(ctx, records, options) {
|
|
83
|
+
const { table } = records.require(options.recordType);
|
|
84
|
+
const key = options.key ?? ctx.key;
|
|
85
|
+
const recordId = String(options.recordId);
|
|
86
|
+
return ctx.tx(async (db) => {
|
|
87
|
+
const client = stepClient(db);
|
|
88
|
+
const written = await writeScore(client, { ...options, runId: ctx.runId, key });
|
|
89
|
+
await client.query(scoreMixinStatement(table), [
|
|
90
|
+
options.score,
|
|
91
|
+
options.explanation ?? null,
|
|
92
|
+
options.spec.version,
|
|
93
|
+
recordId,
|
|
94
|
+
]);
|
|
95
|
+
await insertActivity(client, {
|
|
96
|
+
recordType: options.recordType,
|
|
97
|
+
recordId,
|
|
98
|
+
kind: "score.written",
|
|
99
|
+
runId: ctx.runId,
|
|
100
|
+
// `hf_activity` has no spec column, so the spec's name is in the key: two specs one step
|
|
101
|
+
// scores share its key, and the second's timeline entry would otherwise be dropped as a
|
|
102
|
+
// replay of the first's.
|
|
103
|
+
key: `${key}:score.written:${options.spec.name}`,
|
|
104
|
+
meta: {
|
|
105
|
+
scoreId: written.id,
|
|
106
|
+
spec: options.spec.name,
|
|
107
|
+
specVersion: options.spec.version,
|
|
108
|
+
score: options.score,
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
return written;
|
|
112
|
+
});
|
|
113
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** One row as a source yields it, shaped for `hf_source_record (source, external_id, payload)`. */
|
|
2
|
+
export interface SourceRow<P> {
|
|
3
|
+
readonly externalId: string;
|
|
4
|
+
readonly payload: P;
|
|
5
|
+
}
|
|
6
|
+
export interface SourceDefinition<P = unknown> {
|
|
7
|
+
readonly name: string;
|
|
8
|
+
/** What `defineRecord` calls the records this source produces. */
|
|
9
|
+
readonly recordType: string;
|
|
10
|
+
/** Streamed rather than returned: the loader COPYs it, and a full source need not fit in memory. */
|
|
11
|
+
fetch(): AsyncIterable<SourceRow<P>>;
|
|
12
|
+
}
|
|
13
|
+
export declare function defineSource<P>(definition: SourceDefinition<P>): SourceDefinition<P>;
|
package/dist/sources.js
ADDED
package/dist/specs.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a scorer scores against, versioned. One live version per name: `hf_score.spec_version`
|
|
3
|
+
* and the record mixin's `spec_version` carry the number of the spec a stored score was written
|
|
4
|
+
* under, so a new version adds rows and never rewrites what the old one decided.
|
|
5
|
+
*/
|
|
6
|
+
export interface SpecDefinition<C = unknown> {
|
|
7
|
+
readonly name: string;
|
|
8
|
+
/** An integer of at least 1, bumped whenever `criteria` changes meaning. */
|
|
9
|
+
readonly version: number;
|
|
10
|
+
readonly criteria: C;
|
|
11
|
+
}
|
|
12
|
+
export declare function defineSpec<C>(definition: SpecDefinition<C>): SpecDefinition<C>;
|
package/dist/specs.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { InvalidDefinition } from "./registry.js";
|
|
2
|
+
export function defineSpec(definition) {
|
|
3
|
+
if (!Number.isInteger(definition.version) || definition.version < 1) {
|
|
4
|
+
throw new InvalidDefinition("spec", definition.name, `has a version of ${definition.version}, which is not an integer of at least 1`);
|
|
5
|
+
}
|
|
6
|
+
return definition;
|
|
7
|
+
}
|