@instruments/materialgraph 0.1.0 → 0.2.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/spec-types.js ADDED
@@ -0,0 +1,40 @@
1
+ /**
2
+ * The specification surface — the typed vocabulary for asking a material
3
+ * "does it meet this spec?": certifications (GREENGUARD, FloorScore, Declare),
4
+ * VOC / emissions testing (CDPH v1.2), sustainability programmes (LEED, EPDs),
5
+ * and fire / abrasion / slip classifications.
6
+ *
7
+ * Every shape here is a hand-authored, zero-dependency mirror of the server's
8
+ * spec filter contract (`specFilterSchema` in
9
+ * `@materialgraph/products/search/schema`). It exists so a caller answering a
10
+ * question like "find me a GREENGUARD-certified alternative" or "the same fire
11
+ * class, but with an EPD on record" reaches for a NAMED, typed criterion instead
12
+ * of hand-assembling a magic `attribute:value` string.
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
18
+ * the same criteria as `attribute:value` strings (build them with the helpers
19
+ * in `./specifications`).
20
+ */
21
+ /**
22
+ * The virtual attribute id that matches a certification PROGRAMME wherever it
23
+ * landed at ingest.
24
+ *
25
+ * Certifications are not stored under one attribute: each record is an object
26
+ * (`{ programme, name, status, … }`) split across FOUR real object-array
27
+ * attributes by intent — general `certifications`,
28
+ * `sustainability_certifications`, `material_health_certifications`, and
29
+ * `voc_emissions_testing_certificate` (GREENGUARD Gold / FloorScore / CDPH land
30
+ * in the last). A caller shouldn't have to know which bucket a programme fell
31
+ * into, so this single virtual id matches the `programme` term across all four.
32
+ *
33
+ * @example
34
+ * // "find a GREENGUARD-certified alternative"
35
+ * client.search({
36
+ * similarTo: { variantId: seed },
37
+ * filters: [{ attributeId: CERTIFICATION_PROGRAMME_ATTRIBUTE_ID, term: "GREENGUARD Gold" }],
38
+ * });
39
+ */
40
+ export const CERTIFICATION_PROGRAMME_ATTRIBUTE_ID = "certification_programme";
@@ -0,0 +1,63 @@
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 { type CertificationProgrammeFilter, type SpecPresenceFilter, type SpecTermFilter } 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 declare function specTerm(attributeId: string, term: string): SpecTermFilter;
27
+ /**
28
+ * A presence criterion for `search({ filters })` — the attribute must have ANY
29
+ * accepted value.
30
+ *
31
+ * @example
32
+ * // "…and it must have an EPD on record"
33
+ * client.search({ similarTo: { variantId }, filters: [hasSpec("environmental_product_declaration")] });
34
+ */
35
+ export declare function hasSpec(attributeId: string): SpecPresenceFilter;
36
+ /**
37
+ * A certification-programme criterion for `search({ filters })`, pinned to the
38
+ * virtual attribute so a programme matches wherever it landed at ingest
39
+ * (compliance / sustainability / material-health / VOC buckets).
40
+ *
41
+ * @example
42
+ * // "find a GREENGUARD-certified alternative"
43
+ * client.search({ similarTo: { variantId: seed }, filters: [certificationProgramme("GREENGUARD Gold")] });
44
+ */
45
+ export declare function certificationProgramme(programme: string): CertificationProgrammeFilter;
46
+ /**
47
+ * An `attribute:value` criterion string for `bySpec`. The `bySpec` route splits
48
+ * on the FIRST `:`, so a value may itself contain colons; the attribute id may
49
+ * not.
50
+ *
51
+ * @example
52
+ * client.bySpec([specCriterion("primary_material", "textile"), specCriterion("fire_classifications", "astm_e84_class_a")]);
53
+ */
54
+ export declare function specCriterion(attributeId: string, value: string): string;
55
+ /**
56
+ * An `attribute:value` criterion string for `bySpec` that filters by
57
+ * certification PROGRAMME across all four certification buckets.
58
+ *
59
+ * @example
60
+ * // every brand's GREENGUARD-Gold materials
61
+ * client.bySpec([certificationProgrammeCriterion("GREENGUARD Gold")]);
62
+ */
63
+ export declare function certificationProgrammeCriterion(programme: string): string;
@@ -0,0 +1,73 @@
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
+ * @example
46
+ * // "find a GREENGUARD-certified alternative"
47
+ * client.search({ similarTo: { variantId: seed }, filters: [certificationProgramme("GREENGUARD Gold")] });
48
+ */
49
+ export function certificationProgramme(programme) {
50
+ return { attributeId: CERTIFICATION_PROGRAMME_ATTRIBUTE_ID, term: programme };
51
+ }
52
+ /**
53
+ * An `attribute:value` criterion string for `bySpec`. The `bySpec` route splits
54
+ * on the FIRST `:`, so a value may itself contain colons; the attribute id may
55
+ * not.
56
+ *
57
+ * @example
58
+ * client.bySpec([specCriterion("primary_material", "textile"), specCriterion("fire_classifications", "astm_e84_class_a")]);
59
+ */
60
+ export function specCriterion(attributeId, value) {
61
+ return `${attributeId}:${value}`;
62
+ }
63
+ /**
64
+ * An `attribute:value` criterion string for `bySpec` that filters by
65
+ * certification PROGRAMME across all four certification buckets.
66
+ *
67
+ * @example
68
+ * // every brand's GREENGUARD-Gold materials
69
+ * client.bySpec([certificationProgrammeCriterion("GREENGUARD Gold")]);
70
+ */
71
+ export function certificationProgrammeCriterion(programme) {
72
+ return specCriterion(CERTIFICATION_PROGRAMME_ATTRIBUTE_ID, programme);
73
+ }
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
@@ -134,6 +134,32 @@ export interface SearchResultItem {
134
134
  siblingCount?: number;
135
135
  visualMeanings?: VisualMeaning[];
136
136
  }
137
+ /** The lean `view:"card"` result item — identity + name + thumbnail + score. */
138
+ export interface SearchCardItem {
139
+ variantId: string;
140
+ productId: string | null;
141
+ brandSlug: string | null;
142
+ brand: string;
143
+ /** Product name (the full item's `product`). */
144
+ name: string;
145
+ variantName: string | null;
146
+ imageUrl: string | null;
147
+ score: number;
148
+ }
149
+ /**
150
+ * A feed total with its honesty basis. `exact` — the true count (≤ the cap of
151
+ * 1,000, or a facet-MV read). `lowerBound` — a floor (cap hit, ANN-pool bound,
152
+ * or a degraded count). Never a silent approximation.
153
+ */
154
+ export interface CountedTotal {
155
+ value: number;
156
+ basis: OpenEnum<"exact" | "lowerBound">;
157
+ }
158
+ /** Feed counts — present under `count:true` or `collapse:"product"`. */
159
+ export interface SearchCounts {
160
+ variants: CountedTotal;
161
+ products?: CountedTotal;
162
+ }
137
163
  /** The single candidate source a query resolved to. */
138
164
  export type SearchMode = OpenEnum<"image" | "colour" | "text" | "lexical">;
139
165
  /** Rerank-term report (absent in colour mode — there colour IS the source). */
@@ -142,16 +168,22 @@ export interface SearchRerankReport {
142
168
  colourWeight?: number;
143
169
  colourLabel?: string;
144
170
  }
145
- /** The full search response (both GET and POST forms return this envelope). */
171
+ /**
172
+ * The search response envelope. `view` discriminates the `results` union:
173
+ * `"full"` (default) carries {@link SearchResultItem}s, `"card"` the lean
174
+ * {@link SearchCardItem}s. Every other field is shared. `counts` is present
175
+ * under `count:true` or `collapse:"product"` (replacing the former
176
+ * `variantCount`/`productCount`).
177
+ */
146
178
  export interface SearchResponse {
179
+ view: OpenEnum<"full" | "card">;
147
180
  results: SearchResultItem[];
148
181
  suggestions: SearchSuggestion[];
149
182
  mode: SearchMode;
150
183
  maxScore: number;
151
184
  cursor?: string;
152
185
  rerank?: SearchRerankReport;
153
- variantCount?: number;
154
- productCount?: number;
186
+ counts?: SearchCounts;
155
187
  note?: string;
156
188
  timings?: Record<string, number>;
157
189
  /** Echo of the resolved query input (raw image bytes/vector are never echoed). */
@@ -159,6 +191,11 @@ export interface SearchResponse {
159
191
  /** A `getVariantBrief` echo of the seed variant, present only in `variantId` seed mode. */
160
192
  seed?: MaterialTile | Record<string, unknown>;
161
193
  }
194
+ /** The `view:"card"` response form — the lean grid projection of {@link SearchResponse}. */
195
+ export interface SearchCardResponse extends Omit<SearchResponse, "view" | "results"> {
196
+ view: OpenEnum<"card">;
197
+ results: SearchCardItem[];
198
+ }
162
199
  /** An inferred product-type category from the query image, with its boost. */
163
200
  export interface MatchCategoryInference {
164
201
  productTypeId: string;
@@ -437,6 +474,12 @@ export interface SearchInput {
437
474
  cursor?: string;
438
475
  explain?: boolean;
439
476
  collapse?: OpenEnum<"none" | "product">;
477
+ /** Opt-in feed total: EXACT up to 1,000, `lowerBound` beyond / for ANN pools. */
478
+ count?: boolean;
479
+ /** Only return materials with at least one catalog image. Default false (full catalog). */
480
+ hasImages?: boolean;
481
+ /** Result projection — `"full"` (default) or the lean `"card"` grid form. */
482
+ view?: OpenEnum<"full" | "card">;
440
483
  }
441
484
  /** Query-string params for the GET `/search` form. */
442
485
  export interface SearchGetParams {
@@ -450,6 +493,12 @@ export interface SearchGetParams {
450
493
  limit?: number;
451
494
  /** "0" or "1" — include per-signal `signals` on each result. */
452
495
  explain?: OpenEnum<"0" | "1">;
496
+ /** "true" — opt in to a `counts.variants` feed total. */
497
+ count?: OpenEnum<"true">;
498
+ /** "true" — only return materials with at least one catalog image. */
499
+ hasImages?: OpenEnum<"true">;
500
+ /** Result projection — `"full"` (default) or the lean `"card"` grid form. */
501
+ view?: OpenEnum<"full" | "card">;
453
502
  }
454
503
  /** Params for `search/colours`. */
455
504
  export interface ByColourParams {
@@ -559,6 +608,98 @@ export interface DemandResponse {
559
608
  demand: MaterialDemandResult;
560
609
  cospec: CoSpecifiedResult;
561
610
  }
611
+ /** Which behavioural signal candidate demand is read from. */
612
+ export type DemandSignal = OpenEnum<"sampled" | "considered" | "specified">;
613
+ /**
614
+ * Body for `POST /api/v1/demand/batch` — enrich 1–48 already-selected candidate
615
+ * ids with their real specification demand, in input order and cardinality.
616
+ */
617
+ export interface DemandBatchInput {
618
+ /** 1–48 canonical variant ids. Order AND cardinality are preserved — duplicates are echoed, never collapsed. */
619
+ variantIds: string[];
620
+ /** The behavioural signal to read demand from. Defaults to `sampled` (order co-occurrence) server-side. */
621
+ signal?: DemandSignal;
622
+ /** Optional declared context (e.g. `{ projectType: "hospitality" }`) — echoed for provenance, never used to rank or filter. */
623
+ context?: Record<string, string>;
624
+ }
625
+ /** Headline population behind the metric — this variant's own counts. */
626
+ export interface DemandBatchPopulation {
627
+ specCount: number;
628
+ projectCount: number;
629
+ customerCount: number;
630
+ }
631
+ /** The consideration→specification funnel (project-grain; `exclusionRate` is annotate-only). */
632
+ export interface DemandBatchFunnel {
633
+ consideredProjects: number;
634
+ specifiedProjects: number;
635
+ excludedCount: number;
636
+ exclusionRate: number;
637
+ }
638
+ /** Per-result guardrail status — present on `internal` (`mg:admin`) results only. */
639
+ export interface DemandBatchEvidence {
640
+ belowPrivacyFloor: boolean;
641
+ belowQualityFloor: boolean;
642
+ suppressedForPublic: boolean;
643
+ }
644
+ /** A variant that carries a usable demand signal. */
645
+ export interface DemandBatchAvailableResult {
646
+ variantId: string;
647
+ state: "available";
648
+ population: DemandBatchPopulation;
649
+ /** Exact per-quarter counts (sparse — zero-spec quarters are absent). */
650
+ intervals: DemandQuarter[];
651
+ momentum: DemandMomentum;
652
+ /** The funnel; null when the signal is not `sampled` or no `considered` row/session exists. */
653
+ funnel: DemandBatchFunnel | null;
654
+ /** Exact guardrail evidence for `internal` callers; null for public. */
655
+ evidence: DemandBatchEvidence | null;
656
+ }
657
+ /** A variant with a stats row that is below the public quality floor. */
658
+ export interface DemandBatchInsufficientResult {
659
+ variantId: string;
660
+ state: "insufficient_data";
661
+ }
662
+ /** A variant with no stats — indistinguishable from a privacy-suppressed one for public callers. */
663
+ export interface DemandBatchNoSignalResult {
664
+ variantId: string;
665
+ state: "no_signal";
666
+ }
667
+ /** A requested id that resolves to no real variant. */
668
+ export interface DemandBatchUnknownResult {
669
+ variantId: string;
670
+ state: "unknown_variant";
671
+ }
672
+ /** One discriminated result per input id, in input order and cardinality. */
673
+ export type DemandBatchResult = DemandBatchAvailableResult | DemandBatchInsufficientResult | DemandBatchNoSignalResult | DemandBatchUnknownResult;
674
+ /** The `POST /api/v1/demand/batch` envelope. */
675
+ export interface DemandBatchResponse {
676
+ /** The completed ingestion-session id these counts were mined in (the dataset generation). */
677
+ generationId: string;
678
+ metric: "specification_demand";
679
+ /** The metric universe — the window and signal the counts are scoped to. */
680
+ universe: {
681
+ window: "trailing_36mo";
682
+ signal: DemandSignal;
683
+ description: string;
684
+ };
685
+ /** The guardrail status under which `results` were projected. */
686
+ guardrail: {
687
+ audience: OpenEnum<"public" | "internal">;
688
+ minProjectCount: number;
689
+ minCustomerCount: number;
690
+ };
691
+ /** Outcome tallies over `results` — never a denominator for the metric. */
692
+ coverage: {
693
+ requested: number;
694
+ available: number;
695
+ insufficientData: number;
696
+ noSignal: number;
697
+ unknownVariant: number;
698
+ };
699
+ /** The echoed declared context, or null when none was supplied. */
700
+ context: Record<string, string> | null;
701
+ results: DemandBatchResult[];
702
+ }
562
703
  export interface CoordinateSeed extends DemandSeed {
563
704
  /** The input reference this seed resolved from. */
564
705
  resolvedFrom: string;
@@ -742,7 +883,7 @@ export interface ExtractPaletteInput {
742
883
  /** Options for a cacheable GET batch read (`materialsCached`). */
743
884
  export interface MaterialsBatchGetOptions extends MaterialsBatchOptions {
744
885
  }
745
- /** Options for `brands/{slug}`. */
886
+ /** Options for `brands/{brandSlug}`. */
746
887
  export interface BrandOptions {
747
888
  /** Alternate fuzzy query; takes precedence over the path slug when present. */
748
889
  q?: string;