@instruments/materialgraph 0.38.0 → 0.40.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/runs.d.ts CHANGED
@@ -15,8 +15,9 @@ export declare const getWorkflowRun: (transport: HttpTransport, workflowId: stri
15
15
  * /api/v1/workflows/{workflowId}/runs/{runId}/events`, SSE). Iterates until the stream ends or
16
16
  * `signal` aborts. One connection is bounded server-side (~25s or a terminal
17
17
  * event); pass `after` to resume from the last observed sequence.
18
+ * @yields {RunEvent} Each usable Run event from the stream.
18
19
  */
19
- export declare function streamRunEvents(transport: HttpTransport, workflowId: string, runId: string, options?: StreamRunEventsOptions): AsyncGenerator<RunEvent, void, unknown>;
20
+ export declare const streamRunEvents: (transport: HttpTransport, workflowId: string, runId: string, options?: StreamRunEventsOptions) => AsyncGenerator<RunEvent, void, unknown>;
20
21
  /**
21
22
  * Collect Run events until a terminal event, the deadline, or the stream ends —
22
23
  * the bounded events-log pattern the console uses. A single pass over
package/runs.js CHANGED
@@ -9,16 +9,52 @@ import { MaterialGraphError } from "./errors.js";
9
9
  import { findSdkOperation, operationPath } from "./operations.js";
10
10
  import { TERMINAL_RUN_EVENT_TYPES } from "./run-types.js";
11
11
  import { readSseFrames } from "./sse.js";
12
+ const hasSourceIdentity = (source) => typeof source === "object" &&
13
+ source !== null &&
14
+ "sourceId" in source &&
15
+ typeof source.sourceId === "string" &&
16
+ "role" in source &&
17
+ typeof source.role === "string" &&
18
+ "filename" in source &&
19
+ typeof source.filename === "string" &&
20
+ "mediaType" in source &&
21
+ typeof source.mediaType === "string";
22
+ const hasSourceMeasurements = (source) => typeof source === "object" &&
23
+ source !== null &&
24
+ "sha256" in source &&
25
+ typeof source.sha256 === "string" &&
26
+ "bytes" in source &&
27
+ typeof source.bytes === "number" &&
28
+ "pages" in source &&
29
+ (source.pages === null || typeof source.pages === "number");
30
+ const isSourceReceipt = (value) => typeof value === "object" &&
31
+ value !== null &&
32
+ "batchId" in value &&
33
+ typeof value.batchId === "string" &&
34
+ "sources" in value &&
35
+ Array.isArray(value.sources) &&
36
+ value.sources.every((source) => typeof source === "object" &&
37
+ source !== null &&
38
+ hasSourceIdentity(source) &&
39
+ hasSourceMeasurements(source));
40
+ const isRunEvent = (value) => typeof value === "object" &&
41
+ value !== null &&
42
+ "sequence" in value &&
43
+ typeof value.sequence === "number" &&
44
+ "type" in value &&
45
+ typeof value.type === "string";
12
46
  const runOperation = (operationId) => {
13
47
  const match = findSdkOperation(operationId);
14
48
  if (!match) {
15
49
  throw MaterialGraphError.unexpected(`Unknown SDK operation: ${operationId}`);
16
50
  }
17
- return match;
51
+ return { ...match, operationId };
18
52
  };
19
53
  /** Build a `Blob` from raw bytes, normalising to an ArrayBuffer-backed view. */
20
54
  const bytesToBlob = (input) => {
21
- const type = input.contentType ? { type: input.contentType } : {};
55
+ const type = input.contentType !== undefined && input.contentType !== ""
56
+ ? { type: input.contentType }
57
+ : {};
22
58
  if (input.data instanceof ArrayBuffer) {
23
59
  return new Blob([input.data], type);
24
60
  }
@@ -60,6 +96,9 @@ export const uploadSourceMaterial = async (transport, input, signal) => {
60
96
  signal,
61
97
  url,
62
98
  });
99
+ if (!isSourceReceipt(parsed)) {
100
+ throw MaterialGraphError.unexpected("MaterialGraph source-material upload returned an invalid receipt");
101
+ }
63
102
  return parsed;
64
103
  };
65
104
  /** Start an asynchronous Run of one workflow (`POST /api/v1/workflows/{workflowId}/runs`). */
@@ -85,22 +124,16 @@ const decodeRunEvent = (data) => {
85
124
  catch (cause) {
86
125
  throw MaterialGraphError.unexpected(`MaterialGraph run-event stream sent an unparseable data frame: ${String(cause)}`);
87
126
  }
88
- if (typeof parsed !== "object" || parsed === null) {
89
- return null;
90
- }
91
- const record = parsed;
92
- if (typeof record.sequence !== "number" || typeof record.type !== "string") {
93
- return null;
94
- }
95
- return parsed;
127
+ return isRunEvent(parsed) ? parsed : null;
96
128
  };
97
129
  /**
98
130
  * Stream ordered Run events as an async iterator (`GET
99
131
  * /api/v1/workflows/{workflowId}/runs/{runId}/events`, SSE). Iterates until the stream ends or
100
132
  * `signal` aborts. One connection is bounded server-side (~25s or a terminal
101
133
  * event); pass `after` to resume from the last observed sequence.
134
+ * @yields {RunEvent} Each usable Run event from the stream.
102
135
  */
103
- export async function* streamRunEvents(transport, workflowId, runId, options = {}) {
136
+ export const streamRunEvents = async function* streamRunEvents(transport, workflowId, runId, options = {}) {
104
137
  const body = await transport.openStream(runOperation("list_public_run_events"), {
105
138
  pathParams: { runId, workflowId },
106
139
  ...(options.after === undefined
@@ -116,7 +149,7 @@ export async function* streamRunEvents(transport, workflowId, runId, options = {
116
149
  yield event;
117
150
  }
118
151
  }
119
- }
152
+ };
120
153
  /**
121
154
  * Collect Run events until a terminal event, the deadline, or the stream ends —
122
155
  * the bounded events-log pattern the console uses. A single pass over
@@ -4,8 +4,6 @@ export type SamplizeCatalogueOptions = {
4
4
  after?: string;
5
5
  /** Exact canonical identities, at most 100. Unknown/ineligible IDs are omitted; no substitution. Combined filters intersect. */
6
6
  variantIds?: string[];
7
- /** Exact source collection handle; publisher remains Samplize. */
8
- collectionHandle?: string;
9
7
  /** Pin every subsequent page to the version returned by the first page. */
10
8
  sourceVersion?: string;
11
9
  };
@@ -20,9 +18,10 @@ export type SamplizeCatalogueProduct = {
20
18
  };
21
19
  hex: string;
22
20
  hexSource: {
23
- publisher: "Samplize";
24
- url: string;
25
- source: "snowflake_product_tags";
21
+ publisher: string;
22
+ url: string | null;
23
+ source: "resolved_variant";
24
+ valueSource: string | null;
26
25
  };
27
26
  lrv: number | null;
28
27
  lrvSource: SamplizeLrvObservation | null;
@@ -36,8 +35,8 @@ export type SamplizeCatalogueProduct = {
36
35
  a: number;
37
36
  b: number;
38
37
  };
39
- sourceProductId: string;
40
- url: string;
38
+ sourceProductId: string | null;
39
+ url: string | null;
41
40
  sizeVariants: {
42
41
  sourceVariantId: string;
43
42
  sku: string | null;
@@ -56,6 +55,7 @@ export type SamplizeCataloguePage = {
56
55
  source: Record<string, unknown>;
57
56
  /** Number of eligible records matching the supplied filters. */
58
57
  total: number;
58
+ unavailableFields: string[];
59
59
  items: SamplizeCatalogueProduct[];
60
60
  nextCursor: string | null;
61
61
  /** True on the final page; completeness requires collecting total unique IDs. */
@@ -66,7 +66,7 @@ export type SamplizeHexObservation = {
66
66
  publisher: string;
67
67
  source: string;
68
68
  sourceUrl: string | null;
69
- valueOrigin: string;
69
+ valueSource: string;
70
70
  observedAt: string | null;
71
71
  differsFromSelected: boolean;
72
72
  };
@@ -2,7 +2,13 @@ import { EMBEDDING_VERSION, EMBEDDING_VECTOR_SIZE, embeddingCosineSimilarity, pa
2
2
  export const SAMPLIZE_COLORVEC_RANKING_VERSION = "solid-colorvec-cosine-v2";
3
3
  export const SAMPLIZE_COLORVEC_EMBEDDING_VERSION = EMBEDDING_VERSION;
4
4
  const decodeVector = (encoded) => {
5
- const bytes = Uint8Array.from(atob(encoded), (c) => c.charCodeAt(0));
5
+ const bytes = Uint8Array.from(atob(encoded), (c) => {
6
+ const codePoint = c.codePointAt(0);
7
+ if (codePoint === undefined) {
8
+ throw new Error("Missing encoded vector byte.");
9
+ }
10
+ return codePoint;
11
+ });
6
12
  if (bytes.length !== EMBEDDING_VECTOR_SIZE * 4) {
7
13
  throw new Error("Invalid color vector size.");
8
14
  }
@@ -12,19 +18,23 @@ const decodeVector = (encoded) => {
12
18
  const facets = ["brand", "family", "lightness", "intensity"];
13
19
  const identityOrder = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
14
20
  export const samplizeSolidColorVector = (hex) => {
15
- if (!/^#[0-9a-f]{6}$/i.test(hex)) {
21
+ if (!/^#[0-9a-f]{6}$/iu.test(hex)) {
16
22
  throw new Error("A six-digit published color is required.");
17
23
  }
18
24
  return paletteToEmbedding([{ hex, proportion: 1 }], { validateOklab: true });
19
25
  };
20
- /** Canonical constraints are shared by full-cache similarity and constrained text retrieval. */
21
- export const selectSamplizeEligiblePaints = (paints, input) => {
22
- const reference = input.reference
26
+ const findReferencePaint = (paints, input) => {
27
+ const reference = input.reference !== undefined && input.reference !== ""
23
28
  ? paints.find((p) => p.variantId === input.reference)
24
29
  : undefined;
25
- if (input.reference && !reference) {
30
+ if (input.reference !== undefined && input.reference !== "" && !reference) {
26
31
  throw new Error("The reference is absent from this catalogue version.");
27
32
  }
33
+ return reference;
34
+ };
35
+ /** Canonical constraints are shared by full-cache similarity and constrained text retrieval. */
36
+ export const selectSamplizeEligiblePaints = (paints, input) => {
37
+ const reference = findReferencePaint(paints, input);
28
38
  const low = input.lightnessMin ?? 0;
29
39
  const high = input.lightnessMax ?? 1;
30
40
  const cool = input.temperatureMin ?? -0.4;
@@ -57,7 +67,10 @@ export const selectSamplizeEligiblePaints = (paints, input) => {
57
67
  p.oklab.L <= high &&
58
68
  p.oklab.b >= cool &&
59
69
  p.oklab.b <= warm &&
60
- facets.every((f) => f === omit || !input[f]?.length || input[f].includes(p[f]));
70
+ facets.every((f) => f === omit ||
71
+ input[f] === undefined ||
72
+ input[f].length === 0 ||
73
+ input[f].includes(p[f]));
61
74
  const counts = {
62
75
  brand: {},
63
76
  family: {},
@@ -74,6 +87,91 @@ export const selectSamplizeEligiblePaints = (paints, input) => {
74
87
  const eligible = candidates.filter((p) => matches(p));
75
88
  return { counts, eligible, reference };
76
89
  };
90
+ const searchPalette = (input, reference) => {
91
+ const palette = (input.palette !== undefined && input.palette.length > 0
92
+ ? input.palette
93
+ : [{ hex: input.hex ?? reference?.hex ?? "", proportion: 1 }])
94
+ .map((p) => ({ ...p, hex: p.hex.toLowerCase() }))
95
+ .toSorted((a, b) => b.proportion - a.proportion || identityOrder(a.hex, b.hex));
96
+ if (palette.length > 12 ||
97
+ palette.some((p) => !Number.isFinite(p.proportion) || p.proportion <= 0 || p.proportion > 1)) {
98
+ throw new Error("Supply up to twelve positive image color shares.");
99
+ }
100
+ return palette;
101
+ };
102
+ const searchSamplizePaints = (cache, paints, input, vectorFor) => {
103
+ const limit = input.limit ?? 48;
104
+ if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
105
+ throw new Error("Choose between 1 and 100 suggestions.");
106
+ }
107
+ const { reference, eligible, counts } = selectSamplizeEligiblePaints(paints, input);
108
+ const palette = searchPalette(input, reference);
109
+ const total = palette.reduce((sum, p) => sum + p.proportion, 0);
110
+ const buckets = palette.map((p, position) => {
111
+ const bucketPaints = [];
112
+ return {
113
+ ...p,
114
+ paints: bucketPaints,
115
+ position,
116
+ quota: Math.floor((limit * p.proportion) / total),
117
+ remainder: ((limit * p.proportion) / total) % 1,
118
+ vector: vectorFor(p.hex),
119
+ };
120
+ });
121
+ let remaining = limit - buckets.reduce((sum, p) => sum + p.quota, 0);
122
+ for (const p of [...buckets].toSorted((a, b) => b.remainder - a.remainder || a.position - b.position)) {
123
+ if (remaining-- > 0) {
124
+ p.quota++;
125
+ }
126
+ }
127
+ for (const paint of eligible) {
128
+ let best = buckets[0];
129
+ if (best === undefined) {
130
+ throw new Error("Missing first palette bucket.");
131
+ }
132
+ let score = embeddingCosineSimilarity(paint.vector, best.vector);
133
+ for (let i = 1; i < buckets.length; i++) {
134
+ const candidate = buckets[i];
135
+ if (candidate === undefined) {
136
+ throw new Error("Missing palette bucket.");
137
+ }
138
+ const next = embeddingCosineSimilarity(paint.vector, candidate.vector);
139
+ if (next > score) {
140
+ best = candidate;
141
+ score = next;
142
+ }
143
+ }
144
+ best.paints.push({ id: paint.variantId, score });
145
+ }
146
+ const groups = buckets
147
+ .toSorted((a, b) => b.proportion - a.proportion || a.position - b.position)
148
+ .map((p) => {
149
+ const ids = p.paints
150
+ .toSorted((a, b) => b.score - a.score || identityOrder(a.id, b.id))
151
+ .slice(0, p.quota)
152
+ .map((paint) => paint.id);
153
+ return {
154
+ hex: p.hex,
155
+ ids,
156
+ proportion: p.proportion,
157
+ quota: p.quota,
158
+ shortfall: p.quota - ids.length,
159
+ };
160
+ });
161
+ const ids = groups.flatMap((p) => p.ids);
162
+ return {
163
+ countBasis: "complete-catalogue",
164
+ counts,
165
+ dictionaryVersion: cache.dictionaryVersion,
166
+ eligibleCount: eligible.length,
167
+ embeddingVersion: cache.embeddingVersion,
168
+ groups,
169
+ ids,
170
+ limited: eligible.length > ids.length,
171
+ rankingVersion: cache.rankingVersion,
172
+ sourceVersion: cache.sourceVersion,
173
+ };
174
+ };
77
175
  /** Official portable MG ranking over its complete versioned sampling-channel projection. */
78
176
  export const createSamplizeColorVecIndex = (cache) => {
79
177
  if (cache.embeddingVersion !== EMBEDDING_VERSION ||
@@ -106,7 +204,11 @@ export const createSamplizeColorVecIndex = (cache) => {
106
204
  if (!value) {
107
205
  value = samplizeSolidColorVector(hex);
108
206
  if (vectors.size >= 512) {
109
- vectors.delete(vectors.keys().next().value);
207
+ const oldest = vectors.keys().next().value;
208
+ if (oldest === undefined) {
209
+ throw new Error("Missing oldest cached color vector.");
210
+ }
211
+ vectors.delete(oldest);
110
212
  }
111
213
  vectors.set(key, value);
112
214
  }
@@ -114,78 +216,7 @@ export const createSamplizeColorVecIndex = (cache) => {
114
216
  };
115
217
  return {
116
218
  search(input) {
117
- const limit = input.limit ?? 48;
118
- if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
119
- throw new Error("Choose between 1 and 100 suggestions.");
120
- }
121
- const { reference, eligible, counts } = selectSamplizeEligiblePaints(paints, input);
122
- const palette = (input.palette?.length
123
- ? input.palette
124
- : [{ hex: input.hex ?? reference?.hex ?? "", proportion: 1 }])
125
- .map((p) => ({ ...p, hex: p.hex.toLowerCase() }))
126
- .toSorted((a, b) => b.proportion - a.proportion || identityOrder(a.hex, b.hex));
127
- if (palette.length > 12 ||
128
- palette.some((p) => !Number.isFinite(p.proportion) ||
129
- p.proportion <= 0 ||
130
- p.proportion > 1)) {
131
- throw new Error("Supply up to twelve positive image color shares.");
132
- }
133
- const total = palette.reduce((sum, p) => sum + p.proportion, 0);
134
- const buckets = palette.map((p, position) => ({
135
- ...p,
136
- paints: [],
137
- position,
138
- quota: Math.floor((limit * p.proportion) / total),
139
- remainder: ((limit * p.proportion) / total) % 1,
140
- vector: vectorFor(p.hex),
141
- }));
142
- let remaining = limit - buckets.reduce((sum, p) => sum + p.quota, 0);
143
- for (const p of [...buckets].toSorted((a, b) => b.remainder - a.remainder || a.position - b.position)) {
144
- if (remaining-- > 0) {
145
- p.quota++;
146
- }
147
- }
148
- for (const paint of eligible) {
149
- let best = buckets[0];
150
- let score = embeddingCosineSimilarity(paint.vector, best.vector);
151
- for (let i = 1; i < buckets.length; i++) {
152
- const candidate = buckets[i];
153
- const next = embeddingCosineSimilarity(paint.vector, candidate.vector);
154
- if (next > score) {
155
- best = candidate;
156
- score = next;
157
- }
158
- }
159
- best.paints.push({ id: paint.variantId, score });
160
- }
161
- const groups = buckets
162
- .toSorted((a, b) => b.proportion - a.proportion || a.position - b.position)
163
- .map((p) => {
164
- const ids = p.paints
165
- .toSorted((a, b) => b.score - a.score || identityOrder(a.id, b.id))
166
- .slice(0, p.quota)
167
- .map((paint) => paint.id);
168
- return {
169
- hex: p.hex,
170
- ids,
171
- proportion: p.proportion,
172
- quota: p.quota,
173
- shortfall: p.quota - ids.length,
174
- };
175
- });
176
- const ids = groups.flatMap((p) => p.ids);
177
- return {
178
- countBasis: "complete-catalogue",
179
- counts,
180
- dictionaryVersion: cache.dictionaryVersion,
181
- eligibleCount: eligible.length,
182
- embeddingVersion: cache.embeddingVersion,
183
- groups,
184
- ids,
185
- limited: eligible.length > ids.length,
186
- rankingVersion: cache.rankingVersion,
187
- sourceVersion: cache.sourceVersion,
188
- };
219
+ return searchSamplizePaints(cache, paints, input, vectorFor);
189
220
  },
190
221
  };
191
222
  };
package/sse.d.ts CHANGED
@@ -31,5 +31,6 @@ export declare class SseDecoder {
31
31
  * Iterate the SSE frames of a byte stream. Stops cleanly when the stream ends
32
32
  * or `signal` aborts (mid-read abort rejections are swallowed once the signal
33
33
  * is set). Frames with no `data` field (comments, bare `id:`) are not yielded.
34
+ * @yields {SseFrame} Each completed frame containing fields.
34
35
  */
35
- export declare function readSseFrames(body: ReadableStream<Uint8Array>, signal?: AbortSignal): AsyncGenerator<SseFrame, void, unknown>;
36
+ export declare const readSseFrames: (body: ReadableStream<Uint8Array>, signal?: AbortSignal) => AsyncGenerator<SseFrame, void, unknown>;
package/sse.js CHANGED
@@ -121,14 +121,16 @@ export class SseDecoder {
121
121
  * Iterate the SSE frames of a byte stream. Stops cleanly when the stream ends
122
122
  * or `signal` aborts (mid-read abort rejections are swallowed once the signal
123
123
  * is set). Frames with no `data` field (comments, bare `id:`) are not yielded.
124
+ * @yields {SseFrame} Each completed frame containing fields.
124
125
  */
125
- export async function* readSseFrames(body, signal) {
126
+ export const readSseFrames = async function* readSseFrames(body, signal) {
127
+ const isAborted = () => signal?.aborted === true;
126
128
  const reader = body.getReader();
127
129
  const decoder = new TextDecoder();
128
130
  const sse = new SseDecoder();
129
131
  try {
130
132
  for (;;) {
131
- if (signal?.aborted) {
133
+ if (isAborted()) {
132
134
  return;
133
135
  }
134
136
  let result;
@@ -136,7 +138,7 @@ export async function* readSseFrames(body, signal) {
136
138
  result = await reader.read();
137
139
  }
138
140
  catch (error) {
139
- if (signal?.aborted) {
141
+ if (isAborted()) {
140
142
  return;
141
143
  }
142
144
  throw error;
@@ -152,7 +154,7 @@ export async function* readSseFrames(body, signal) {
152
154
  }
153
155
  const text = decoder.decode(result.value, { stream: true });
154
156
  for (const frame of sse.push(text)) {
155
- if (signal?.aborted) {
157
+ if (isAborted()) {
156
158
  return;
157
159
  }
158
160
  yield frame;
@@ -162,4 +164,4 @@ export async function* readSseFrames(body, signal) {
162
164
  finally {
163
165
  reader.releaseLock();
164
166
  }
165
- }
167
+ };
package/types.d.ts CHANGED
@@ -10,7 +10,7 @@ import type { MaterialTile } from "./material-types.js";
10
10
  * against the real Zod schemas so the mirror cannot silently drift.
11
11
  * TOLERANT-PARSING CONTRACT: the public surface declares open response enums —
12
12
  * "clients must tolerate unknown enum values and unknown fields". Every union
13
- * that mirrors a server enum is therefore widened with `(string & {})` so a new
13
+ * that mirrors a server enum is therefore widened with `(string & Record<never, never>)` so a new
14
14
  * server value still type-checks, and objects that may grow fields are read
15
15
  * structurally (unknown keys pass through untouched — the client never strips).
16
16
  *
@@ -21,12 +21,12 @@ import type { MaterialTile } from "./material-types.js";
21
21
  * are canonical vocabulary you discover.
22
22
  * 2. **Absent is not false.** An optional field that is missing means UNKNOWN,
23
23
  * not "known to be absent in reality" — most dangerously for booleans.
24
- * Values carry `confidence` and `valueOrigin`; report them as claims.
24
+ * Values carry `confidence` and `valueSource`; report them as claims.
25
25
  * 3. **A page is not a corpus.** A list is this page. Follow the cursor for
26
26
  * the rest, and never report a page size as a total.
27
27
  */
28
28
  import type { SearchCatalogueSubset } from "./search-catalogue-subset-types.js";
29
- export type OpenEnum<T extends string> = T | (string & {});
29
+ export type OpenEnum<T extends string> = T | (string & Record<never, never>);
30
30
  export type { JsonValue } from "./json-value-types.js";
31
31
  /** OKLab triple. */
32
32
  export type OklabTriple = {
@@ -247,9 +247,9 @@ export type ListAttributesResult = {
247
247
  hasNextPage: boolean;
248
248
  };
249
249
  };
250
- export type * from "./catalog-discovery-types.js";
251
- export type * from "./resolve-color-types.js";
252
- export type * from "./by-spec-types.js";
250
+ export type { ResolveValueCandidate, ValueResolution, } from "./catalog-discovery-types.js";
251
+ export type { ResolveColorOptions, ResolveColorCatalogueVariant, ResolveColorVariant, ResolveColorResponse, } from "./resolve-color-types.js";
252
+ export type { BySpecVerdictState, BySpecVerdictCell, BySpecVerdict, MaterialVerdict, BySpecUnresolvedCriterion, } from "./by-spec-types.js";
253
253
  export type WhoamiResponse = {
254
254
  principal: string;
255
255
  scopes: string[];