@instruments/materialgraph 0.6.0 → 0.7.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/codes-types.d.ts CHANGED
@@ -13,18 +13,47 @@
13
13
  * ADVISORY ONLY: every verdict and the envelope carry the AHJ caveat — this is
14
14
  * model code as adopted, never a legal determination.
15
15
  */
16
+ import type { VariantId } from "./identifiers.js";
16
17
  import type { OpenEnum } from "./types.js";
17
18
  /**
18
- * Badge a bounded set of materials against the code-requirement registry for one
19
- * (jurisdiction, occupancy, space, sprinklered) context. `results` preserve
20
- * input order and cardinality (duplicate ids are echoed).
19
+ * A code context: the space the registry is queried for.
20
+ *
21
+ * CONDITIONAL REQUIREMENT — two axes must END UP resolved, and each may be
22
+ * stated OR correlated. Every field below is optional in the type because the
23
+ * validator resolves them per axis independently; the rule it enforces at
24
+ * runtime is:
25
+ *
26
+ * > occupancyGroup, resolved = `input.occupancyGroup` ?? correlate(roomType)
27
+ * > spaceType, resolved = `input.spaceType` ?? correlate(roomType)
28
+ *
29
+ * and either resolving to nothing is a 400, verbatim:
30
+ *
31
+ * - `"An occupancy group is required — state occupancyGroup, or a roomType that
32
+ * correlates to one."`
33
+ * - `"A space type is required — state spaceType, or a roomType that correlates
34
+ * to one."`
35
+ * - `"Room type \"{roomType}\" does not correlate to an IBC occupancy group —
36
+ * state occupancyGroup explicitly."`
37
+ * - `"Room type \"{roomType}\" does not correlate to a Table 803.13 finish
38
+ * location — state spaceType explicitly."`
39
+ *
40
+ * So: give `occupancyGroup` + `spaceType`, or give `roomType` and let both
41
+ * correlate, or mix them — an explicit axis always wins over a correlated one,
42
+ * and a room is only consulted for an axis you did not state. Correlation never
43
+ * guesses: a room that maps to neither is an error naming the axis, and the
44
+ * response's `resolvedContext` is where you audit what was inferred for you.
45
+ *
46
+ * Discover every accepted value — jurisdictions, occupancy groups, finish
47
+ * locations, and the room correlations themselves — from
48
+ * {@link CodesClient.context}. Nothing here should be hardcoded.
21
49
  */
22
50
  export interface CodeContextRequest {
23
- /** A seeded jurisdiction id (e.g. `"us-tx"`); an unknown id is a 400. */
51
+ /** A seeded jurisdiction id (e.g. `"us-tx"`); an unknown id is a 400 listing the seeded set. */
24
52
  jurisdictionId: string;
25
53
  /**
26
54
  * The IBC occupancy group for the space (e.g. `"I-2"` hospital, `"A-2"`
27
- * restaurant). Required unless `roomType` correlates to one.
55
+ * restaurant). Required unless `roomType` correlates to one. An uncovered
56
+ * group is a 400 listing the covered ones.
28
57
  */
29
58
  occupancyGroup?: string;
30
59
  /**
@@ -42,8 +71,12 @@ export interface CodeContextRequest {
42
71
  sprinklered: boolean;
43
72
  }
44
73
  export interface CodeAdvisoryInput extends CodeContextRequest {
45
- /** 1–100 opaque variant ids to badge, echoed back in `results` in this exact order. */
46
- variantIds: string[];
74
+ /**
75
+ * 1–100 opaque variant ids to badge, echoed back in `results` in this exact
76
+ * order and cardinality (duplicates are echoed, never collapsed). Preserve
77
+ * them verbatim — never parse or construct one.
78
+ */
79
+ variantIds: VariantId[];
47
80
  }
48
81
  /** The resolved jurisdiction the verdicts were evaluated against. */
49
82
  export interface CodeAdvisoryJurisdiction {
@@ -101,7 +134,15 @@ export interface CodeRequirementThreshold {
101
134
  unit: string;
102
135
  } | null;
103
136
  }
104
- /** The accepted fact a verdict was decided from (null when the material carried none). */
137
+ /**
138
+ * The accepted fact a verdict was decided from — null when the material carried
139
+ * none, which is exactly the case that produces `verdict: "unknown"`.
140
+ *
141
+ * `confidence` and `sourceBasis` are the strength of the fact, not of the code
142
+ * reading. Quote them whenever you quote the badge: a `meets` resting on a
143
+ * `derived` value at low confidence is a materially different statement from one
144
+ * resting on a `tested` figure.
145
+ */
105
146
  export interface CodeRequirementEvidence {
106
147
  attributeId: OpenEnum<"fire_classifications" | "slip_resistance_dcof">;
107
148
  value: string;
@@ -114,7 +155,14 @@ export interface CodeRequirementVerdict {
114
155
  dimension: OpenEnum<"fire" | "floor" | "slip" | "decorative">;
115
156
  section: string;
116
157
  requirement: CodeRequirementThreshold;
117
- /** `meets` / `shortfall` / `unknown` — `unknown` when no defensible fact maps onto `scale`. */
158
+ /**
159
+ * What may be said about this material against this requirement:
160
+ * - `meets` — a defensible fact clears the threshold. Advisory, still not a
161
+ * determination.
162
+ * - `shortfall` — a defensible fact FAILS to clear it. A real negative.
163
+ * - `unknown` — no defensible fact maps onto `scale`. NOT a failure and NOT a
164
+ * pass: "no comparable rating on file". Never present it as either.
165
+ */
118
166
  verdict: OpenEnum<"meets" | "shortfall" | "unknown">;
119
167
  evidence: CodeRequirementEvidence | null;
120
168
  /** The AHJ advisory caveat — model code as adopted, never a legal determination. */
@@ -126,6 +174,16 @@ export interface CodeRequirementVerdict {
126
174
  * `not_applicable` (nothing applied) is deliberately distinct from `meets`.
127
175
  */
128
176
  export interface CodeAdvisorySummary {
177
+ /**
178
+ * The rolled-up card, and what each token licenses:
179
+ * - `meets` — every applicable requirement was `meets`. Advisory pass.
180
+ * - `shortfall` — at least one requirement failed on real evidence. Blocking.
181
+ * - `unverified` — nothing failed, but something is `unknown`. COMMON, and
182
+ * never a pass: it means "no comparable rating is on file". Show it as an
183
+ * open question, not a green tick.
184
+ * - `not_applicable` — the space imposes nothing this material answers for.
185
+ * Deliberately distinct from `meets`: it is silence, not approval.
186
+ */
129
187
  status: OpenEnum<"meets" | "shortfall" | "unverified" | "not_applicable">;
130
188
  /** One deterministic sentence naming what failed, e.g. `"Falls short on fire for A-2 corridors."`. */
131
189
  headline: string;
@@ -134,7 +192,8 @@ export interface CodeAdvisorySummary {
134
192
  }
135
193
  /** Per-variant advisory outcome, in input order. */
136
194
  export interface CodeAdvisoryVariantResult {
137
- variantId: string;
195
+ /** Opaque — echoed verbatim from `variantIds`, in that position. */
196
+ variantId: VariantId;
138
197
  /** `evaluated` when the id resolved; `unknown_variant` (with `requirements: []`) when it did not. */
139
198
  state: OpenEnum<"evaluated" | "unknown_variant">;
140
199
  /** What this material is and what it was scored against. Null only for `state: "unknown_variant"`. */
@@ -168,7 +227,14 @@ export interface ResolvedCodeContext {
168
227
  /** The `ms_room` term the axes were correlated from, or null. */
169
228
  roomType: string | null;
170
229
  }
171
- /** How far a jurisdiction's rows have been reviewed against that AHJ's own tables. */
230
+ /**
231
+ * How far a jurisdiction's rows have been reviewed against that AHJ's own tables.
232
+ *
233
+ * - `thorough` — reviewed against this jurisdiction's own adopted tables.
234
+ * - `model_baseline` — UNAMENDED model IBC stood in, because that AHJ's own
235
+ * amendments have not been reviewed. Local amendments may be stricter, so
236
+ * say "per model IBC" rather than naming the jurisdiction as the authority.
237
+ */
172
238
  export type CodeCoverageTier = OpenEnum<"thorough" | "model_baseline">;
173
239
  export interface CodeJurisdictionSummary {
174
240
  id: string;
@@ -17,6 +17,20 @@
17
17
  * materialized view refreshed on a schedule, not on write, so a caller can and
18
18
  * should check how much of the corpus the answer actually covers before
19
19
  * treating it as complete. The two SEARCH responses are live and uncached.
20
+ *
21
+ * ROUTING OUT OF THIS NAMESPACE. Colour is a lens, and these are not the only
22
+ * doors into it:
23
+ * - ONE colour → `mg.byColour({ hex })`. This namespace's `palette` is for N
24
+ * colours at stated proportions, which is a different ranking, not a
25
+ * convenience wrapper.
26
+ * - A colour NAME rather than a hex → `mg.resolveColour(name)` first.
27
+ * - Colour WORDS (families, moods, undertones) → `mg.colourFacets()`.
28
+ * - Walking the corpus by lightness/vividness → `mg.browse({ sort })`.
29
+ * - Colour as one signal among words, a seed and a mood → `mg.search(...)`.
30
+ *
31
+ * And the honesty rule the whole lens rests on: every colour here is DERIVED
32
+ * FROM PRODUCT IMAGERY, not declared by the manufacturer. Report visual
33
+ * likeness, never a stated colour specification.
20
34
  */
21
35
  import type { CategoryColourProfilesResponse, ColourProfilesParams, ColourStatsResponse } from "./colour-aggregate-types.js";
22
36
  import type { QueryParams } from "./http-transport.js";
@@ -31,8 +45,14 @@ export declare class ColoursClient {
31
45
  #private;
32
46
  constructor(transport: ColoursTransport);
33
47
  /**
34
- * The representative colour palette of 1–8 categories in ONE call
35
- * (GET `/api/v1/colours/profiles`).
48
+ * THE QUESTION: "what colours does this CATEGORY come in?" — the representative
49
+ * palette of 1–8 categories in ONE call (GET `/api/v1/colours/profiles`).
50
+ *
51
+ * An aggregate over a category, not a list of materials: use `mg.byColour()` or
52
+ * `mg.search()` when you want the materials themselves, and
53
+ * {@link ColoursClient.stats} for coverage across the whole corpus rather than
54
+ * one category's palette. Check `basis` — this reads a scheduled materialized
55
+ * view, so it can lag the catalog.
36
56
  *
37
57
  * `groupBy: "group"` takes product-type GROUP keys and merges every leaf type
38
58
  * under a group into one histogram BEFORE ranking — a group's palette, not the
@@ -54,6 +74,14 @@ export declare class ColoursClient {
54
74
  */
55
75
  profiles(params: ColourProfilesParams, signal?: AbortSignal): Promise<CategoryColourProfilesResponse>;
56
76
  /**
77
+ * THE QUESTION: "how much colour does the graph hold AT ALL?" — corpus-level
78
+ * coverage, before you trust any colour answer.
79
+ *
80
+ * The sanity check under every other colour call. Use
81
+ * {@link ColoursClient.profiles} for one category's palette and
82
+ * `mg.colourFacets()` for the vocabulary. `basis.omissions` lists what this
83
+ * call deliberately does not answer — read it rather than assuming silence.
84
+ *
57
85
  * Corpus colour coverage (GET `/api/v1/colours/stats`): how much of the graph
58
86
  * carries colour at all, the split across the canonical colour families, and
59
87
  * coverage per product-type group. `basis` states the source and freshness of
@@ -62,8 +90,12 @@ export declare class ColoursClient {
62
90
  */
63
91
  stats(signal?: AbortSignal): Promise<ColourStatsResponse>;
64
92
  /**
65
- * Find materials that go with a WHOLE PALETTE — N colours at once, not one
66
- * colour repeated (POST `/api/v1/search/palettes`).
93
+ * THE QUESTION: "what goes with this WHOLE PALETTE?" — N colours at once, at
94
+ * stated proportions, not one colour repeated (POST `/api/v1/search/palettes`).
95
+ *
96
+ * Use `mg.byColour()` for a single colour, {@link ColoursClient.gradient} when
97
+ * the colours form an ordered TRANSITION rather than a set, and
98
+ * `mg.coordinate()` when the scheme is made of materials rather than colours.
67
99
  *
68
100
  * Each colour scores `sim × prop`: `sim` is OKLab closeness on the same
69
101
  * mapping `byColour` ranks with, and `prop` rewards a candidate whose own
@@ -90,8 +122,11 @@ export declare class ColoursClient {
90
122
  */
91
123
  palette(input: SearchPaletteInput, signal?: AbortSignal): Promise<SearchPaletteResponse>;
92
124
  /**
93
- * Find materials that move ALONG a colour transition — "cream to rust", in
94
- * order (POST `/api/v1/search/gradients`).
125
+ * THE QUESTION: "what moves from this colour to that one?" — materials that
126
+ * travel ALONG a transition, in order (POST `/api/v1/search/gradients`).
127
+ *
128
+ * Order is the whole point: use {@link ColoursClient.palette} when the colours
129
+ * are a set rather than a path, and `mg.byColour()` for a single colour.
95
130
  *
96
131
  * Not a palette: the score is multiplied by `coverage`, the share of the path
97
132
  * the candidate actually spans, so a material owning only one end ranks below
package/colours-client.js CHANGED
@@ -4,8 +4,14 @@ export class ColoursClient {
4
4
  this.#transport = transport;
5
5
  }
6
6
  /**
7
- * The representative colour palette of 1–8 categories in ONE call
8
- * (GET `/api/v1/colours/profiles`).
7
+ * THE QUESTION: "what colours does this CATEGORY come in?" — the representative
8
+ * palette of 1–8 categories in ONE call (GET `/api/v1/colours/profiles`).
9
+ *
10
+ * An aggregate over a category, not a list of materials: use `mg.byColour()` or
11
+ * `mg.search()` when you want the materials themselves, and
12
+ * {@link ColoursClient.stats} for coverage across the whole corpus rather than
13
+ * one category's palette. Check `basis` — this reads a scheduled materialized
14
+ * view, so it can lag the catalog.
9
15
  *
10
16
  * `groupBy: "group"` takes product-type GROUP keys and merges every leaf type
11
17
  * under a group into one histogram BEFORE ranking — a group's palette, not the
@@ -34,6 +40,14 @@ export class ColoursClient {
34
40
  }, signal);
35
41
  }
36
42
  /**
43
+ * THE QUESTION: "how much colour does the graph hold AT ALL?" — corpus-level
44
+ * coverage, before you trust any colour answer.
45
+ *
46
+ * The sanity check under every other colour call. Use
47
+ * {@link ColoursClient.profiles} for one category's palette and
48
+ * `mg.colourFacets()` for the vocabulary. `basis.omissions` lists what this
49
+ * call deliberately does not answer — read it rather than assuming silence.
50
+ *
37
51
  * Corpus colour coverage (GET `/api/v1/colours/stats`): how much of the graph
38
52
  * carries colour at all, the split across the canonical colour families, and
39
53
  * coverage per product-type group. `basis` states the source and freshness of
@@ -44,8 +58,12 @@ export class ColoursClient {
44
58
  return this.#transport.get("get_colour_stats", undefined, signal);
45
59
  }
46
60
  /**
47
- * Find materials that go with a WHOLE PALETTE — N colours at once, not one
48
- * colour repeated (POST `/api/v1/search/palettes`).
61
+ * THE QUESTION: "what goes with this WHOLE PALETTE?" — N colours at once, at
62
+ * stated proportions, not one colour repeated (POST `/api/v1/search/palettes`).
63
+ *
64
+ * Use `mg.byColour()` for a single colour, {@link ColoursClient.gradient} when
65
+ * the colours form an ordered TRANSITION rather than a set, and
66
+ * `mg.coordinate()` when the scheme is made of materials rather than colours.
49
67
  *
50
68
  * Each colour scores `sim × prop`: `sim` is OKLab closeness on the same
51
69
  * mapping `byColour` ranks with, and `prop` rewards a candidate whose own
@@ -74,8 +92,11 @@ export class ColoursClient {
74
92
  return this.#transport.post("search_by_palette", input, signal);
75
93
  }
76
94
  /**
77
- * Find materials that move ALONG a colour transition — "cream to rust", in
78
- * order (POST `/api/v1/search/gradients`).
95
+ * THE QUESTION: "what moves from this colour to that one?" — materials that
96
+ * travel ALONG a transition, in order (POST `/api/v1/search/gradients`).
97
+ *
98
+ * Order is the whole point: use {@link ColoursClient.palette} when the colours
99
+ * are a set rather than a path, and `mg.byColour()` for a single colour.
79
100
  *
80
101
  * Not a palette: the score is multiplied by `coverage`, the share of the path
81
102
  * the candidate actually spans, so a material owning only one end ranks below
@@ -12,6 +12,7 @@
12
12
  * TOLERANT-PARSING CONTRACT: response enums are widened with {@link OpenEnum}
13
13
  * so a new server value still type-checks; objects are read structurally.
14
14
  */
15
+ import type { RunId, SchemeId, SlotId, VariantId } from "./identifiers.js";
15
16
  import type { OpenEnum } from "./types.js";
16
17
  /**
17
18
  * The four v1 variation axes. Request-side, so this is a STRICT union (the
@@ -25,21 +26,22 @@ export type VaryAxis = "palette" | "sustainability" | "brandDiversity" | "priceT
25
26
  export declare const VARY_AXES: readonly VaryAxis[];
26
27
  /** Pin one slot to a specific variant so vary re-derives only the rest. */
27
28
  export interface SchemeLock {
28
- slotId: string;
29
- /** Must equal that slot's CURRENT pick in the source scheme. */
30
- variantId: string;
29
+ slotId: SlotId;
30
+ /** Must equal that slot's CURRENT pick in the source scheme. Opaque. */
31
+ variantId: VariantId;
31
32
  }
32
33
  /** Candidate exclusions applied on top of the automatic source-pick exclusion. */
33
34
  export interface SchemeExclusions {
34
- variantIds?: string[];
35
+ /** Opaque variant ids to keep out of every slot. */
36
+ variantIds?: VariantId[];
35
37
  brandSlugs?: string[];
36
38
  }
37
39
  /** Body for `POST /api/v1/schemes/vary` ({@link MaterialGraphClient.varyScheme}). */
38
40
  export interface VarySchemesRequest {
39
- /** The succeeded `compose-material-schemes` Run to vary from. */
40
- runId: string;
41
+ /** The succeeded `compose-material-schemes` Run to vary from. Opaque. */
42
+ runId: RunId;
41
43
  /** Which scheme in the Run is option A. Defaults to the Run's FIRST scheme. */
42
- schemeId?: string;
44
+ schemeId?: SchemeId;
43
45
  axis: VaryAxis;
44
46
  /** Slots kept pinned to their source pick; only unlocked slots re-derive. */
45
47
  locks?: SchemeLock[];
@@ -59,8 +61,8 @@ export interface ComposeAnchorInput {
59
61
  element: SlotElement;
60
62
  /** Optional named space to scope the anchor to (matched by name/slug). */
61
63
  spaceName?: string;
62
- /** An exact catalog variant id. Mutually exclusive with `query`. */
63
- variantId?: string;
64
+ /** An exact catalog variant id. Opaque. Mutually exclusive with `query`. */
65
+ variantId?: VariantId;
64
66
  /** Free-text "Brand Product Colour". Mutually exclusive with `variantId`. */
65
67
  query?: string;
66
68
  /** Display name for slot naming and warnings. */
@@ -92,6 +94,7 @@ export interface CompliancePreset {
92
94
  * every candidate but never exclude one.
93
95
  */
94
96
  export interface ComposeMaterialSchemesInput {
97
+ /** The uploaded source-material batch to compose from. Opaque. */
95
98
  batchId: string;
96
99
  count?: number;
97
100
  seed?: number;
@@ -137,12 +140,13 @@ export interface CompositionMatch {
137
140
  }
138
141
  /** One slot's chosen material plus the reason it was picked. */
139
142
  export interface CompositionPick {
140
- slotId: string;
143
+ slotId: SlotId;
141
144
  slotName: string;
142
145
  spaceId?: string | null;
143
146
  element?: string | null;
144
147
  material: {
145
- variantId: string;
148
+ /** Opaque — round-trip verbatim into the material lenses. */
149
+ variantId: VariantId;
146
150
  brand: string;
147
151
  product: string;
148
152
  variant: string;
@@ -171,12 +175,16 @@ export interface CompositionObjective {
171
175
  }
172
176
  /** A hydrated scheme: one pick per slot, plus its objective breakdown. */
173
177
  export interface CompositionScheme {
174
- schemeId: string;
178
+ schemeId: SchemeId;
175
179
  title: string;
176
180
  picks: CompositionPick[];
177
181
  objective: CompositionObjective;
178
182
  }
179
- /** How far short of the request the derivation fell, with honest reasons. */
183
+ /**
184
+ * How far short of the request the derivation fell, with honest reasons.
185
+ * `produced < requested` is a real answer, not an error — `reasons` says why,
186
+ * and a null shortfall means nothing fell short.
187
+ */
180
188
  export interface CompositionShortfall {
181
189
  requested: number;
182
190
  produced: number;
@@ -185,9 +193,9 @@ export interface CompositionShortfall {
185
193
  /** The axis + source provenance stamped onto every varied scheme. */
186
194
  export interface VaryProvenance {
187
195
  axis: VaryAxis;
188
- sourceRunId: string;
189
- sourceSchemeId: string;
190
- lockedSlotIds: string[];
196
+ sourceRunId: RunId;
197
+ sourceSchemeId: SchemeId;
198
+ lockedSlotIds: SlotId[];
191
199
  /**
192
200
  * Human-readable notes recording exactly what the axis did — counts, not
193
201
  * adjectives (e.g. "brandDiversity: hard-excluded 2 brand(s) appearing ≥2×").
@@ -330,11 +338,12 @@ export interface CompositionAnsweredPayload {
330
338
  * slot: how many were considered and the top picks (≤5) hydrated for identity.
331
339
  */
332
340
  export interface SlotPoolEventPayload {
333
- slotId: string;
341
+ slotId: SlotId;
334
342
  slotName: string;
343
+ /** How many candidates were WEIGHED, not how many qualified. */
335
344
  considered: number;
336
345
  top: Array<{
337
- variantId: string;
346
+ variantId: VariantId;
338
347
  brand: string;
339
348
  product: string;
340
349
  imageUrl: string | null;
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Wire types for BATCH candidate demand enrichment — `POST /api/v1/demand/batch`.
3
+ *
4
+ * Split out of `types.ts` for the repository's source-file size budget, the same
5
+ * reason `material-types.ts` and `catalog-discovery-types.ts` sit beside it;
6
+ * re-exported from `types.ts`, so importers see no difference.
7
+ *
8
+ * THE LENS: this answers "how much is each of these already-chosen materials
9
+ * actually specified?" — enrichment over a bounded id list you already hold. It
10
+ * never resolves, ranks, reorders or discovers. Use
11
+ * {@link MaterialGraphClient.demand} for one material named in free text,
12
+ * {@link MaterialGraphClient.pairings} for what goes WITH a material, and
13
+ * {@link MaterialGraphClient.search} to find candidates in the first place.
14
+ */
15
+ import type { VariantId } from "./identifiers.js";
16
+ import type { DemandMomentum, DemandQuarter, DemandSignal, OpenEnum } from "./types.js";
17
+ /**
18
+ * Body for `POST /api/v1/demand/batch` — enrich 1–48 already-selected candidate
19
+ * ids with their real specification demand, in input order and cardinality.
20
+ */
21
+ export interface DemandBatchInput {
22
+ /** 1–48 canonical variant ids. Order AND cardinality are preserved — duplicates are echoed, never collapsed. */
23
+ variantIds: VariantId[];
24
+ /** The behavioural signal to read demand from. Defaults to `sampled` (order co-occurrence) server-side. */
25
+ signal?: DemandSignal;
26
+ /** Optional declared context (e.g. `{ projectType: "hospitality" }`) — echoed for provenance, never used to rank or filter. */
27
+ context?: Record<string, string>;
28
+ }
29
+ /** Headline population behind the metric — this variant's own counts. */
30
+ export interface DemandBatchPopulation {
31
+ specCount: number;
32
+ projectCount: number;
33
+ customerCount: number;
34
+ }
35
+ /** The consideration→specification funnel (project-grain; `exclusionRate` is annotate-only). */
36
+ export interface DemandBatchFunnel {
37
+ consideredProjects: number;
38
+ specifiedProjects: number;
39
+ excludedCount: number;
40
+ exclusionRate: number;
41
+ }
42
+ /** Per-result guardrail status — present on `internal` (`mg:admin`) results only. */
43
+ export interface DemandBatchEvidence {
44
+ belowPrivacyFloor: boolean;
45
+ belowQualityFloor: boolean;
46
+ suppressedForPublic: boolean;
47
+ }
48
+ /** A variant that carries a usable demand signal. */
49
+ export interface DemandBatchAvailableResult {
50
+ variantId: VariantId;
51
+ state: "available";
52
+ population: DemandBatchPopulation;
53
+ /** Exact per-quarter counts (sparse — zero-spec quarters are absent). */
54
+ intervals: DemandQuarter[];
55
+ momentum: DemandMomentum;
56
+ /** The funnel; null when the signal is not `sampled` or no `considered` row/session exists. */
57
+ funnel: DemandBatchFunnel | null;
58
+ /** Exact guardrail evidence for `internal` callers; null for public. */
59
+ evidence: DemandBatchEvidence | null;
60
+ }
61
+ /**
62
+ * A variant with a stats row that is below the public quality floor. We HAVE a
63
+ * reading and are declining to publish it — not a claim that nobody specifies it.
64
+ */
65
+ export interface DemandBatchInsufficientResult {
66
+ variantId: VariantId;
67
+ state: "insufficient_data";
68
+ }
69
+ /**
70
+ * A variant with no stats — indistinguishable from a privacy-suppressed one for
71
+ * public callers, and therefore never reportable as "nobody specifies this".
72
+ */
73
+ export interface DemandBatchNoSignalResult {
74
+ variantId: VariantId;
75
+ state: "no_signal";
76
+ }
77
+ /** A requested id that resolves to no real variant. */
78
+ export interface DemandBatchUnknownResult {
79
+ variantId: VariantId;
80
+ state: "unknown_variant";
81
+ }
82
+ /**
83
+ * One discriminated result per input id, in input order and cardinality.
84
+ *
85
+ * ABSENT IS NOT ZERO: only `available` carries numbers. The other three states
86
+ * each mean "no number to report", for three different reasons, and none of them
87
+ * licenses "this material is not specified" — see each member.
88
+ */
89
+ export type DemandBatchResult = DemandBatchAvailableResult | DemandBatchInsufficientResult | DemandBatchNoSignalResult | DemandBatchUnknownResult;
90
+ /** The `POST /api/v1/demand/batch` envelope. */
91
+ export interface DemandBatchResponse {
92
+ /** The completed ingestion-session id these counts were mined in (the dataset generation). */
93
+ generationId: string;
94
+ metric: "specification_demand";
95
+ /** The metric universe — the window and signal the counts are scoped to. */
96
+ universe: {
97
+ window: "trailing_36mo";
98
+ signal: DemandSignal;
99
+ description: string;
100
+ };
101
+ /** The guardrail status under which `results` were projected. */
102
+ guardrail: {
103
+ audience: OpenEnum<"public" | "internal">;
104
+ minProjectCount: number;
105
+ minCustomerCount: number;
106
+ };
107
+ /** Outcome tallies over `results` — never a denominator for the metric. */
108
+ coverage: {
109
+ requested: number;
110
+ available: number;
111
+ insufficientData: number;
112
+ noSignal: number;
113
+ unknownVariant: number;
114
+ };
115
+ /** The echoed declared context, or null when none was supplied. */
116
+ context: Record<string, string> | null;
117
+ results: DemandBatchResult[];
118
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Wire types for the SINGLE-SEED demand reads — `GET /api/v1/demand` and
3
+ * `POST /api/v1/pairings/coordinate`.
4
+ *
5
+ * Split out of `types.ts` for the repository's source-file size budget, the same
6
+ * reason `material-types.ts` and `demand-batch-types.ts` sit beside it; both are
7
+ * re-exported from `types.ts`, so importers see no difference.
8
+ *
9
+ * THE LENS: demand answers "how much is this specified, and what travels with
10
+ * it?" from real order behaviour. It is not a search and not a recommendation
11
+ * engine — every number is bounded by the `trailing_36mo` window and by the
12
+ * {@link DemandSignal} it was read under, and a missing number means UNREAD, not
13
+ * zero.
14
+ */
15
+ import type { ProductId, VariantId } from "./identifiers.js";
16
+ import type { CompanionDemand, DemandMomentum, DemandQuarter, OpenEnum } from "./types.js";
17
+ /** A resolved seed material (identity block shared by the demand reads). */
18
+ export interface DemandSeed {
19
+ /** Opaque — round-trip verbatim. */
20
+ variantId: VariantId;
21
+ brand: string;
22
+ product: string;
23
+ color: string | null;
24
+ variantName: string | null;
25
+ imageUrl: string | null;
26
+ brandSlug: string;
27
+ productId: ProductId;
28
+ }
29
+ /** Headline demand stats for a seed: counts, quarterly series, momentum. */
30
+ export interface MaterialDemandStats {
31
+ specCount: number;
32
+ projectCount: number;
33
+ customerCount: number;
34
+ /** Sparse — a quarter with no activity is ABSENT, not a zero row. */
35
+ byQuarter: DemandQuarter[];
36
+ momentum: DemandMomentum;
37
+ }
38
+ /**
39
+ * `getMaterialDemand` result, passed through verbatim by the `/demand` route.
40
+ *
41
+ * `demand: null` means NO READING — the seed did not resolve, or nothing cleared
42
+ * the publication floor. It is never a reading of zero, and never licenses
43
+ * "nobody specifies this".
44
+ */
45
+ export interface MaterialDemandResult {
46
+ found: boolean;
47
+ query: string;
48
+ seed: DemandSeed | null;
49
+ /** The only window v1 serves. Activity outside it is unread, not absent. */
50
+ window: "trailing_36mo";
51
+ demand: MaterialDemandStats | null;
52
+ }
53
+ /**
54
+ * `getCoSpecifiedMaterials` result, passed through verbatim. The `companions`
55
+ * list (materials specified alongside the seed) is included but left loosely
56
+ * typed — there is no captured fixture to pin its full shape.
57
+ *
58
+ * `resolution` is the honest report on the free-text seed: `ambiguous` means the
59
+ * phrase matched several materials and `seedAlternatives` holds them — pick one
60
+ * and re-ask by `variantId` rather than treating the first as the answer.
61
+ */
62
+ export interface CoSpecifiedResult {
63
+ found: boolean;
64
+ resolution: OpenEnum<"resolved" | "ambiguous" | "not_found">;
65
+ query: string;
66
+ seed: DemandSeed | null;
67
+ seedCategory: string | null;
68
+ seedAlternatives: DemandSeed[];
69
+ companions?: unknown[];
70
+ }
71
+ /** The `/api/v1/demand` envelope — both domain reads side by side, verbatim. */
72
+ export interface DemandResponse {
73
+ query: string;
74
+ demand: MaterialDemandResult;
75
+ cospec: CoSpecifiedResult;
76
+ }
77
+ /** Seed a demand read by EXACTLY ONE of `variantId` or `material`. */
78
+ export interface DemandParams {
79
+ /** An exact variant id. Opaque — pass through verbatim. */
80
+ variantId?: VariantId;
81
+ /** Free text ("Brand Product Colour"), resolved server-side. May be ambiguous. */
82
+ material?: string;
83
+ }
84
+ export interface CoordinateSeed extends DemandSeed {
85
+ /** The input reference this seed resolved from. */
86
+ resolvedFrom: string;
87
+ }
88
+ export interface CoordinateCompanion {
89
+ /** Opaque — round-trip verbatim. */
90
+ variantId: VariantId;
91
+ brand: string;
92
+ product: string;
93
+ color: string | null;
94
+ variantName: string | null;
95
+ imageUrl: string | null;
96
+ brandSlug: string;
97
+ productId: ProductId;
98
+ totalLift: number;
99
+ liftLabel: string;
100
+ seedMatchCount: number;
101
+ seedContributions: Array<{
102
+ seedVariantId: VariantId;
103
+ lift: number;
104
+ }>;
105
+ demand: CompanionDemand | null;
106
+ swatches: Array<{
107
+ hex: string;
108
+ proportion: number;
109
+ }>;
110
+ /** Annotate-only appearance similarity — never reorders companions in v1. */
111
+ colourFit: number | null;
112
+ }
113
+ /**
114
+ * The `POST /api/v1/pairings/coordinate` envelope. `unresolved` lists the input
115
+ * references that matched nothing — read it before presenting `companions` as
116
+ * "what goes with your palette", because a companion set derived from two of
117
+ * four seeds is a different claim.
118
+ */
119
+ export interface CoordinateResponse {
120
+ seeds: CoordinateSeed[];
121
+ /** Input references that resolved to no material. Never silently dropped. */
122
+ unresolved: string[];
123
+ window: "trailing_36mo";
124
+ companions: CoordinateCompanion[];
125
+ }
126
+ /** Body for `POST /api/v1/pairings/coordinate`. */
127
+ export interface CoordinateInput {
128
+ /** Free-text material references, resolved server-side; misses land in `unresolved`. */
129
+ materials?: string[];
130
+ /** Exact variant ids. Opaque — pass through verbatim. */
131
+ variantIds?: VariantId[];
132
+ projectType?: string;
133
+ limit?: number;
134
+ }
@@ -0,0 +1 @@
1
+ export {};