@instruments/materialgraph 0.6.0 → 0.6.1

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/spec-types.d.ts CHANGED
@@ -11,12 +11,21 @@
11
11
  * class, but with an EPD on record" reaches for a NAMED, typed criterion instead
12
12
  * of hand-assembling a magic `attribute:value` string.
13
13
  *
14
- * These criteria drive two operations equally:
15
- * - `client.search({ filters: [...] })` — spec gates layered onto a ranked
16
- * (colour / visual / lexical) search.
17
- * - `client.bySpec(...)` — the value-anchored, spec-only primitive, which takes
14
+ * These criteria drive two operations equally, and choosing between them IS the
15
+ * routing decision:
16
+ * - `client.search({ filters: [...] })` — spec gates layered onto a RANKED
17
+ * (colour / visual / lexical) search. Reach for it when relevance still
18
+ * matters: "something like this seed, but Class A".
19
+ * - `client.bySpec(...)` — the value-anchored, SPEC-ONLY primitive, which takes
18
20
  * the same criteria as `attribute:value` strings (build them with the helpers
19
- * in `./specifications`).
21
+ * in `./specifications`). Reach for it when the spec is the whole question and
22
+ * ranking would be noise.
23
+ *
24
+ * Discover the tokens before you spend them: `client.specificationFacets()` for
25
+ * what is facetable at all, `client.attributeValues()` for one attribute's
26
+ * values, `client.resolveAttribute()` / `client.resolveAttributeValue()` to turn
27
+ * a phrase into a canonical pair. A hand-guessed token returns an honest empty
28
+ * answer, which is the hardest kind of bug to see.
20
29
  */
21
30
  /**
22
31
  * The virtual attribute id that matches a certification PROGRAMME wherever it
package/spec-types.js CHANGED
@@ -11,12 +11,21 @@
11
11
  * class, but with an EPD on record" reaches for a NAMED, typed criterion instead
12
12
  * of hand-assembling a magic `attribute:value` string.
13
13
  *
14
- * These criteria drive two operations equally:
15
- * - `client.search({ filters: [...] })` — spec gates layered onto a ranked
16
- * (colour / visual / lexical) search.
17
- * - `client.bySpec(...)` — the value-anchored, spec-only primitive, which takes
14
+ * These criteria drive two operations equally, and choosing between them IS the
15
+ * routing decision:
16
+ * - `client.search({ filters: [...] })` — spec gates layered onto a RANKED
17
+ * (colour / visual / lexical) search. Reach for it when relevance still
18
+ * matters: "something like this seed, but Class A".
19
+ * - `client.bySpec(...)` — the value-anchored, SPEC-ONLY primitive, which takes
18
20
  * the same criteria as `attribute:value` strings (build them with the helpers
19
- * in `./specifications`).
21
+ * in `./specifications`). Reach for it when the spec is the whole question and
22
+ * ranking would be noise.
23
+ *
24
+ * Discover the tokens before you spend them: `client.specificationFacets()` for
25
+ * what is facetable at all, `client.attributeValues()` for one attribute's
26
+ * values, `client.resolveAttribute()` / `client.resolveAttributeValue()` to turn
27
+ * a phrase into a canonical pair. A hand-guessed token returns an honest empty
28
+ * answer, which is the hardest kind of bug to see.
20
29
  */
21
30
  /**
22
31
  * The virtual attribute id that matches a certification PROGRAMME wherever it
@@ -6,6 +6,11 @@
6
6
  * Split out of `types.ts` for the repository's source-file size budget, the same
7
7
  * reason `catalog-discovery-types.ts` sits beside it; both are re-exported from
8
8
  * `types.ts`, so importers see no difference.
9
+ *
10
+ * THE LENS: entity typeahead — it ranks NAMES, not materials. `mg.search()` is
11
+ * what answers a committed query; `mg.suggestColour()` covers colour names and
12
+ * `mg.searchVocabulary()` covers attribute/value tokens. Suggestions are a page
13
+ * of the best matches, never a census of matching entities.
9
14
  */
10
15
  /** Which kind of entity a suggestion is — and therefore what `refId` means. */
11
16
  export type SuggestKind = "product" | "brand" | "category";
@@ -25,8 +30,12 @@ export interface Suggestion {
25
30
  brandSlug: string | null;
26
31
  brandName: string | null;
27
32
  categoryKey: string | null;
28
- /** A representative variant, so a client can open a material record directly. */
33
+ /**
34
+ * A representative variant, so a client can open a material record directly.
35
+ * Opaque, and null when none was picked — not a product without variants.
36
+ */
29
37
  variantId: string | null;
38
+ /** Variants behind this entity IN MATERIALGRAPH — a coverage figure. */
30
39
  variantCount: number;
31
40
  }
32
41
  /** The suggest response: the query as interpreted, plus the ranked suggestions. */
package/suggest-types.js CHANGED
@@ -6,6 +6,11 @@
6
6
  * Split out of `types.ts` for the repository's source-file size budget, the same
7
7
  * reason `catalog-discovery-types.ts` sits beside it; both are re-exported from
8
8
  * `types.ts`, so importers see no difference.
9
+ *
10
+ * THE LENS: entity typeahead — it ranks NAMES, not materials. `mg.search()` is
11
+ * what answers a committed query; `mg.suggestColour()` covers colour names and
12
+ * `mg.searchVocabulary()` covers attribute/value tokens. Suggestions are a page
13
+ * of the best matches, never a census of matching entities.
9
14
  */
10
15
  /**
11
16
  * Shape the query string for a suggest call.
package/types.d.ts CHANGED
@@ -11,8 +11,22 @@
11
11
  * that mirrors a server enum is therefore widened with `(string & {})` so a new
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
+ *
15
+ * READING CONTRACT — the three things callers get wrong, stated once here and
16
+ * repeated on the types where each one bites:
17
+ * 1. **Ids are opaque.** Round-trip them verbatim; never parse or construct
18
+ * one. See `./identifiers.ts` for which strings are opaque tokens and which
19
+ * are canonical vocabulary you discover.
20
+ * 2. **Absent is not false.** An optional field that is missing means UNKNOWN,
21
+ * not "known to be absent in reality" — most dangerously for booleans.
22
+ * Values carry `confidence` and `sourceBasis`; report them as claims.
23
+ * 3. **A page is not a corpus.** `results.length` is this page.
24
+ * {@link CountedTotal} — with the `basis` it states — is the claim about the
25
+ * match set. Follow the cursor for the rest, and never report a page size as
26
+ * a total.
14
27
  */
15
28
  import type { BySpecVerdict } from "./by-spec-types.js";
29
+ import type { Cursor, ProductId, VariantId } from "./identifiers.js";
16
30
  import type { MaterialDetail, MaterialTile } from "./material-types.js";
17
31
  import type { SearchFilter, SearchSuggestion } from "./search-refinement-types.js";
18
32
  /** A widened string-literal union: known members autocomplete, unknown values still type-check. */
@@ -79,13 +93,22 @@ export interface VisualMeaning {
79
93
  version?: string;
80
94
  appliedAt?: string;
81
95
  }
82
- /** One material in a search / match result list. */
96
+ /**
97
+ * One material in a search / match result list.
98
+ *
99
+ * ABSENT IS NOT FALSE. The optional fields here (`colourMeta`, `signals`,
100
+ * `retired`, `siblingCount`, `visualMeanings`, `verdict`) are absent when the
101
+ * request did not ask for them or the graph holds no answer — never because the
102
+ * answer is "no". A missing `retired` is not "still current"; a missing
103
+ * `visualMeanings` is not "this material has no visual meaning".
104
+ */
83
105
  export interface SearchResultItem {
84
- variantId: string;
106
+ /** Opaque — round-trip verbatim into `material()`, `pairings()`, `similarTo`. */
107
+ variantId: VariantId;
85
108
  brand: string;
86
109
  product: string;
87
110
  brandSlug: string | null;
88
- productId: string | null;
111
+ productId: ProductId | null;
89
112
  color: string | null;
90
113
  variantName: string | null;
91
114
  imageUrl: string | null;
@@ -112,10 +135,18 @@ export interface SearchResultItem {
112
135
  */
113
136
  verdict?: BySpecVerdict;
114
137
  }
115
- /** The lean `view:"card"` result item — identity + name + thumbnail + score. */
138
+ /**
139
+ * The lean `view:"card"` result item — identity + name + thumbnail + score.
140
+ *
141
+ * A PROJECTION, not a smaller answer: a field the card omits (palette, verdict,
142
+ * signals, availability) is unread here, never known-absent. Re-read the same
143
+ * `variantId` at `view: "full"` or via `material()` before claiming anything the
144
+ * card does not carry.
145
+ */
116
146
  export interface SearchCardItem {
117
- variantId: string;
118
- productId: string | null;
147
+ /** Opaque — round-trip verbatim. */
148
+ variantId: VariantId;
149
+ productId: ProductId | null;
119
150
  brandSlug: string | null;
120
151
  brand: string;
121
152
  /** Product name (the full item's `product`). */
@@ -125,15 +156,26 @@ export interface SearchCardItem {
125
156
  score: number;
126
157
  }
127
158
  /**
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.
159
+ * A feed total with its honesty basis — THE CLAIM ABOUT THE MATCH SET, which is
160
+ * a different number from `results.length` (this page).
161
+ *
162
+ * `exact` — the true count (≤ the cap of 1,000, or a facet-MV read). Say "473
163
+ * materials match".
164
+ * `lowerBound` — a floor (cap hit, ANN-pool bound, or a degraded count). Say
165
+ * "at least 1,000", never "1,000". Never a silent approximation.
166
+ *
167
+ * A response with no `counts` at all states no total: `count: true` is opt-in,
168
+ * and the absence of a total is not a total of zero.
131
169
  */
132
170
  export interface CountedTotal {
133
171
  value: number;
134
172
  basis: OpenEnum<"exact" | "lowerBound">;
135
173
  }
136
- /** Feed counts — present under `count:true` or `collapse:"product"`. */
174
+ /**
175
+ * Feed counts — present ONLY under `count:true` or `collapse:"product"`. This is
176
+ * the corpus-level claim; `results.length` is the page. Never report one as the
177
+ * other.
178
+ */
137
179
  export interface SearchCounts {
138
180
  variants: CountedTotal;
139
181
  products?: CountedTotal;
@@ -152,14 +194,21 @@ export interface SearchRerankReport {
152
194
  * {@link SearchCardItem}s. Every other field is shared. `counts` is present
153
195
  * under `count:true` or `collapse:"product"` (replacing the former
154
196
  * `variantCount`/`productCount`).
197
+ *
198
+ * A PAGE, NOT A CORPUS. `results.length` is what you were handed;
199
+ * {@link SearchCounts} is the only statement about the match set, and only when
200
+ * you asked for it. Follow `cursor` for the rest. "I found 24 materials" is
201
+ * wrong whenever `limit` was 24.
155
202
  */
156
203
  export interface SearchResponse {
157
204
  view: OpenEnum<"full" | "card">;
205
+ /** THIS PAGE. Its length is a page size, never a total. */
158
206
  results: SearchResultItem[];
159
207
  suggestions: SearchSuggestion[];
160
208
  mode: SearchMode;
161
209
  maxScore: number;
162
- cursor?: string;
210
+ /** Opaque keyset for the next page. Absent = end of the walk, not an error. */
211
+ cursor?: Cursor;
163
212
  rerank?: SearchRerankReport;
164
213
  counts?: SearchCounts;
165
214
  note?: string;
@@ -180,8 +229,17 @@ export interface MatchCategoryInference {
180
229
  displayName: string;
181
230
  boost: number;
182
231
  }
183
- /** The Material Match response (non-streaming form). */
232
+ /**
233
+ * The Material Match response (non-streaming form).
234
+ *
235
+ * `poolSize` is the candidate pool the floor was applied to, NOT a corpus total
236
+ * and NOT "how many materials look like this". An empty `results` with
237
+ * `droppedBelowFloor > 0` means "candidates existed and none was good enough" —
238
+ * a different answer from `poolSize: 0`, which means the image found nothing to
239
+ * search against at all.
240
+ */
184
241
  export interface MatchResponse {
242
+ /** THIS answer, capped by `limit`. Not a total. */
185
243
  results: SearchResultItem[];
186
244
  inferredCategories: MatchCategoryInference[];
187
245
  qualityFloor: number;
@@ -195,9 +253,17 @@ export interface MatchCategory {
195
253
  group: string | null;
196
254
  type: string | null;
197
255
  }
198
- /** One colour-palette match. */
256
+ /**
257
+ * One colour-palette match.
258
+ *
259
+ * `hex`, `distance` and `closeness` are OKLab geometry over colour EXTRACTED
260
+ * FROM PRODUCT IMAGERY, not declared by the manufacturer. Report it as visual
261
+ * likeness ("reads close to this colour"), never as a stated colour
262
+ * specification.
263
+ */
199
264
  export interface ByColourMatch {
200
- variantId: string;
265
+ /** Opaque — round-trip verbatim. */
266
+ variantId: VariantId;
201
267
  brand: string;
202
268
  product: string;
203
269
  color: string | null;
@@ -216,16 +282,18 @@ export interface ByColourResponse {
216
282
  categories?: string[];
217
283
  oklab?: OklabTriple;
218
284
  };
285
+ /** `matches.length` — THIS PAGE. Not a corpus total; this surface states none. */
219
286
  count: number;
220
287
  matches: ByColourMatch[];
221
288
  }
222
289
  /** One item from the pure colour-scalar browse. */
223
290
  export interface BrowseResultItem {
224
- variantId: string;
291
+ /** Opaque — round-trip verbatim. */
292
+ variantId: VariantId;
225
293
  brand: string;
226
294
  product: string;
227
295
  brandSlug: string | null;
228
- productId: string | null;
296
+ productId: ProductId | null;
229
297
  color: string | null;
230
298
  variantName: string | null;
231
299
  imageUrl: string | null;
@@ -236,10 +304,17 @@ export interface BrowseResultItem {
236
304
  };
237
305
  retired?: boolean;
238
306
  }
239
- /** A single page of the colour browse. `cursor` is the keyset for the next page. */
307
+ /**
308
+ * A single page of the colour browse — the SDK's one genuinely cursor-paginated
309
+ * surface. `results.length` is this page and the browse states no total at all,
310
+ * so a corpus claim can only come from walking every page
311
+ * (`client.browse.pages(...)` does the loop).
312
+ */
240
313
  export interface BrowseResponse {
314
+ /** THIS PAGE. */
241
315
  results: BrowseResultItem[];
242
- cursor?: string;
316
+ /** Opaque keyset for the next page. Absent = the walk is finished. */
317
+ cursor?: Cursor;
243
318
  }
244
319
  export type ColourDictionaryId = OpenEnum<"colour_family" | "colour_mood" | "colour_undertone">;
245
320
  export interface ColourFacetTerm {
@@ -285,17 +360,34 @@ export interface ProjectTypeShare {
285
360
  projectType: string;
286
361
  projectCount: number;
287
362
  }
288
- /** A pairing item backed by real co-specification (order) evidence. */
363
+ /**
364
+ * A pairing item from the behavioural arm — real order evidence, or board
365
+ * curation when this seed has no order edges.
366
+ *
367
+ * WHAT YOU MAY CLAIM, per `source`:
368
+ * - `cospec` — real ORDER co-occurrence. The two materials were ordered
369
+ * together on real projects. "Specified together" is honest here.
370
+ * - `considered` — BOARD ACTIVITY only (moodboard curation). Somebody put them
371
+ * on a board together; nobody is known to have bought them together. Say
372
+ * "often boarded together" / "considered alongside" and NEVER "specified
373
+ * together". The server falls back to this arm seed-by-seed when the seed
374
+ * carries no `sampled` edges, so a single response can be entirely
375
+ * `considered`.
376
+ *
377
+ * See {@link ColourPairing} for the third, non-evidential arm.
378
+ */
289
379
  export interface CospecPairing {
380
+ /** The evidence tier. `cospec` = orders; `considered` = boards. Read it before phrasing. */
290
381
  source: "cospec" | "considered";
291
- variantId: string;
382
+ /** Opaque — round-trip verbatim. */
383
+ variantId: VariantId;
292
384
  brand: string;
293
385
  product: string;
294
386
  color: string | null;
295
387
  variantName: string | null;
296
388
  imageUrl: string | null;
297
389
  brandSlug: string;
298
- productId: string;
390
+ productId: ProductId;
299
391
  category: string | null;
300
392
  projectCount: number;
301
393
  customerCount: number;
@@ -304,16 +396,31 @@ export interface CospecPairing {
304
396
  demand: CompanionDemand | null;
305
397
  topProjectTypes: ProjectTypeShare[];
306
398
  }
307
- /** A pairing item topped up from colour palette-similarity (merely looks adjacent). */
399
+ /**
400
+ * A pairing item topped up from colour palette-similarity — a LOOK-ALIKE, not
401
+ * evidence. Nobody ordered these together and nobody boarded them together; the
402
+ * palettes sit near each other in OKLab. Say "shares a palette with" and never
403
+ * "specified together" or "often paired with".
404
+ */
308
405
  export interface ColourPairing extends MaterialTile {
309
406
  source: "colour";
310
407
  score: number;
311
408
  }
409
+ /**
410
+ * One pairing, discriminated by `source` into three tiers of very different
411
+ * strength: `cospec` (real orders) > `considered` (board activity) > `colour`
412
+ * (palette look-alike). Branch on `source` before you write a sentence about it.
413
+ */
312
414
  export type PairingItem = CospecPairing | ColourPairing;
313
415
  export interface PairingsResponse {
314
416
  seed: MaterialTile;
417
+ /** A rail, not a ranked corpus — mixed evidence tiers, capped by `limit`. */
315
418
  items: PairingItem[];
316
- /** How many items came from real order evidence (the cospec arm). */
419
+ /**
420
+ * How many `items` came from real ORDER evidence (`source: "cospec"`). Zero
421
+ * with a full rail means every item is board activity or colour look-alike —
422
+ * check it before presenting the rail as demand evidence.
423
+ */
317
424
  cospecCount: number;
318
425
  }
319
426
  export interface MaterialResponse {
@@ -324,15 +431,23 @@ export interface MaterialResponse {
324
431
  export interface SiblingsResponse {
325
432
  siblings: MaterialTile[];
326
433
  }
327
- /** Batch read of tile projections. */
434
+ /**
435
+ * Batch read of tile projections. `materials` is NOT positional — resolve by
436
+ * `variantId`, and read `missingIds` rather than inferring absence from length.
437
+ */
328
438
  export interface MaterialsBatchTileResponse {
329
439
  materials: MaterialTile[];
330
- missingIds: string[];
440
+ /** Requested ids that resolved to nothing. Echoed verbatim, opaque. */
441
+ missingIds: VariantId[];
331
442
  }
332
- /** Batch read of detail projections. */
443
+ /**
444
+ * Batch read of detail projections. `materials` is NOT positional — resolve by
445
+ * `variantId`, and read `missingIds` rather than inferring absence from length.
446
+ */
333
447
  export interface MaterialsBatchDetailResponse {
334
448
  materials: MaterialDetail[];
335
- missingIds: string[];
449
+ /** Requested ids that resolved to nothing. Echoed verbatim, opaque. */
450
+ missingIds: VariantId[];
336
451
  }
337
452
  export interface ResolveColourResponse {
338
453
  match: {
@@ -414,7 +529,7 @@ export interface SearchInput {
414
529
  * seed variant, so it is the only form `verdicts` can diff a candidate against.
415
530
  */
416
531
  similarTo?: {
417
- variantId: string;
532
+ variantId: VariantId;
418
533
  } | {
419
534
  vector: string;
420
535
  };
@@ -438,11 +553,16 @@ export interface SearchInput {
438
553
  * demotes a failed/missing preferred hit WITHIN its page only.
439
554
  */
440
555
  preferences?: SearchFilter[];
556
+ /** Page size — the size of ONE page, never a bound on the match set. */
441
557
  limit?: number;
442
- cursor?: string;
558
+ /** The previous response's `cursor`, passed back verbatim. Never synthesise one. */
559
+ cursor?: Cursor;
443
560
  explain?: boolean;
444
561
  collapse?: OpenEnum<"none" | "product">;
445
- /** Opt-in feed total: EXACT up to 1,000, `lowerBound` beyond / for ANN pools. */
562
+ /**
563
+ * Opt-in feed total: EXACT up to 1,000, `lowerBound` beyond / for ANN pools.
564
+ * Without it the response states NO total — and a page size is not one.
565
+ */
446
566
  count?: boolean;
447
567
  /** Only return materials with at least one catalog image. Default false (full catalog). */
448
568
  hasImages?: boolean;
@@ -464,7 +584,8 @@ export interface SearchGetParams {
464
584
  mood?: string;
465
585
  sort?: OpenEnum<"relevance" | "light" | "dark" | "vivid" | "muted" | "colourful">;
466
586
  tol?: OpenEnum<"tight" | "normal" | "loose">;
467
- variantId?: string;
587
+ /** Seed the search from this variant's image. Opaque — pass it through verbatim. */
588
+ variantId?: VariantId;
468
589
  limit?: number;
469
590
  /** "0" or "1" — include per-signal `signals` on each result. */
470
591
  explain?: OpenEnum<"0" | "1">;
@@ -492,7 +613,12 @@ export interface ByColourParams {
492
613
  /** Params for the pure colour `search/browse` surface. */
493
614
  export interface BrowseParams {
494
615
  sort: OpenEnum<"light" | "dark" | "vivid" | "muted" | "colourful">;
495
- cursor?: string;
616
+ /**
617
+ * The previous page's `cursor`, verbatim. Opaque keyset state — not an offset.
618
+ * Changing `sort`, `limit` or `types` invalidates it.
619
+ */
620
+ cursor?: Cursor;
621
+ /** Page size. */
496
622
  limit?: number;
497
623
  /** Comma-separated product-type ids to restrict the browse to. */
498
624
  types?: string;
@@ -525,7 +651,14 @@ export interface MatchInput {
525
651
  }
526
652
  /** Options for a batch material read. */
527
653
  export interface MaterialsBatchOptions {
654
+ /** Projection depth. `tile` omits fields rather than reporting them absent. */
528
655
  view?: "tile" | "detail";
656
+ /**
657
+ * Which id space `ids` are in — MaterialGraph's own opaque `variantId`
658
+ * (default) or Material Bank's `materialBankId`. Both are opaque; they are NOT
659
+ * interchangeable, and passing one under the other's `idKind` returns
660
+ * `missingIds`, not an error.
661
+ */
529
662
  idKind?: OpenEnum<"variantId" | "materialBankId">;
530
663
  }
531
664
  /** Options for a pairings read. */
@@ -543,184 +676,28 @@ export interface ResolveAttributeOptions {
543
676
  export interface BySpecOptions {
544
677
  limit?: number;
545
678
  }
546
- /** A resolved seed material (identity block shared by the demand reads). */
547
- export interface DemandSeed {
548
- variantId: string;
549
- brand: string;
550
- product: string;
551
- color: string | null;
552
- variantName: string | null;
553
- imageUrl: string | null;
554
- brandSlug: string;
555
- productId: string;
556
- }
679
+ export type * from "./demand-types.js";
680
+ /** One quarter's tally. Sparse across a series — an absent quarter is not a zero. */
557
681
  export interface DemandQuarter {
558
682
  quarter: string;
559
683
  specCount: number;
560
684
  projectCount: number;
561
685
  }
562
- /** Headline demand stats for a seed: counts, quarterly series, momentum. */
563
- export interface MaterialDemandStats {
564
- specCount: number;
565
- projectCount: number;
566
- customerCount: number;
567
- byQuarter: DemandQuarter[];
568
- momentum: DemandMomentum;
569
- }
570
- /** `getMaterialDemand` result, passed through verbatim by the `/demand` route. */
571
- export interface MaterialDemandResult {
572
- found: boolean;
573
- query: string;
574
- seed: DemandSeed | null;
575
- window: "trailing_36mo";
576
- demand: MaterialDemandStats | null;
577
- }
578
686
  /**
579
- * `getCoSpecifiedMaterials` result, passed through verbatim. The `companions`
580
- * list (materials specified alongside the seed) is included but left loosely
581
- * typed — there is no captured fixture to pin its full shape.
687
+ * Which behavioural signal a demand read is scoped to — and therefore what a
688
+ * number drawn from it may honestly be called.
689
+ *
690
+ * - `sampled` — real ORDER co-occurrence. The default everywhere. Present as
691
+ * "specified together" / "specified on N projects".
692
+ * - `considered` — board / moodboard CURATION. Somebody assembled them; nobody
693
+ * is known to have bought them. Present as "considered alongside", never as
694
+ * a specification.
695
+ * - `specified` — the won tier. STATS ONLY: no co-specification edges are built
696
+ * at this grain, so asking for `specified` companions is a rejected request,
697
+ * not an empty result.
582
698
  */
583
- export interface CoSpecifiedResult {
584
- found: boolean;
585
- resolution: OpenEnum<"resolved" | "ambiguous" | "not_found">;
586
- query: string;
587
- seed: DemandSeed | null;
588
- seedCategory: string | null;
589
- seedAlternatives: DemandSeed[];
590
- companions?: unknown[];
591
- }
592
- /** The `/api/v1/demand` envelope — both domain reads side by side, verbatim. */
593
- export interface DemandResponse {
594
- query: string;
595
- demand: MaterialDemandResult;
596
- cospec: CoSpecifiedResult;
597
- }
598
- /** Which behavioural signal candidate demand is read from. */
599
699
  export type DemandSignal = OpenEnum<"sampled" | "considered" | "specified">;
600
- /**
601
- * Body for `POST /api/v1/demand/batch` — enrich 1–48 already-selected candidate
602
- * ids with their real specification demand, in input order and cardinality.
603
- */
604
- export interface DemandBatchInput {
605
- /** 1–48 canonical variant ids. Order AND cardinality are preserved — duplicates are echoed, never collapsed. */
606
- variantIds: string[];
607
- /** The behavioural signal to read demand from. Defaults to `sampled` (order co-occurrence) server-side. */
608
- signal?: DemandSignal;
609
- /** Optional declared context (e.g. `{ projectType: "hospitality" }`) — echoed for provenance, never used to rank or filter. */
610
- context?: Record<string, string>;
611
- }
612
- /** Headline population behind the metric — this variant's own counts. */
613
- export interface DemandBatchPopulation {
614
- specCount: number;
615
- projectCount: number;
616
- customerCount: number;
617
- }
618
- /** The consideration→specification funnel (project-grain; `exclusionRate` is annotate-only). */
619
- export interface DemandBatchFunnel {
620
- consideredProjects: number;
621
- specifiedProjects: number;
622
- excludedCount: number;
623
- exclusionRate: number;
624
- }
625
- /** Per-result guardrail status — present on `internal` (`mg:admin`) results only. */
626
- export interface DemandBatchEvidence {
627
- belowPrivacyFloor: boolean;
628
- belowQualityFloor: boolean;
629
- suppressedForPublic: boolean;
630
- }
631
- /** A variant that carries a usable demand signal. */
632
- export interface DemandBatchAvailableResult {
633
- variantId: string;
634
- state: "available";
635
- population: DemandBatchPopulation;
636
- /** Exact per-quarter counts (sparse — zero-spec quarters are absent). */
637
- intervals: DemandQuarter[];
638
- momentum: DemandMomentum;
639
- /** The funnel; null when the signal is not `sampled` or no `considered` row/session exists. */
640
- funnel: DemandBatchFunnel | null;
641
- /** Exact guardrail evidence for `internal` callers; null for public. */
642
- evidence: DemandBatchEvidence | null;
643
- }
644
- /** A variant with a stats row that is below the public quality floor. */
645
- export interface DemandBatchInsufficientResult {
646
- variantId: string;
647
- state: "insufficient_data";
648
- }
649
- /** A variant with no stats — indistinguishable from a privacy-suppressed one for public callers. */
650
- export interface DemandBatchNoSignalResult {
651
- variantId: string;
652
- state: "no_signal";
653
- }
654
- /** A requested id that resolves to no real variant. */
655
- export interface DemandBatchUnknownResult {
656
- variantId: string;
657
- state: "unknown_variant";
658
- }
659
- /** One discriminated result per input id, in input order and cardinality. */
660
- export type DemandBatchResult = DemandBatchAvailableResult | DemandBatchInsufficientResult | DemandBatchNoSignalResult | DemandBatchUnknownResult;
661
- /** The `POST /api/v1/demand/batch` envelope. */
662
- export interface DemandBatchResponse {
663
- /** The completed ingestion-session id these counts were mined in (the dataset generation). */
664
- generationId: string;
665
- metric: "specification_demand";
666
- /** The metric universe — the window and signal the counts are scoped to. */
667
- universe: {
668
- window: "trailing_36mo";
669
- signal: DemandSignal;
670
- description: string;
671
- };
672
- /** The guardrail status under which `results` were projected. */
673
- guardrail: {
674
- audience: OpenEnum<"public" | "internal">;
675
- minProjectCount: number;
676
- minCustomerCount: number;
677
- };
678
- /** Outcome tallies over `results` — never a denominator for the metric. */
679
- coverage: {
680
- requested: number;
681
- available: number;
682
- insufficientData: number;
683
- noSignal: number;
684
- unknownVariant: number;
685
- };
686
- /** The echoed declared context, or null when none was supplied. */
687
- context: Record<string, string> | null;
688
- results: DemandBatchResult[];
689
- }
690
- export interface CoordinateSeed extends DemandSeed {
691
- /** The input reference this seed resolved from. */
692
- resolvedFrom: string;
693
- }
694
- export interface CoordinateCompanion {
695
- variantId: string;
696
- brand: string;
697
- product: string;
698
- color: string | null;
699
- variantName: string | null;
700
- imageUrl: string | null;
701
- brandSlug: string;
702
- productId: string;
703
- totalLift: number;
704
- liftLabel: string;
705
- seedMatchCount: number;
706
- seedContributions: Array<{
707
- seedVariantId: string;
708
- lift: number;
709
- }>;
710
- demand: CompanionDemand | null;
711
- swatches: Array<{
712
- hex: string;
713
- proportion: number;
714
- }>;
715
- /** Annotate-only appearance similarity — never reorders companions in v1. */
716
- colourFit: number | null;
717
- }
718
- export interface CoordinateResponse {
719
- seeds: CoordinateSeed[];
720
- unresolved: string[];
721
- window: "trailing_36mo";
722
- companions: CoordinateCompanion[];
723
- }
700
+ export type * from "./demand-batch-types.js";
724
701
  export interface ExtractedPalette {
725
702
  colors: Array<{
726
703
  hex: string;
@@ -848,18 +825,6 @@ export interface BrandProfileResponse {
848
825
  name: string;
849
826
  }>;
850
827
  }
851
- /** Seed a demand read by EXACTLY ONE of `variantId` or `material`. */
852
- export interface DemandParams {
853
- variantId?: string;
854
- material?: string;
855
- }
856
- /** Body for `POST /api/v1/pairings/coordinate`. */
857
- export interface CoordinateInput {
858
- materials?: string[];
859
- variantIds?: string[];
860
- projectType?: string;
861
- limit?: number;
862
- }
863
828
  /** Body for `POST /api/v1/search/palettes/extract`. */
864
829
  export interface ExtractPaletteInput {
865
830
  /** The query image as a data URI or bare base64 string. */