@instruments/materialgraph 0.1.0 → 0.3.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.
Files changed (56) hide show
  1. package/by-spec-types.d.ts +73 -0
  2. package/by-spec-types.js +7 -0
  3. package/client.d.ts +186 -7
  4. package/client.js +242 -125
  5. package/codes-client.d.ts +82 -0
  6. package/codes-client.js +73 -0
  7. package/codes-types.d.ts +238 -0
  8. package/codes-types.js +1 -0
  9. package/colour-aggregate-types.d.ts +93 -0
  10. package/colour-aggregate-types.js +1 -0
  11. package/colours-client.d.ts +52 -0
  12. package/colours-client.js +39 -0
  13. package/composition-types.d.ts +343 -0
  14. package/composition-types.js +10 -0
  15. package/http-transport.d.ts +74 -0
  16. package/http-transport.js +201 -0
  17. package/index.d.ts +19 -0
  18. package/index.js +9 -0
  19. package/match-stream-types.d.ts +150 -0
  20. package/match-stream-types.js +28 -0
  21. package/match-stream.d.ts +22 -0
  22. package/match-stream.js +280 -0
  23. package/material-block-types.d.ts +23 -2
  24. package/material-types.d.ts +92 -0
  25. package/material-types.js +1 -0
  26. package/ndjson.d.ts +46 -0
  27. package/ndjson.js +103 -0
  28. package/operations.d.ts +6 -2
  29. package/operations.js +30 -2
  30. package/package.json +1 -1
  31. package/project-types.d.ts +67 -0
  32. package/project-types.js +16 -0
  33. package/projects.d.ts +13 -0
  34. package/projects.js +46 -0
  35. package/run-events.d.ts +82 -0
  36. package/run-events.js +29 -0
  37. package/run-types.d.ts +266 -0
  38. package/run-types.js +6 -0
  39. package/runs.d.ts +84 -0
  40. package/runs.js +253 -0
  41. package/schedule-types.d.ts +88 -0
  42. package/schedule-types.js +12 -0
  43. package/search-refinement-types.d.ts +26 -1
  44. package/search-result-validation.d.ts +27 -0
  45. package/search-result-validation.js +174 -0
  46. package/source-material-validation.d.ts +3 -0
  47. package/source-material-validation.js +109 -0
  48. package/spec-types.d.ts +82 -0
  49. package/spec-types.js +44 -0
  50. package/specifications.d.ts +72 -0
  51. package/specifications.js +82 -0
  52. package/sse.d.ts +35 -0
  53. package/sse.js +147 -0
  54. package/types.d.ts +204 -77
  55. package/validation.d.ts +119 -0
  56. package/validation.js +241 -0
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Zero-dependency builders for the specification surface. Turn a plain-language
3
+ * spec intent into a typed criterion the SDK's two spec operations accept:
4
+ *
5
+ * - `search({ filters })` takes {@link SpecFilter} objects — a spec gate layered
6
+ * onto a ranked (colour / visual / lexical) search. Build them with
7
+ * {@link specTerm}, {@link hasSpec}, and {@link certificationProgramme}.
8
+ * - `bySpec(specs)` takes `attribute:value` strings — the value-anchored,
9
+ * spec-only primitive. Build them with {@link specCriterion} and
10
+ * {@link certificationProgrammeCriterion}.
11
+ *
12
+ * The point of these helpers: Desk (and any spec-driven caller) never has to
13
+ * remember that a certification programme lives under a VIRTUAL attribute, nor
14
+ * hand-format the `attribute:value` string the `bySpec` route splits on its
15
+ * first `:`.
16
+ */
17
+ import { CERTIFICATION_PROGRAMME_ATTRIBUTE_ID, } from "./spec-types.js";
18
+ /**
19
+ * A term-equality criterion for `search({ filters })` — the attribute must
20
+ * carry this canonical value.
21
+ *
22
+ * @example
23
+ * // "same fire class as the seed"
24
+ * client.search({ similarTo: { variantId }, filters: [specTerm("fire_classifications", "astm_e84_class_a")] });
25
+ */
26
+ export function specTerm(attributeId, term) {
27
+ return { attributeId, term };
28
+ }
29
+ /**
30
+ * A presence criterion for `search({ filters })` — the attribute must have ANY
31
+ * accepted value.
32
+ *
33
+ * @example
34
+ * // "…and it must have an EPD on record"
35
+ * client.search({ similarTo: { variantId }, filters: [hasSpec("environmental_product_declaration")] });
36
+ */
37
+ export function hasSpec(attributeId) {
38
+ return { attributeId, hasValue: true };
39
+ }
40
+ /**
41
+ * A certification-programme criterion for `search({ filters })`, pinned to the
42
+ * virtual attribute so a programme matches wherever it landed at ingest
43
+ * (compliance / sustainability / material-health / VOC buckets).
44
+ *
45
+ * The value may be a display name or alias ("GREENGUARD Gold", "LEED") — the
46
+ * server resolves it to its canonical programme token before matching — or a
47
+ * canonical token directly (e.g. `greenguard_certificate`,
48
+ * `has_greenguard_gold_certification`).
49
+ *
50
+ * @example
51
+ * // "find a GREENGUARD-certified alternative"
52
+ * client.search({ similarTo: { variantId: seed }, filters: [certificationProgramme("GREENGUARD Gold")] });
53
+ */
54
+ export function certificationProgramme(programme) {
55
+ return { attributeId: CERTIFICATION_PROGRAMME_ATTRIBUTE_ID, term: programme };
56
+ }
57
+ /**
58
+ * An `attribute:value` criterion string for `bySpec`. The `bySpec` route splits
59
+ * on the FIRST `:`, so a value may itself contain colons; the attribute id may
60
+ * not.
61
+ *
62
+ * @example
63
+ * client.bySpec([specCriterion("primary_material", "textile"), specCriterion("fire_classifications", "astm_e84_class_a")]);
64
+ */
65
+ export function specCriterion(attributeId, value) {
66
+ return `${attributeId}:${value}`;
67
+ }
68
+ /**
69
+ * An `attribute:value` criterion string for `bySpec` that filters by
70
+ * certification PROGRAMME across all four certification buckets.
71
+ *
72
+ * The value may be a display name or alias ("GREENGUARD Gold", "LEED"), resolved
73
+ * server-side to its canonical programme token, or a canonical token directly
74
+ * (e.g. `greenguard_certificate`, `has_greenguard_gold_certification`).
75
+ *
76
+ * @example
77
+ * // every brand's GREENGUARD-Gold materials
78
+ * client.bySpec([certificationProgrammeCriterion("GREENGUARD Gold")]);
79
+ */
80
+ export function certificationProgrammeCriterion(programme) {
81
+ return specCriterion(CERTIFICATION_PROGRAMME_ATTRIBUTE_ID, programme);
82
+ }
package/sse.d.ts ADDED
@@ -0,0 +1,35 @@
1
+ /**
2
+ * A minimal, dependency-free Server-Sent Events reader — the primitive behind
3
+ * {@link MaterialGraphClient.streamRunEvents}. Hand-rolled (no `eventsource`,
4
+ * no `undici`) so the SDK stays zero-runtime-dep and works unchanged inside
5
+ * Node route handlers, where `fetch` yields a byte `ReadableStream`.
6
+ *
7
+ * The parser follows the WHATWG event-stream tokenizer closely enough for the
8
+ * `/api/v1/runs/{runId}/events` contract: `\n`, `\r\n`, and `\r` line endings,
9
+ * multi-line `data:` accumulation, comment (`:`-prefixed) lines, and a single
10
+ * optional leading space after the field colon. Frames split across network
11
+ * chunks are held in an internal buffer until their terminating blank line.
12
+ */
13
+ /** One dispatched SSE frame. `data` is the joined data lines (no trailing newline). */
14
+ export interface SseFrame {
15
+ id?: string;
16
+ event?: string;
17
+ data: string;
18
+ }
19
+ /**
20
+ * A push-based SSE line assembler. Feed it decoded text with {@link push}; it
21
+ * returns any frames completed by that text. Call {@link flush} once the stream
22
+ * ends to dispatch a final frame that had data but no terminating blank line.
23
+ */
24
+ export declare class SseDecoder {
25
+ #private;
26
+ push(text: string): SseFrame[];
27
+ /** Dispatch a trailing frame that had fields but no closing blank line. */
28
+ flush(): SseFrame[];
29
+ }
30
+ /**
31
+ * Iterate the SSE frames of a byte stream. Stops cleanly when the stream ends
32
+ * or `signal` aborts (mid-read abort rejections are swallowed once the signal
33
+ * is set). Frames with no `data` field (comments, bare `id:`) are not yielded.
34
+ */
35
+ export declare function readSseFrames(body: ReadableStream<Uint8Array>, signal?: AbortSignal): AsyncGenerator<SseFrame, void, unknown>;
package/sse.js ADDED
@@ -0,0 +1,147 @@
1
+ /**
2
+ * A minimal, dependency-free Server-Sent Events reader — the primitive behind
3
+ * {@link MaterialGraphClient.streamRunEvents}. Hand-rolled (no `eventsource`,
4
+ * no `undici`) so the SDK stays zero-runtime-dep and works unchanged inside
5
+ * Node route handlers, where `fetch` yields a byte `ReadableStream`.
6
+ *
7
+ * The parser follows the WHATWG event-stream tokenizer closely enough for the
8
+ * `/api/v1/runs/{runId}/events` contract: `\n`, `\r\n`, and `\r` line endings,
9
+ * multi-line `data:` accumulation, comment (`:`-prefixed) lines, and a single
10
+ * optional leading space after the field colon. Frames split across network
11
+ * chunks are held in an internal buffer until their terminating blank line.
12
+ */
13
+ /**
14
+ * A push-based SSE line assembler. Feed it decoded text with {@link push}; it
15
+ * returns any frames completed by that text. Call {@link flush} once the stream
16
+ * ends to dispatch a final frame that had data but no terminating blank line.
17
+ */
18
+ export class SseDecoder {
19
+ #buffer = "";
20
+ #dataLines = [];
21
+ #id;
22
+ #event;
23
+ #hasFields = false;
24
+ push(text) {
25
+ this.#buffer += text;
26
+ const frames = [];
27
+ for (;;) {
28
+ const lineEnd = this.#nextLineEnd();
29
+ if (lineEnd === null)
30
+ break;
31
+ const line = this.#buffer.slice(0, lineEnd.index);
32
+ this.#buffer = this.#buffer.slice(lineEnd.nextIndex);
33
+ const frame = this.#consumeLine(line);
34
+ if (frame)
35
+ frames.push(frame);
36
+ }
37
+ return frames;
38
+ }
39
+ /** Dispatch a trailing frame that had fields but no closing blank line. */
40
+ flush() {
41
+ if (this.#buffer.length > 0) {
42
+ // At end of stream a deferred trailing CR is a real terminator, not data.
43
+ const line = this.#buffer.endsWith("\r") ? this.#buffer.slice(0, -1) : this.#buffer;
44
+ this.#buffer = "";
45
+ const frame = this.#consumeLine(line);
46
+ if (frame)
47
+ return [frame];
48
+ }
49
+ const frame = this.#dispatch();
50
+ return frame ? [frame] : [];
51
+ }
52
+ /** Locate the next line terminator, deferring a lone trailing `\r` (may be `\r\n`). */
53
+ #nextLineEnd() {
54
+ for (let i = 0; i < this.#buffer.length; i += 1) {
55
+ const char = this.#buffer[i];
56
+ if (char === "\n")
57
+ return { index: i, nextIndex: i + 1 };
58
+ if (char === "\r") {
59
+ if (i === this.#buffer.length - 1)
60
+ return null;
61
+ return this.#buffer[i + 1] === "\n"
62
+ ? { index: i, nextIndex: i + 2 }
63
+ : { index: i, nextIndex: i + 1 };
64
+ }
65
+ }
66
+ return null;
67
+ }
68
+ #consumeLine(line) {
69
+ if (line.length === 0)
70
+ return this.#dispatch();
71
+ if (line.startsWith(":"))
72
+ return undefined;
73
+ const colon = line.indexOf(":");
74
+ const field = colon === -1 ? line : line.slice(0, colon);
75
+ let value = colon === -1 ? "" : line.slice(colon + 1);
76
+ if (value.startsWith(" "))
77
+ value = value.slice(1);
78
+ if (field === "data") {
79
+ this.#dataLines.push(value);
80
+ this.#hasFields = true;
81
+ }
82
+ else if (field === "event") {
83
+ this.#event = value;
84
+ this.#hasFields = true;
85
+ }
86
+ else if (field === "id") {
87
+ this.#id = value;
88
+ this.#hasFields = true;
89
+ }
90
+ return undefined;
91
+ }
92
+ #dispatch() {
93
+ if (!this.#hasFields)
94
+ return undefined;
95
+ const frame = { data: this.#dataLines.join("\n") };
96
+ if (this.#event !== undefined)
97
+ frame.event = this.#event;
98
+ if (this.#id !== undefined)
99
+ frame.id = this.#id;
100
+ this.#dataLines = [];
101
+ this.#event = undefined;
102
+ this.#id = undefined;
103
+ this.#hasFields = false;
104
+ return frame;
105
+ }
106
+ }
107
+ /**
108
+ * Iterate the SSE frames of a byte stream. Stops cleanly when the stream ends
109
+ * or `signal` aborts (mid-read abort rejections are swallowed once the signal
110
+ * is set). Frames with no `data` field (comments, bare `id:`) are not yielded.
111
+ */
112
+ export async function* readSseFrames(body, signal) {
113
+ const reader = body.getReader();
114
+ const decoder = new TextDecoder();
115
+ const sse = new SseDecoder();
116
+ try {
117
+ for (;;) {
118
+ if (signal?.aborted)
119
+ return;
120
+ let result;
121
+ try {
122
+ result = await reader.read();
123
+ }
124
+ catch (error) {
125
+ if (signal?.aborted)
126
+ return;
127
+ throw error;
128
+ }
129
+ if (result.done) {
130
+ for (const frame of sse.push(decoder.decode()))
131
+ yield frame;
132
+ for (const frame of sse.flush())
133
+ yield frame;
134
+ return;
135
+ }
136
+ const text = decoder.decode(result.value, { stream: true });
137
+ for (const frame of sse.push(text)) {
138
+ if (signal?.aborted)
139
+ return;
140
+ yield frame;
141
+ }
142
+ }
143
+ }
144
+ finally {
145
+ reader.releaseLock();
146
+ }
147
+ }
package/types.d.ts CHANGED
@@ -12,9 +12,19 @@
12
12
  * server value still type-checks, and objects that may grow fields are read
13
13
  * structurally (unknown keys pass through untouched — the client never strips).
14
14
  */
15
+ import type { BySpecVerdict } from "./by-spec-types.js";
16
+ import type { MaterialDetail, MaterialTile } from "./material-types.js";
15
17
  import type { SearchFilter, SearchSuggestion } from "./search-refinement-types.js";
16
18
  /** A widened string-literal union: known members autocomplete, unknown values still type-check. */
17
19
  export type OpenEnum<T extends string> = T | (string & {});
20
+ /**
21
+ * A JSON value, recursively — exactly what survives a `jsonb` round-trip
22
+ * unchanged. Used where a payload's SHAPE belongs to its producer but its values
23
+ * must still be JSON: `Date`, `bigint`, `undefined`, and functions are not.
24
+ */
25
+ export type JsonValue = string | number | boolean | null | JsonValue[] | {
26
+ [key: string]: JsonValue;
27
+ };
18
28
  /** One palette swatch of a material's main image. */
19
29
  export interface PaletteSwatch {
20
30
  hex: string;
@@ -48,60 +58,15 @@ export interface MaterialColour {
48
58
  }
49
59
  /** Where the served image came from. */
50
60
  export type ImageSource = OpenEnum<"r2" | "origin">;
51
- /** The grid-card projection of a material. */
52
- export interface MaterialTile {
53
- variantId: string;
54
- brand: string;
55
- product: string;
56
- variantName: string | null;
57
- imageUrl: string | null;
58
- imageSource: ImageSource | null;
59
- category: string | null;
60
- colour: MaterialColour;
61
- }
62
- /** A canonical attribute/term pair carried on a detail record. */
63
- export interface MaterialFacet {
64
- attributeId: string;
65
- term: string;
66
- }
67
- /**
68
- * One dimensional configuration of a material. Numeric fields are millimetres
69
- * (mass in kilograms); string fields are the source's own descriptors. The
70
- * index signature preserves the corpus's long tail of rarer keys.
71
- */
72
- export interface DimensionEntry {
73
- label?: string;
74
- tile_size?: string;
75
- measurement_basis?: string;
76
- width_mm?: number;
77
- length_mm?: number;
78
- height_mm?: number;
79
- depth_mm?: number;
80
- thickness_mm?: number;
81
- diameter_mm?: number;
82
- visible_projection_mm?: number;
83
- weight_kg?: number;
84
- [key: string]: unknown;
85
- }
86
- /** The record projection of a material — the tile plus detail-only fields. */
87
- export interface MaterialDetail extends MaterialTile {
88
- brandSlug: string;
89
- productId: string;
90
- color: string | null;
91
- materialBankId: string | null;
92
- installationImageUrl: string | null;
93
- displayCategory: string | null;
94
- categoryPath: string[];
95
- palette: PaletteSwatch[];
96
- facets: MaterialFacet[];
97
- dimensions: DimensionEntry[] | null;
98
- }
61
+ export type { DimensionEntry, MaterialAvailability, MaterialDetail, MaterialFacet, MaterialSuccessor, MaterialTile, } from "./material-types.js";
99
62
  /** Per-signal scores, present on a result when `explain` was requested. */
100
63
  export interface SearchResultSignals {
101
64
  image: number | null;
102
65
  category: number | null;
103
66
  colour: number | null;
104
67
  lexical: number | null;
68
+ /** Text→image RERANK cosine — non-null only for a seeded search carrying `text`. */
69
+ text: number | null;
105
70
  }
106
71
  /** An accepted/measured visual meaning — descriptive evidence, never a ranking cause. */
107
72
  export interface VisualMeaning {
@@ -131,8 +96,47 @@ export interface SearchResultItem {
131
96
  score: number;
132
97
  signals?: SearchResultSignals;
133
98
  retired?: boolean;
99
+ /**
100
+ * Present (true) only on quality-floor rejects returned under `includeBelowFloor`
101
+ * (match only) — the item failed the match image-quality floor and would be
102
+ * dropped by default. Appended after the kept results.
103
+ */
104
+ belowFloor?: boolean;
134
105
  siblingCount?: number;
135
106
  visualMeanings?: VisualMeaning[];
107
+ /**
108
+ * The shared report card for this hit against the request's `filters` (required
109
+ * cells) and `preferences` (preferred cells). Present (full view only) whenever a
110
+ * spec filter or preference was given. Reuses the by-spec verdict shape — the
111
+ * shared CandidateVerdict mirror; see {@link MaterialVerdict}.
112
+ */
113
+ verdict?: BySpecVerdict;
114
+ }
115
+ /** The lean `view:"card"` result item — identity + name + thumbnail + score. */
116
+ export interface SearchCardItem {
117
+ variantId: string;
118
+ productId: string | null;
119
+ brandSlug: string | null;
120
+ brand: string;
121
+ /** Product name (the full item's `product`). */
122
+ name: string;
123
+ variantName: string | null;
124
+ imageUrl: string | null;
125
+ score: number;
126
+ }
127
+ /**
128
+ * A feed total with its honesty basis. `exact` — the true count (≤ the cap of
129
+ * 1,000, or a facet-MV read). `lowerBound` — a floor (cap hit, ANN-pool bound,
130
+ * or a degraded count). Never a silent approximation.
131
+ */
132
+ export interface CountedTotal {
133
+ value: number;
134
+ basis: OpenEnum<"exact" | "lowerBound">;
135
+ }
136
+ /** Feed counts — present under `count:true` or `collapse:"product"`. */
137
+ export interface SearchCounts {
138
+ variants: CountedTotal;
139
+ products?: CountedTotal;
136
140
  }
137
141
  /** The single candidate source a query resolved to. */
138
142
  export type SearchMode = OpenEnum<"image" | "colour" | "text" | "lexical">;
@@ -142,16 +146,22 @@ export interface SearchRerankReport {
142
146
  colourWeight?: number;
143
147
  colourLabel?: string;
144
148
  }
145
- /** The full search response (both GET and POST forms return this envelope). */
149
+ /**
150
+ * The search response envelope. `view` discriminates the `results` union:
151
+ * `"full"` (default) carries {@link SearchResultItem}s, `"card"` the lean
152
+ * {@link SearchCardItem}s. Every other field is shared. `counts` is present
153
+ * under `count:true` or `collapse:"product"` (replacing the former
154
+ * `variantCount`/`productCount`).
155
+ */
146
156
  export interface SearchResponse {
157
+ view: OpenEnum<"full" | "card">;
147
158
  results: SearchResultItem[];
148
159
  suggestions: SearchSuggestion[];
149
160
  mode: SearchMode;
150
161
  maxScore: number;
151
162
  cursor?: string;
152
163
  rerank?: SearchRerankReport;
153
- variantCount?: number;
154
- productCount?: number;
164
+ counts?: SearchCounts;
155
165
  note?: string;
156
166
  timings?: Record<string, number>;
157
167
  /** Echo of the resolved query input (raw image bytes/vector are never echoed). */
@@ -159,6 +169,11 @@ export interface SearchResponse {
159
169
  /** A `getVariantBrief` echo of the seed variant, present only in `variantId` seed mode. */
160
170
  seed?: MaterialTile | Record<string, unknown>;
161
171
  }
172
+ /** The `view:"card"` response form — the lean grid projection of {@link SearchResponse}. */
173
+ export interface SearchCardResponse extends Omit<SearchResponse, "view" | "results"> {
174
+ view: OpenEnum<"card">;
175
+ results: SearchCardItem[];
176
+ }
162
177
  /** An inferred product-type category from the query image, with its boost. */
163
178
  export interface MatchCategoryInference {
164
179
  productTypeId: string;
@@ -348,31 +363,7 @@ export interface ResolveAttributeResponse {
348
363
  candidates: AttributeCandidate[];
349
364
  }
350
365
  export type * from "./catalog-discovery-types.js";
351
- export interface BySpecMatchedCell {
352
- attributeId: string;
353
- confidence: number | null;
354
- sourceBasis: string | null;
355
- }
356
- export interface BySpecMatch {
357
- variantId: string;
358
- brand: string;
359
- product: string;
360
- variant: string;
361
- color: string | null;
362
- imageUrl: string | null;
363
- hex: string | null;
364
- matched: BySpecMatchedCell[];
365
- minConfidence: number | null;
366
- }
367
- export interface BySpecResponse {
368
- criteria: Array<{
369
- attribute: string;
370
- value: string;
371
- }>;
372
- totalMatches: number;
373
- brandsSpanned: number;
374
- matches: BySpecMatch[];
375
- }
366
+ export type * from "./by-spec-types.js";
376
367
  export interface WhoamiResponse {
377
368
  principal: string;
378
369
  scopes: string[];
@@ -416,6 +407,11 @@ export interface SearchInput {
416
407
  };
417
408
  mood?: string;
418
409
  sort?: OpenEnum<"relevance" | "light" | "dark" | "vivid" | "muted" | "colourful">;
410
+ /**
411
+ * Seed a visual search from an existing variant's image (`variantId`) or a
412
+ * pre-computed image vector (`vector`). Only the `{ variantId }` form carries a
413
+ * seed variant, so it is the only form `verdicts` can diff a candidate against.
414
+ */
419
415
  similarTo?: {
420
416
  variantId: string;
421
417
  } | {
@@ -431,12 +427,33 @@ export interface SearchInput {
431
427
  category?: number;
432
428
  colour?: number;
433
429
  lexical?: number;
430
+ /** Text rerank multiplier applied when a seeded search also carries `text` (default 0.4). */
431
+ text?: number;
434
432
  };
435
433
  filters?: SearchFilter[];
434
+ /**
435
+ * SOFT spec criteria (rank-never-exclude). Same shape as `filters`; a preference
436
+ * never removes a result — it adds a `preferred` cell to each hit's `verdict` and
437
+ * demotes a failed/missing preferred hit WITHIN its page only.
438
+ */
439
+ preferences?: SearchFilter[];
436
440
  limit?: number;
437
441
  cursor?: string;
438
442
  explain?: boolean;
439
443
  collapse?: OpenEnum<"none" | "product">;
444
+ /** Opt-in feed total: EXACT up to 1,000, `lowerBound` beyond / for ANN pools. */
445
+ count?: boolean;
446
+ /** Only return materials with at least one catalog image. Default false (full catalog). */
447
+ hasImages?: boolean;
448
+ /**
449
+ * Opt-in seed-relative verdict cells (MG-629). Only meaningful with
450
+ * `similarTo: { variantId }`: each hit's `verdict` gains `preferred` cells
451
+ * diffing its comparable attributes (numeric + a `colour` axis) against the
452
+ * seed's, with directional deviations. Inert on every other seed form.
453
+ */
454
+ verdicts?: boolean;
455
+ /** Result projection — `"full"` (default) or the lean `"card"` grid form. */
456
+ view?: OpenEnum<"full" | "card">;
440
457
  }
441
458
  /** Query-string params for the GET `/search` form. */
442
459
  export interface SearchGetParams {
@@ -450,6 +467,12 @@ export interface SearchGetParams {
450
467
  limit?: number;
451
468
  /** "0" or "1" — include per-signal `signals` on each result. */
452
469
  explain?: OpenEnum<"0" | "1">;
470
+ /** "true" — opt in to a `counts.variants` feed total. */
471
+ count?: OpenEnum<"true">;
472
+ /** "true" — only return materials with at least one catalog image. */
473
+ hasImages?: OpenEnum<"true">;
474
+ /** Result projection — `"full"` (default) or the lean `"card"` grid form. */
475
+ view?: OpenEnum<"full" | "card">;
453
476
  }
454
477
  /** Params for `search/colours`. */
455
478
  export interface ByColourParams {
@@ -485,7 +508,19 @@ export interface MatchInput {
485
508
  detectionLabel?: string;
486
509
  materialFunction?: string;
487
510
  detectionCategory?: string;
511
+ /**
512
+ * Opt-in: also return quality-floor rejects, flagged `belowFloor: true` and
513
+ * appended after the kept results. Absent/false = default behaviour;
514
+ * `droppedBelowFloor` is unchanged.
515
+ */
516
+ includeBelowFloor?: boolean;
488
517
  requestedType?: OpenEnum<"swatch" | "installation" | "flatplan">;
518
+ /** Hard spec gates forwarded verbatim to the inner search — term match or presence, each with an optional trust bar. */
519
+ filters?: SearchFilter[];
520
+ /** Soft criteria (rank-never-exclude). A miss demotes within the page but never removes a hit. */
521
+ preferences?: SearchFilter[];
522
+ /** Opt in to seed-relative verdict cells; inert unless the inner search carries a variant seed. */
523
+ verdicts?: boolean;
489
524
  }
490
525
  /** Options for a batch material read. */
491
526
  export interface MaterialsBatchOptions {
@@ -559,6 +594,98 @@ export interface DemandResponse {
559
594
  demand: MaterialDemandResult;
560
595
  cospec: CoSpecifiedResult;
561
596
  }
597
+ /** Which behavioural signal candidate demand is read from. */
598
+ export type DemandSignal = OpenEnum<"sampled" | "considered" | "specified">;
599
+ /**
600
+ * Body for `POST /api/v1/demand/batch` — enrich 1–48 already-selected candidate
601
+ * ids with their real specification demand, in input order and cardinality.
602
+ */
603
+ export interface DemandBatchInput {
604
+ /** 1–48 canonical variant ids. Order AND cardinality are preserved — duplicates are echoed, never collapsed. */
605
+ variantIds: string[];
606
+ /** The behavioural signal to read demand from. Defaults to `sampled` (order co-occurrence) server-side. */
607
+ signal?: DemandSignal;
608
+ /** Optional declared context (e.g. `{ projectType: "hospitality" }`) — echoed for provenance, never used to rank or filter. */
609
+ context?: Record<string, string>;
610
+ }
611
+ /** Headline population behind the metric — this variant's own counts. */
612
+ export interface DemandBatchPopulation {
613
+ specCount: number;
614
+ projectCount: number;
615
+ customerCount: number;
616
+ }
617
+ /** The consideration→specification funnel (project-grain; `exclusionRate` is annotate-only). */
618
+ export interface DemandBatchFunnel {
619
+ consideredProjects: number;
620
+ specifiedProjects: number;
621
+ excludedCount: number;
622
+ exclusionRate: number;
623
+ }
624
+ /** Per-result guardrail status — present on `internal` (`mg:admin`) results only. */
625
+ export interface DemandBatchEvidence {
626
+ belowPrivacyFloor: boolean;
627
+ belowQualityFloor: boolean;
628
+ suppressedForPublic: boolean;
629
+ }
630
+ /** A variant that carries a usable demand signal. */
631
+ export interface DemandBatchAvailableResult {
632
+ variantId: string;
633
+ state: "available";
634
+ population: DemandBatchPopulation;
635
+ /** Exact per-quarter counts (sparse — zero-spec quarters are absent). */
636
+ intervals: DemandQuarter[];
637
+ momentum: DemandMomentum;
638
+ /** The funnel; null when the signal is not `sampled` or no `considered` row/session exists. */
639
+ funnel: DemandBatchFunnel | null;
640
+ /** Exact guardrail evidence for `internal` callers; null for public. */
641
+ evidence: DemandBatchEvidence | null;
642
+ }
643
+ /** A variant with a stats row that is below the public quality floor. */
644
+ export interface DemandBatchInsufficientResult {
645
+ variantId: string;
646
+ state: "insufficient_data";
647
+ }
648
+ /** A variant with no stats — indistinguishable from a privacy-suppressed one for public callers. */
649
+ export interface DemandBatchNoSignalResult {
650
+ variantId: string;
651
+ state: "no_signal";
652
+ }
653
+ /** A requested id that resolves to no real variant. */
654
+ export interface DemandBatchUnknownResult {
655
+ variantId: string;
656
+ state: "unknown_variant";
657
+ }
658
+ /** One discriminated result per input id, in input order and cardinality. */
659
+ export type DemandBatchResult = DemandBatchAvailableResult | DemandBatchInsufficientResult | DemandBatchNoSignalResult | DemandBatchUnknownResult;
660
+ /** The `POST /api/v1/demand/batch` envelope. */
661
+ export interface DemandBatchResponse {
662
+ /** The completed ingestion-session id these counts were mined in (the dataset generation). */
663
+ generationId: string;
664
+ metric: "specification_demand";
665
+ /** The metric universe — the window and signal the counts are scoped to. */
666
+ universe: {
667
+ window: "trailing_36mo";
668
+ signal: DemandSignal;
669
+ description: string;
670
+ };
671
+ /** The guardrail status under which `results` were projected. */
672
+ guardrail: {
673
+ audience: OpenEnum<"public" | "internal">;
674
+ minProjectCount: number;
675
+ minCustomerCount: number;
676
+ };
677
+ /** Outcome tallies over `results` — never a denominator for the metric. */
678
+ coverage: {
679
+ requested: number;
680
+ available: number;
681
+ insufficientData: number;
682
+ noSignal: number;
683
+ unknownVariant: number;
684
+ };
685
+ /** The echoed declared context, or null when none was supplied. */
686
+ context: Record<string, string> | null;
687
+ results: DemandBatchResult[];
688
+ }
562
689
  export interface CoordinateSeed extends DemandSeed {
563
690
  /** The input reference this seed resolved from. */
564
691
  resolvedFrom: string;
@@ -742,7 +869,7 @@ export interface ExtractPaletteInput {
742
869
  /** Options for a cacheable GET batch read (`materialsCached`). */
743
870
  export interface MaterialsBatchGetOptions extends MaterialsBatchOptions {
744
871
  }
745
- /** Options for `brands/{slug}`. */
872
+ /** Options for `brands/{brandSlug}`. */
746
873
  export interface BrandOptions {
747
874
  /** Alternate fuzzy query; takes precedence over the path slug when present. */
748
875
  q?: string;