@kontourai/lookout 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 +55 -0
- package/README.md +163 -0
- package/bin/lookout.mjs +5 -0
- package/dist/src/canonical-value.d.ts +29 -0
- package/dist/src/canonical-value.js +109 -0
- package/dist/src/check-result.d.ts +32 -0
- package/dist/src/check-result.js +1 -0
- package/dist/src/check-runner.d.ts +18 -0
- package/dist/src/check-runner.js +90 -0
- package/dist/src/cli.d.ts +14 -0
- package/dist/src/cli.js +173 -0
- package/dist/src/index.d.ts +20 -0
- package/dist/src/index.js +10 -0
- package/dist/src/observation-store.d.ts +55 -0
- package/dist/src/observation-store.js +241 -0
- package/dist/src/proposal-diff.d.ts +80 -0
- package/dist/src/proposal-diff.js +208 -0
- package/dist/src/provider-resolution.d.ts +4 -0
- package/dist/src/provider-resolution.js +2 -0
- package/dist/src/registry.d.ts +29 -0
- package/dist/src/registry.js +141 -0
- package/dist/src/snapshot-store.d.ts +2 -0
- package/dist/src/snapshot-store.js +5 -0
- package/dist/src/structural-diff.d.ts +24 -0
- package/dist/src/structural-diff.js +94 -0
- package/dist/src/survey-emission.d.ts +68 -0
- package/dist/src/survey-emission.js +138 -0
- package/package.json +57 -0
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { canonicalValueKey, } from "./canonical-value.js";
|
|
2
|
+
import { compareStructural, diffKeyedMultiset } from "./structural-diff.js";
|
|
3
|
+
function callbackError(label, cause) {
|
|
4
|
+
return { kind: "callback-threw", message: `${label} callback threw`, cause };
|
|
5
|
+
}
|
|
6
|
+
function invoke(label, callback) {
|
|
7
|
+
try {
|
|
8
|
+
return { ok: true, value: callback() };
|
|
9
|
+
}
|
|
10
|
+
catch (cause) {
|
|
11
|
+
return { ok: false, error: callbackError(label, cause) };
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
function identity(label, callback) {
|
|
15
|
+
const called = invoke(label, callback);
|
|
16
|
+
if (!called.ok)
|
|
17
|
+
return called;
|
|
18
|
+
if (typeof called.value === "string")
|
|
19
|
+
return { ok: true, value: called.value };
|
|
20
|
+
return called.value.ok ? { ok: true, value: called.value.key } : called.value;
|
|
21
|
+
}
|
|
22
|
+
export function extractionProposalIdentity(proposal) {
|
|
23
|
+
try {
|
|
24
|
+
const pathIndices = Object.prototype.hasOwnProperty.call(proposal, "pathIndices")
|
|
25
|
+
? { present: true, value: proposal.pathIndices }
|
|
26
|
+
: { present: false };
|
|
27
|
+
const encoded = canonicalValueKey({
|
|
28
|
+
fieldPath: proposal.fieldPath,
|
|
29
|
+
pathIndices,
|
|
30
|
+
locator: proposal.provenance.locator,
|
|
31
|
+
});
|
|
32
|
+
return encoded.ok ? { ok: true, key: encoded.key } : encoded;
|
|
33
|
+
}
|
|
34
|
+
catch (cause) {
|
|
35
|
+
return {
|
|
36
|
+
ok: false,
|
|
37
|
+
error: { kind: "unsupported-value", message: "Extraction proposal identity could not be inspected", path: "$", cause },
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function evidence(observation, entityKey, fieldKey, proposal) {
|
|
42
|
+
return {
|
|
43
|
+
sourceId: observation.sourceId,
|
|
44
|
+
snapshotRef: observation.snapshotRef,
|
|
45
|
+
observedAt: observation.observedAt,
|
|
46
|
+
entityKey,
|
|
47
|
+
fieldKey,
|
|
48
|
+
value: proposal.candidateValue,
|
|
49
|
+
confidence: proposal.confidence,
|
|
50
|
+
provenance: proposal.provenance,
|
|
51
|
+
extractor: proposal.extractor,
|
|
52
|
+
fieldPath: proposal.fieldPath,
|
|
53
|
+
...(Object.prototype.hasOwnProperty.call(proposal, "pathIndices")
|
|
54
|
+
? { pathIndices: proposal.pathIndices }
|
|
55
|
+
: {}),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
function changeKind(prior, current) {
|
|
59
|
+
if (prior === undefined && current !== undefined)
|
|
60
|
+
return { ok: true, value: "value-populated" };
|
|
61
|
+
if (Array.isArray(prior) && Array.isArray(current)) {
|
|
62
|
+
const facts = diffKeyedMultiset(prior, current, { identity: canonicalValueKey });
|
|
63
|
+
if (!facts.ok)
|
|
64
|
+
return facts;
|
|
65
|
+
const added = facts.value.additions.length > 0;
|
|
66
|
+
const removed = facts.value.removals.length > 0;
|
|
67
|
+
if (added && !removed)
|
|
68
|
+
return { ok: true, value: "items-added" };
|
|
69
|
+
if (removed && !added)
|
|
70
|
+
return { ok: true, value: "items-removed" };
|
|
71
|
+
return { ok: true, value: "value-replaced" };
|
|
72
|
+
}
|
|
73
|
+
const sameCategory = (prior === null ? "null" : typeof prior) === (current === null ? "null" : typeof current);
|
|
74
|
+
return { ok: true, value: sameCategory ? "value-updated" : "value-replaced" };
|
|
75
|
+
}
|
|
76
|
+
export function diffProposalSets(input) {
|
|
77
|
+
if (input.prior.sourceId !== input.current.sourceId) {
|
|
78
|
+
return {
|
|
79
|
+
ok: false,
|
|
80
|
+
error: { kind: "unsupported-value", message: "Proposal observations must have the same sourceId", path: "$.sourceId" },
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
const priorEntities = invoke("selectEntities", () => input.selectEntities(input.prior));
|
|
84
|
+
if (!priorEntities.ok)
|
|
85
|
+
return priorEntities;
|
|
86
|
+
const currentEntities = invoke("selectEntities", () => input.selectEntities(input.current));
|
|
87
|
+
if (!currentEntities.ok)
|
|
88
|
+
return currentEntities;
|
|
89
|
+
const entities = diffKeyedMultiset(priorEntities.value, currentEntities.value, {
|
|
90
|
+
identity: (entity) => input.entityIdentity(entity),
|
|
91
|
+
});
|
|
92
|
+
if (!entities.ok)
|
|
93
|
+
return entities;
|
|
94
|
+
const events = [];
|
|
95
|
+
const retainedProposalOccurrences = [];
|
|
96
|
+
const addedProposalOccurrences = [];
|
|
97
|
+
const removedProposalOccurrences = [];
|
|
98
|
+
const provenanceChanges = [];
|
|
99
|
+
const removedEntities = [];
|
|
100
|
+
for (const pair of entities.value.retained) {
|
|
101
|
+
const entityKeyResult = identity("entityIdentity", () => input.entityIdentity(pair.prior));
|
|
102
|
+
if (!entityKeyResult.ok)
|
|
103
|
+
return entityKeyResult;
|
|
104
|
+
const entityKey = entityKeyResult.value;
|
|
105
|
+
const priorProposals = invoke("proposalsFor", () => input.proposalsFor(pair.prior));
|
|
106
|
+
if (!priorProposals.ok)
|
|
107
|
+
return priorProposals;
|
|
108
|
+
const currentProposals = invoke("proposalsFor", () => input.proposalsFor(pair.current));
|
|
109
|
+
if (!currentProposals.ok)
|
|
110
|
+
return currentProposals;
|
|
111
|
+
const occurrences = diffKeyedMultiset(priorProposals.value, currentProposals.value, {
|
|
112
|
+
identity: extractionProposalIdentity,
|
|
113
|
+
});
|
|
114
|
+
if (!occurrences.ok)
|
|
115
|
+
return occurrences;
|
|
116
|
+
retainedProposalOccurrences.push(...occurrences.value.retained);
|
|
117
|
+
addedProposalOccurrences.push(...occurrences.value.additions);
|
|
118
|
+
removedProposalOccurrences.push(...occurrences.value.removals);
|
|
119
|
+
const priorFields = priorProposals.value.map((proposal) => ({ entity: pair.prior, proposal }));
|
|
120
|
+
const currentFields = currentProposals.value.map((proposal) => ({ entity: pair.current, proposal }));
|
|
121
|
+
const fields = diffKeyedMultiset(priorFields, currentFields, {
|
|
122
|
+
identity: ({ entity, proposal }) => input.fieldIdentity(entity, proposal),
|
|
123
|
+
});
|
|
124
|
+
if (!fields.ok)
|
|
125
|
+
return fields;
|
|
126
|
+
for (const field of fields.value.retained) {
|
|
127
|
+
const fieldKeyResult = identity("fieldIdentity", () => input.fieldIdentity(field.prior.entity, field.prior.proposal));
|
|
128
|
+
if (!fieldKeyResult.ok)
|
|
129
|
+
return fieldKeyResult;
|
|
130
|
+
const fieldKey = fieldKeyResult.value;
|
|
131
|
+
const comparison = compareStructural(field.prior.proposal, field.current.proposal, {
|
|
132
|
+
value: (proposal) => proposal.candidateValue,
|
|
133
|
+
provenance: (proposal) => proposal.provenance,
|
|
134
|
+
});
|
|
135
|
+
if (!comparison.ok)
|
|
136
|
+
return comparison;
|
|
137
|
+
const priorEvidence = evidence(input.prior, entityKey, fieldKey, field.prior.proposal);
|
|
138
|
+
const currentEvidence = evidence(input.current, entityKey, fieldKey, field.current.proposal);
|
|
139
|
+
if (comparison.value.provenanceChanged) {
|
|
140
|
+
provenanceChanges.push({ entityKey, fieldKey, prior: priorEvidence, current: currentEvidence });
|
|
141
|
+
}
|
|
142
|
+
if (comparison.value.valueChanged) {
|
|
143
|
+
const kind = changeKind(field.prior.proposal.candidateValue, field.current.proposal.candidateValue);
|
|
144
|
+
if (!kind.ok)
|
|
145
|
+
return kind;
|
|
146
|
+
events.push({ kind: "field-changed", entityKey, fieldKey, changeKind: kind.value, prior: priorEvidence, current: currentEvidence });
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
for (const field of fields.value.additions) {
|
|
150
|
+
const fieldKey = identity("fieldIdentity", () => input.fieldIdentity(field.entity, field.proposal));
|
|
151
|
+
if (!fieldKey.ok)
|
|
152
|
+
return fieldKey;
|
|
153
|
+
events.push({
|
|
154
|
+
kind: "field-changed",
|
|
155
|
+
entityKey,
|
|
156
|
+
fieldKey: fieldKey.value,
|
|
157
|
+
changeKind: "value-populated",
|
|
158
|
+
current: evidence(input.current, entityKey, fieldKey.value, field.proposal),
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
for (const field of fields.value.removals) {
|
|
162
|
+
const fieldKey = identity("fieldIdentity", () => input.fieldIdentity(field.entity, field.proposal));
|
|
163
|
+
if (!fieldKey.ok)
|
|
164
|
+
return fieldKey;
|
|
165
|
+
events.push({
|
|
166
|
+
kind: "field-changed",
|
|
167
|
+
entityKey,
|
|
168
|
+
fieldKey: fieldKey.value,
|
|
169
|
+
changeKind: Array.isArray(field.proposal.candidateValue) ? "items-removed" : "value-replaced",
|
|
170
|
+
prior: evidence(input.prior, entityKey, fieldKey.value, field.proposal),
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
for (const entity of entities.value.additions) {
|
|
175
|
+
const entityKeyResult = identity("entityIdentity", () => input.entityIdentity(entity));
|
|
176
|
+
if (!entityKeyResult.ok)
|
|
177
|
+
return entityKeyResult;
|
|
178
|
+
const proposals = invoke("proposalsFor", () => input.proposalsFor(entity));
|
|
179
|
+
if (!proposals.ok)
|
|
180
|
+
return proposals;
|
|
181
|
+
const current = [];
|
|
182
|
+
for (const proposal of proposals.value) {
|
|
183
|
+
const fieldKey = identity("fieldIdentity", () => input.fieldIdentity(entity, proposal));
|
|
184
|
+
if (!fieldKey.ok)
|
|
185
|
+
return fieldKey;
|
|
186
|
+
current.push(evidence(input.current, entityKeyResult.value, fieldKey.value, proposal));
|
|
187
|
+
addedProposalOccurrences.push(proposal);
|
|
188
|
+
}
|
|
189
|
+
events.push({ kind: "new-entity-appeared", entityKey: entityKeyResult.value, current });
|
|
190
|
+
}
|
|
191
|
+
for (const entity of entities.value.removals) {
|
|
192
|
+
const entityKeyResult = identity("entityIdentity", () => input.entityIdentity(entity));
|
|
193
|
+
if (!entityKeyResult.ok)
|
|
194
|
+
return entityKeyResult;
|
|
195
|
+
removedEntities.push(entityKeyResult.value);
|
|
196
|
+
const proposals = invoke("proposalsFor", () => input.proposalsFor(entity));
|
|
197
|
+
if (!proposals.ok)
|
|
198
|
+
return proposals;
|
|
199
|
+
removedProposalOccurrences.push(...proposals.value);
|
|
200
|
+
}
|
|
201
|
+
return {
|
|
202
|
+
ok: true,
|
|
203
|
+
value: {
|
|
204
|
+
events,
|
|
205
|
+
facts: { retainedProposalOccurrences, addedProposalOccurrences, removedProposalOccurrences, provenanceChanges, removedEntities },
|
|
206
|
+
},
|
|
207
|
+
};
|
|
208
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { TargetFieldSchema } from "@kontourai/traverse";
|
|
2
|
+
export type LookoutSourceKind = "web-page" | "api-record";
|
|
3
|
+
export type RenderPolicy = "never" | "on-shell-warning" | "always";
|
|
4
|
+
export interface LookoutSource {
|
|
5
|
+
id: string;
|
|
6
|
+
kind: LookoutSourceKind;
|
|
7
|
+
url: string;
|
|
8
|
+
targetSchema: TargetFieldSchema[];
|
|
9
|
+
cadenceHint: string;
|
|
10
|
+
renderPolicy: RenderPolicy;
|
|
11
|
+
}
|
|
12
|
+
export interface LookoutRegistryDocument {
|
|
13
|
+
version: 1;
|
|
14
|
+
sources: LookoutSource[];
|
|
15
|
+
}
|
|
16
|
+
export declare class RegistryValidationError extends Error {
|
|
17
|
+
readonly issues: string[];
|
|
18
|
+
constructor(issues: string[]);
|
|
19
|
+
}
|
|
20
|
+
export declare class LookoutRegistry {
|
|
21
|
+
#private;
|
|
22
|
+
readonly version: 1;
|
|
23
|
+
readonly sources: readonly LookoutSource[];
|
|
24
|
+
constructor(sources: LookoutSource[]);
|
|
25
|
+
list(): readonly LookoutSource[];
|
|
26
|
+
get(id: string): LookoutSource | undefined;
|
|
27
|
+
}
|
|
28
|
+
export declare function loadRegistry(registryPath?: string): Promise<LookoutRegistry>;
|
|
29
|
+
export declare function parseRegistry(value: unknown): LookoutRegistry;
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
export class RegistryValidationError extends Error {
|
|
4
|
+
issues;
|
|
5
|
+
constructor(issues) {
|
|
6
|
+
super(`Invalid Lookout registry:\n${issues.map((issue) => `- ${issue}`).join("\n")}`);
|
|
7
|
+
this.name = "RegistryValidationError";
|
|
8
|
+
this.issues = issues;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
export class LookoutRegistry {
|
|
12
|
+
version = 1;
|
|
13
|
+
sources;
|
|
14
|
+
#byId;
|
|
15
|
+
constructor(sources) {
|
|
16
|
+
this.sources = sources;
|
|
17
|
+
this.#byId = new Map(sources.map((source) => [source.id, source]));
|
|
18
|
+
}
|
|
19
|
+
list() {
|
|
20
|
+
return this.sources;
|
|
21
|
+
}
|
|
22
|
+
get(id) {
|
|
23
|
+
return this.#byId.get(id);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export async function loadRegistry(registryPath = path.join(process.cwd(), "lookout.sources.json")) {
|
|
27
|
+
const raw = await readFile(registryPath, "utf8");
|
|
28
|
+
let parsed;
|
|
29
|
+
try {
|
|
30
|
+
parsed = JSON.parse(raw);
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
34
|
+
throw new RegistryValidationError([`document is not valid JSON: ${message}`]);
|
|
35
|
+
}
|
|
36
|
+
return parseRegistry(parsed);
|
|
37
|
+
}
|
|
38
|
+
export function parseRegistry(value) {
|
|
39
|
+
const issues = [];
|
|
40
|
+
if (!isRecord(value)) {
|
|
41
|
+
throw new RegistryValidationError(["document must be an object"]);
|
|
42
|
+
}
|
|
43
|
+
if (value.version !== 1)
|
|
44
|
+
issues.push("version must be exactly 1");
|
|
45
|
+
if (!Array.isArray(value.sources)) {
|
|
46
|
+
issues.push("sources must be an array");
|
|
47
|
+
throw new RegistryValidationError(issues);
|
|
48
|
+
}
|
|
49
|
+
const ids = new Map();
|
|
50
|
+
const sources = [];
|
|
51
|
+
value.sources.forEach((candidate, index) => {
|
|
52
|
+
const context = `sources[${index}]`;
|
|
53
|
+
if (!isRecord(candidate)) {
|
|
54
|
+
issues.push(`${context} must be an object`);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const id = candidate.id;
|
|
58
|
+
const label = typeof id === "string" && id.trim() ? `${context} (id "${id}")` : context;
|
|
59
|
+
if (typeof id !== "string" || id.trim() === "") {
|
|
60
|
+
issues.push(`${context}.id must be a non-empty string`);
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
const first = ids.get(id);
|
|
64
|
+
if (first !== undefined)
|
|
65
|
+
issues.push(`${label}.id duplicates sources[${first}].id`);
|
|
66
|
+
else
|
|
67
|
+
ids.set(id, index);
|
|
68
|
+
}
|
|
69
|
+
if (candidate.kind !== "web-page" && candidate.kind !== "api-record") {
|
|
70
|
+
issues.push(`${label}.kind must be "web-page" or "api-record"`);
|
|
71
|
+
}
|
|
72
|
+
if (!isHttpUrl(candidate.url)) {
|
|
73
|
+
issues.push(`${label}.url must be an absolute HTTP(S) URL`);
|
|
74
|
+
}
|
|
75
|
+
if (typeof candidate.cadenceHint !== "string" || candidate.cadenceHint.trim() === "") {
|
|
76
|
+
issues.push(`${label}.cadenceHint must be a non-empty string`);
|
|
77
|
+
}
|
|
78
|
+
if (candidate.renderPolicy !== "never" &&
|
|
79
|
+
candidate.renderPolicy !== "on-shell-warning" &&
|
|
80
|
+
candidate.renderPolicy !== "always") {
|
|
81
|
+
issues.push(`${label}.renderPolicy must be "never", "on-shell-warning", or "always"`);
|
|
82
|
+
}
|
|
83
|
+
validateTargetSchema(candidate.targetSchema, label, issues);
|
|
84
|
+
if (typeof id === "string" && id.trim() !== "" &&
|
|
85
|
+
(candidate.kind === "web-page" || candidate.kind === "api-record") &&
|
|
86
|
+
isHttpUrl(candidate.url) &&
|
|
87
|
+
typeof candidate.cadenceHint === "string" && candidate.cadenceHint.trim() !== "" &&
|
|
88
|
+
(candidate.renderPolicy === "never" || candidate.renderPolicy === "on-shell-warning" || candidate.renderPolicy === "always") &&
|
|
89
|
+
Array.isArray(candidate.targetSchema)) {
|
|
90
|
+
sources.push(candidate);
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
if (issues.length > 0)
|
|
94
|
+
throw new RegistryValidationError(issues);
|
|
95
|
+
return new LookoutRegistry(sources);
|
|
96
|
+
}
|
|
97
|
+
function validateTargetSchema(value, context, issues) {
|
|
98
|
+
if (!Array.isArray(value)) {
|
|
99
|
+
issues.push(`${context}.targetSchema must be an array`);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
const allowedTypes = new Set(["string", "number", "boolean", "date", "enum", "array", "object"]);
|
|
103
|
+
value.forEach((field, index) => {
|
|
104
|
+
const label = `${context}.targetSchema[${index}]`;
|
|
105
|
+
if (!isRecord(field)) {
|
|
106
|
+
issues.push(`${label} must be an object`);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
if (typeof field.path !== "string" || field.path.trim() === "")
|
|
110
|
+
issues.push(`${label}.path must be a non-empty string`);
|
|
111
|
+
if (typeof field.type !== "string" || !allowedTypes.has(field.type))
|
|
112
|
+
issues.push(`${label}.type is invalid`);
|
|
113
|
+
if (field.enumValues !== undefined && (!Array.isArray(field.enumValues) || field.enumValues.some((item) => typeof item !== "string"))) {
|
|
114
|
+
issues.push(`${label}.enumValues must be an array of strings`);
|
|
115
|
+
}
|
|
116
|
+
if (field.type === "enum" && (!Array.isArray(field.enumValues) || field.enumValues.length === 0)) {
|
|
117
|
+
issues.push(`${label}.enumValues must be non-empty for enum fields`);
|
|
118
|
+
}
|
|
119
|
+
if (field.description !== undefined && typeof field.description !== "string")
|
|
120
|
+
issues.push(`${label}.description must be a string`);
|
|
121
|
+
if (field.required !== undefined && typeof field.required !== "boolean")
|
|
122
|
+
issues.push(`${label}.required must be a boolean`);
|
|
123
|
+
if (field.inferenceType !== undefined && field.inferenceType !== "explicit" && field.inferenceType !== "inferred") {
|
|
124
|
+
issues.push(`${label}.inferenceType must be "explicit" or "inferred"`);
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
function isRecord(value) {
|
|
129
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
130
|
+
}
|
|
131
|
+
function isHttpUrl(value) {
|
|
132
|
+
if (typeof value !== "string")
|
|
133
|
+
return false;
|
|
134
|
+
try {
|
|
135
|
+
const url = new URL(value);
|
|
136
|
+
return (url.protocol === "http:" || url.protocol === "https:") && Boolean(url.hostname);
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { createFilesystemSnapshotStore } from "@kontourai/traverse/fetch";
|
|
3
|
+
export function createLookoutSnapshotStore(root = path.join(process.cwd(), ".kontourai", "lookout", "snapshots")) {
|
|
4
|
+
return createFilesystemSnapshotStore({ root });
|
|
5
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { type DiffResult, type IdentityResult } from "./canonical-value.js";
|
|
2
|
+
export interface StructuralFacts<T> {
|
|
3
|
+
readonly valueChanged: boolean;
|
|
4
|
+
readonly provenanceChanged: boolean;
|
|
5
|
+
}
|
|
6
|
+
export type StructuralComparison<T> = DiffResult<StructuralFacts<T>>;
|
|
7
|
+
export interface StructuralComparisonOptions<T> {
|
|
8
|
+
readonly value?: (item: T) => unknown;
|
|
9
|
+
readonly provenance?: (item: T) => unknown;
|
|
10
|
+
}
|
|
11
|
+
export declare function compareStructural<T>(prior: T, current: T, options?: StructuralComparisonOptions<T>): StructuralComparison<T>;
|
|
12
|
+
export interface KeyedMultisetFacts<T, K extends string> {
|
|
13
|
+
readonly unchanged: boolean;
|
|
14
|
+
readonly retained: readonly {
|
|
15
|
+
readonly prior: T;
|
|
16
|
+
readonly current: T;
|
|
17
|
+
}[];
|
|
18
|
+
readonly additions: readonly T[];
|
|
19
|
+
readonly removals: readonly T[];
|
|
20
|
+
}
|
|
21
|
+
export interface KeyedMultisetOptions<T, K extends string> {
|
|
22
|
+
readonly identity: (item: T) => K | IdentityResult<K>;
|
|
23
|
+
}
|
|
24
|
+
export declare function diffKeyedMultiset<T, K extends string>(prior: readonly T[], current: readonly T[], options: KeyedMultisetOptions<T, K>): DiffResult<KeyedMultisetFacts<T, K>>;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { canonicalValueKey, } from "./canonical-value.js";
|
|
2
|
+
function callbackError(label, cause) {
|
|
3
|
+
return { kind: "callback-threw", message: `${label} callback threw`, cause };
|
|
4
|
+
}
|
|
5
|
+
function projected(label, projector, item) {
|
|
6
|
+
try {
|
|
7
|
+
return { ok: true, value: projector(item) };
|
|
8
|
+
}
|
|
9
|
+
catch (cause) {
|
|
10
|
+
return { ok: false, error: callbackError(label, cause) };
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
function equalCanonical(prior, current) {
|
|
14
|
+
const priorKey = canonicalValueKey(prior);
|
|
15
|
+
if (!priorKey.ok)
|
|
16
|
+
return priorKey;
|
|
17
|
+
const currentKey = canonicalValueKey(current);
|
|
18
|
+
if (!currentKey.ok)
|
|
19
|
+
return currentKey;
|
|
20
|
+
return { ok: true, value: priorKey.key === currentKey.key };
|
|
21
|
+
}
|
|
22
|
+
export function compareStructural(prior, current, options = {}) {
|
|
23
|
+
const valueProjector = options.value ?? ((item) => item);
|
|
24
|
+
const priorValue = projected("value", valueProjector, prior);
|
|
25
|
+
if (!priorValue.ok)
|
|
26
|
+
return priorValue;
|
|
27
|
+
const currentValue = projected("value", valueProjector, current);
|
|
28
|
+
if (!currentValue.ok)
|
|
29
|
+
return currentValue;
|
|
30
|
+
const valueEqual = equalCanonical(priorValue.value, currentValue.value);
|
|
31
|
+
if (!valueEqual.ok)
|
|
32
|
+
return valueEqual;
|
|
33
|
+
let provenanceChanged = false;
|
|
34
|
+
if (options.provenance) {
|
|
35
|
+
const priorProvenance = projected("provenance", options.provenance, prior);
|
|
36
|
+
if (!priorProvenance.ok)
|
|
37
|
+
return priorProvenance;
|
|
38
|
+
const currentProvenance = projected("provenance", options.provenance, current);
|
|
39
|
+
if (!currentProvenance.ok)
|
|
40
|
+
return currentProvenance;
|
|
41
|
+
const provenanceEqual = equalCanonical(priorProvenance.value, currentProvenance.value);
|
|
42
|
+
if (!provenanceEqual.ok)
|
|
43
|
+
return provenanceEqual;
|
|
44
|
+
provenanceChanged = !provenanceEqual.value;
|
|
45
|
+
}
|
|
46
|
+
return { ok: true, value: { valueChanged: !valueEqual.value, provenanceChanged } };
|
|
47
|
+
}
|
|
48
|
+
function identityOf(callback, item) {
|
|
49
|
+
try {
|
|
50
|
+
const result = callback(item);
|
|
51
|
+
if (typeof result === "string")
|
|
52
|
+
return { ok: true, value: result };
|
|
53
|
+
return result.ok ? { ok: true, value: result.key } : result;
|
|
54
|
+
}
|
|
55
|
+
catch (cause) {
|
|
56
|
+
return { ok: false, error: callbackError("identity", cause) };
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
export function diffKeyedMultiset(prior, current, options) {
|
|
60
|
+
const currentKeys = [];
|
|
61
|
+
for (const item of current) {
|
|
62
|
+
const identity = identityOf(options.identity, item);
|
|
63
|
+
if (!identity.ok)
|
|
64
|
+
return identity;
|
|
65
|
+
currentKeys.push(identity.value);
|
|
66
|
+
}
|
|
67
|
+
const queues = new Map();
|
|
68
|
+
for (let index = 0; index < currentKeys.length; index += 1) {
|
|
69
|
+
const key = currentKeys[index];
|
|
70
|
+
const queue = queues.get(key) ?? [];
|
|
71
|
+
queue.push(index);
|
|
72
|
+
queues.set(key, queue);
|
|
73
|
+
}
|
|
74
|
+
const retained = [];
|
|
75
|
+
const removals = [];
|
|
76
|
+
const matchedCurrent = new Set();
|
|
77
|
+
for (const item of prior) {
|
|
78
|
+
const identity = identityOf(options.identity, item);
|
|
79
|
+
if (!identity.ok)
|
|
80
|
+
return identity;
|
|
81
|
+
const currentIndex = queues.get(identity.value)?.shift();
|
|
82
|
+
if (currentIndex === undefined)
|
|
83
|
+
removals.push(item);
|
|
84
|
+
else {
|
|
85
|
+
matchedCurrent.add(currentIndex);
|
|
86
|
+
retained.push({ prior: item, current: current[currentIndex] });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const additions = current.filter((_item, index) => !matchedCurrent.has(index));
|
|
90
|
+
return {
|
|
91
|
+
ok: true,
|
|
92
|
+
value: { unchanged: additions.length === 0 && removals.length === 0, retained, additions, removals },
|
|
93
|
+
};
|
|
94
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { type SurveyInput } from "@kontourai/survey";
|
|
2
|
+
import type { LookoutSource } from "./registry.js";
|
|
3
|
+
import { type ProposalDiffEvent, type ProposalSetDiff, type ProposalSetDiffInput, type ProposalSetFacts, type ProposalSetObservation } from "./proposal-diff.js";
|
|
4
|
+
import type { ObservationCheckAnchor, ObservationStore, StoredProposalObservationV1 } from "./observation-store.js";
|
|
5
|
+
export interface BaselineEstablishedFact {
|
|
6
|
+
readonly kind: "baseline-established";
|
|
7
|
+
readonly sourceId: string;
|
|
8
|
+
readonly snapshotRef: string;
|
|
9
|
+
readonly observedAt: string;
|
|
10
|
+
readonly origin: LookoutSource["kind"];
|
|
11
|
+
readonly resolution: "observation";
|
|
12
|
+
readonly proposalCount: number;
|
|
13
|
+
}
|
|
14
|
+
export type EmissionFact = BaselineEstablishedFact | {
|
|
15
|
+
readonly kind: "proposal-set-facts";
|
|
16
|
+
readonly priorSnapshotRef: string;
|
|
17
|
+
readonly currentSnapshotRef: string;
|
|
18
|
+
readonly origin: LookoutSource["kind"];
|
|
19
|
+
readonly resolution: "observation";
|
|
20
|
+
readonly value: ProposalSetFacts;
|
|
21
|
+
};
|
|
22
|
+
export interface EmissionSuccess {
|
|
23
|
+
readonly sourceId: string;
|
|
24
|
+
readonly events: readonly ProposalDiffEvent[];
|
|
25
|
+
readonly facts: readonly EmissionFact[];
|
|
26
|
+
readonly surveyInput: SurveyInput | null;
|
|
27
|
+
readonly committedObservation: StoredProposalObservationV1;
|
|
28
|
+
readonly warnings: readonly string[];
|
|
29
|
+
}
|
|
30
|
+
export type EmissionErrorKind = "invalid-input" | "prior-state-error" | "diff-error" | "authoring-error" | "persistence-error";
|
|
31
|
+
export interface EmissionError {
|
|
32
|
+
readonly kind: EmissionErrorKind;
|
|
33
|
+
readonly message: string;
|
|
34
|
+
readonly cause?: unknown;
|
|
35
|
+
}
|
|
36
|
+
export type EmissionResult = {
|
|
37
|
+
readonly ok: true;
|
|
38
|
+
readonly value: EmissionSuccess;
|
|
39
|
+
} | {
|
|
40
|
+
readonly ok: false;
|
|
41
|
+
readonly error: EmissionError;
|
|
42
|
+
};
|
|
43
|
+
export interface EmitSurveyInput<E> {
|
|
44
|
+
readonly source: LookoutSource;
|
|
45
|
+
readonly current: ProposalSetObservation;
|
|
46
|
+
readonly check: ObservationCheckAnchor;
|
|
47
|
+
readonly callbacks: Omit<ProposalSetDiffInput<E>, "prior" | "current">;
|
|
48
|
+
}
|
|
49
|
+
export interface SurveyEmitter<E> {
|
|
50
|
+
emit(input: EmitSurveyInput<E>): Promise<EmissionResult>;
|
|
51
|
+
}
|
|
52
|
+
export interface CreateSurveyEmitterOptions<E> {
|
|
53
|
+
readonly store: ObservationStore;
|
|
54
|
+
readonly now?: () => string;
|
|
55
|
+
readonly diff?: (input: ProposalSetDiffInput<E>) => {
|
|
56
|
+
readonly ok: true;
|
|
57
|
+
readonly value: ProposalSetDiff;
|
|
58
|
+
} | {
|
|
59
|
+
readonly ok: false;
|
|
60
|
+
readonly error: {
|
|
61
|
+
readonly message: string;
|
|
62
|
+
};
|
|
63
|
+
};
|
|
64
|
+
/** Test seam. Production callers must leave this as observation. */
|
|
65
|
+
readonly resolution?: "observation";
|
|
66
|
+
readonly transformSurveyInput?: (input: SurveyInput) => SurveyInput;
|
|
67
|
+
}
|
|
68
|
+
export declare function createSurveyEmitter<E>(options: CreateSurveyEmitterOptions<E>): SurveyEmitter<E>;
|