menuconform 0.1.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.
@@ -0,0 +1,413 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://raw.githubusercontent.com/amitkssolanki/menuconform/main/schema/menu_ir.schema.json",
4
+ "title": "menuconform Menu IR",
5
+ "description": "Intermediate representation of a restaurant menu for UCP food-ordering conformance checking. Design principle: this schema validates SHAPE AND TYPES only. It deliberately admits semantically broken menus (negative prices, min_select > max_select, dangling references) so the rule engine can load them and report findings. Referential integrity, ID uniqueness, and the modifier-group nesting depth limit (error above 6, warn above 4) are graph properties checked by STRUCT- rules, not by this schema. v0.2 informed by field study of Uber Eats, DoorDash, Grubhub, and Checkmate menu models (2026-08-30).",
6
+ "type": "object",
7
+ "required": ["ir_version", "name", "currency", "timezone", "categories", "items", "modifier_groups", "modifiers"],
8
+ "properties": {
9
+ "ir_version": {
10
+ "const": "0.2",
11
+ "description": "IR schema version this document conforms to."
12
+ },
13
+ "name": {
14
+ "type": "string",
15
+ "description": "Display name of the menu (e.g. 'Main Menu', 'Breakfast'). Importers may synthesize one if the source has none. A source location with multiple daypart menus becomes multiple IR documents."
16
+ },
17
+ "currency": {
18
+ "type": "string",
19
+ "pattern": "^[A-Z]{3}$",
20
+ "description": "ISO 4217 currency code. All prices in this document are integers in this currency's minor units (USD: cents). Deliberately required in-document even though delivery platforms carry it out-of-band."
21
+ },
22
+ "timezone": {
23
+ "type": "string",
24
+ "minLength": 1,
25
+ "description": "IANA timezone name (e.g. 'America/Chicago') that all availability windows are interpreted in. AVAIL- rules validate that it resolves. Deliberately required in-document even though delivery platforms carry it out-of-band."
26
+ },
27
+ "locale": {
28
+ "type": "string",
29
+ "pattern": "^[a-z]{2,3}(-[A-Z]{2})?$",
30
+ "description": "BCP 47 language tag for names and descriptions. Single-locale in IR 0.2. Defaults to en-US.",
31
+ "default": "en-US"
32
+ },
33
+ "availability": {
34
+ "$ref": "#/$defs/availabilityWindows",
35
+ "description": "Menu-level service windows. Categories and items may narrow (but conceptually not widen) these; AVAIL- rules check for contradictions."
36
+ },
37
+ "special_hours": {
38
+ "type": "array",
39
+ "description": "Date-specific exceptions layered over the weekly windows (holidays, one-off closures, extended hours). On a listed date these replace the weekly windows entirely for the whole menu.",
40
+ "items": { "$ref": "#/$defs/specialHours" }
41
+ },
42
+ "source": {
43
+ "type": "object",
44
+ "description": "Provenance of this IR document. Informational; surfaced in reports.",
45
+ "properties": {
46
+ "system": { "type": "string", "description": "Generic source system label (e.g. 'generic-json', 'generic-csv'). No vendor-proprietary identifiers in public fixtures." },
47
+ "importer": { "type": "string", "description": "Name/version of the importer that produced this document." },
48
+ "exported_at": { "type": "string", "format": "date-time" }
49
+ },
50
+ "additionalProperties": false
51
+ },
52
+ "categories": {
53
+ "type": "array",
54
+ "items": { "$ref": "#/$defs/category" }
55
+ },
56
+ "items": {
57
+ "type": "array",
58
+ "items": { "$ref": "#/$defs/item" }
59
+ },
60
+ "modifier_groups": {
61
+ "type": "array",
62
+ "items": { "$ref": "#/$defs/modifierGroup" }
63
+ },
64
+ "modifiers": {
65
+ "type": "array",
66
+ "items": { "$ref": "#/$defs/modifier" }
67
+ }
68
+ },
69
+ "patternProperties": { "^x_": {} },
70
+ "additionalProperties": false,
71
+
72
+ "$defs": {
73
+ "id": {
74
+ "type": "string",
75
+ "minLength": 1,
76
+ "description": "Opaque identifier, unique within its collection (uniqueness checked by STRUCT- rules, not schema). Deliberately unconstrained beyond non-empty: POS exports produce numeric strings, GUIDs, and worse."
77
+ },
78
+ "minorUnits": {
79
+ "type": "integer",
80
+ "description": "Money as an integer in the menu currency's minor units. Negative values pass schema; PRICE- rules decide whether they are legitimate (e.g. a discounting modifier) or a finding."
81
+ },
82
+ "idArray": {
83
+ "type": "array",
84
+ "items": { "$ref": "#/$defs/id" }
85
+ },
86
+ "isoDate": {
87
+ "type": "string",
88
+ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$",
89
+ "description": "Calendar date YYYY-MM-DD in the menu timezone."
90
+ },
91
+
92
+ "availabilityWindows": {
93
+ "type": "array",
94
+ "description": "A set of recurring weekly service windows. Empty array means 'never available' (a finding, but valid shape). Absent means 'inherit from parent scope' (item -> category -> menu -> always).",
95
+ "items": { "$ref": "#/$defs/availabilityWindow" }
96
+ },
97
+ "availabilityWindow": {
98
+ "type": "object",
99
+ "required": ["start", "end"],
100
+ "properties": {
101
+ "days": {
102
+ "type": "array",
103
+ "items": { "enum": ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] },
104
+ "uniqueItems": true,
105
+ "description": "Days this window applies to. Absent means all seven days."
106
+ },
107
+ "start": { "$ref": "#/$defs/timeOfDay" },
108
+ "end": {
109
+ "$ref": "#/$defs/timeOfDay",
110
+ "description": "End time. '24:00' is accepted as end-of-day. end < start means the window crosses midnight into the following day."
111
+ },
112
+ "start_date": {
113
+ "$ref": "#/$defs/isoDate",
114
+ "description": "First calendar date this window is in effect (inclusive). Absent = no lower bound. Supports seasonal/LTO windows (all four studied platforms have date-bounded availability)."
115
+ },
116
+ "end_date": {
117
+ "$ref": "#/$defs/isoDate",
118
+ "description": "Last calendar date this window is in effect (inclusive). Absent = no upper bound."
119
+ }
120
+ },
121
+ "patternProperties": { "^x_": {} },
122
+ "additionalProperties": false
123
+ },
124
+ "specialHours": {
125
+ "type": "object",
126
+ "required": ["date"],
127
+ "properties": {
128
+ "date": { "$ref": "#/$defs/isoDate" },
129
+ "closed": {
130
+ "type": "boolean",
131
+ "default": false,
132
+ "description": "true = closed on this date (all day, or between start/end if given). false = open per the start/end given, replacing the weekly windows."
133
+ },
134
+ "start": { "$ref": "#/$defs/timeOfDay" },
135
+ "end": { "$ref": "#/$defs/timeOfDay" }
136
+ },
137
+ "patternProperties": { "^x_": {} },
138
+ "additionalProperties": false
139
+ },
140
+ "timeOfDay": {
141
+ "type": "string",
142
+ "pattern": "^(([01][0-9]|2[0-3]):[0-5][0-9]|24:00)$",
143
+ "description": "Local wall-clock time HH:MM in the menu timezone."
144
+ },
145
+
146
+ "category": {
147
+ "type": "object",
148
+ "required": ["id", "name", "item_ids"],
149
+ "properties": {
150
+ "id": { "$ref": "#/$defs/id" },
151
+ "name": { "type": "string" },
152
+ "description": { "type": "string" },
153
+ "item_ids": {
154
+ "$ref": "#/$defs/idArray",
155
+ "description": "Ordered item references. An item may appear in multiple categories. Empty is valid shape (STRUCT- finding)."
156
+ },
157
+ "availability": { "$ref": "#/$defs/availabilityWindows" }
158
+ },
159
+ "patternProperties": { "^x_": {} },
160
+ "additionalProperties": false
161
+ },
162
+
163
+ "item": {
164
+ "type": "object",
165
+ "required": ["id", "name", "price"],
166
+ "properties": {
167
+ "id": { "$ref": "#/$defs/id" },
168
+ "name": { "type": "string" },
169
+ "description": { "type": "string" },
170
+ "price": {
171
+ "$ref": "#/$defs/minorUnits",
172
+ "description": "Base price, required and explicit. 0 is legitimate for items priced entirely through modifiers (e.g. pizza priced by size selection); PRICE-/CART- rules verify a positive total is reachable."
173
+ },
174
+ "active": {
175
+ "type": "boolean",
176
+ "default": true,
177
+ "description": "Menu-authored state: false = intentionally not orderable. Distinct from suspended_until (operational 86ing) — all four studied platforms separate these."
178
+ },
179
+ "suspended_until": {
180
+ "type": "string",
181
+ "format": "date-time",
182
+ "description": "Operationally 86'd until this instant (ISO 8601). A timestamp in the past means not suspended (idempotent auto-recovery, per Checkmate/Uber convention). Absent = not suspended."
183
+ },
184
+ "modifier_group_ids": {
185
+ "$ref": "#/$defs/idArray",
186
+ "description": "Modifier groups attached directly to the item (nesting depth 1)."
187
+ },
188
+ "slots": {
189
+ "type": "array",
190
+ "description": "Half/half (or n-way split) construct. Each slot is a named portion of the item carrying its own modifier groups. When present, slot_pricing is required. No studied platform has a first-class equivalent — exporters lower slots to nested-group conventions.",
191
+ "items": { "$ref": "#/$defs/slot" }
192
+ },
193
+ "slot_pricing": {
194
+ "enum": ["proportional", "full_price", "max_slot"],
195
+ "description": "How a priced modifier applied to a slot contributes to the item total. proportional: modifier price x slot fraction, rounded half-up per selection. full_price: full modifier price regardless of slot. max_slot: for each modifier group shared across slots, charge as if the most expensive slot's selections applied to the whole item. All three exist in the wild. Applied AFTER conditional-price resolution."
196
+ },
197
+ "availability": { "$ref": "#/$defs/availabilityWindows" },
198
+ "fulfillment_modes": {
199
+ "type": "array",
200
+ "items": { "enum": ["pickup", "delivery", "dine_in"] },
201
+ "uniqueItems": true,
202
+ "description": "Modes this item may be ordered through. Absent means all. Availability gating only — per-mode pricing is out of scope: importers materialize one IR document per priced channel (Checkmate precedent)."
203
+ },
204
+ "allergens": { "$ref": "#/$defs/allergenInfo" },
205
+ "dietary": { "$ref": "#/$defs/dietaryList" },
206
+ "min_age": {
207
+ "type": "integer",
208
+ "description": "Minimum purchaser age in years (21 = US alcohol, 18 = tobacco). Absent means unrestricted. AGE- rules key off this."
209
+ },
210
+ "calories": { "$ref": "#/$defs/calorieRange" },
211
+ "external_ids": { "$ref": "#/$defs/externalIds" }
212
+ },
213
+ "dependentRequired": {
214
+ "slots": ["slot_pricing"]
215
+ },
216
+ "patternProperties": { "^x_": {} },
217
+ "additionalProperties": false
218
+ },
219
+
220
+ "slot": {
221
+ "type": "object",
222
+ "required": ["id", "name", "fraction", "modifier_group_ids"],
223
+ "properties": {
224
+ "id": { "$ref": "#/$defs/id" },
225
+ "name": { "type": "string", "description": "e.g. 'Left Half', 'Right Half'." },
226
+ "fraction": {
227
+ "type": "number",
228
+ "description": "Portion of the whole item this slot represents (0.5 for a half). Used by proportional slot pricing. Rules check fractions sum to 1."
229
+ },
230
+ "modifier_group_ids": { "$ref": "#/$defs/idArray" }
231
+ },
232
+ "patternProperties": { "^x_": {} },
233
+ "additionalProperties": false
234
+ },
235
+
236
+ "modifierGroup": {
237
+ "type": "object",
238
+ "required": ["id", "name", "min_select", "max_select", "options"],
239
+ "properties": {
240
+ "id": { "$ref": "#/$defs/id" },
241
+ "name": { "type": "string" },
242
+ "min_select": {
243
+ "type": "integer",
244
+ "description": "Minimum number of DISTINCT options that must be selected (v0.2 redefinition — radio/checkbox semantics; all three major platforms constrain distinct-option counts and total units as separate axes). 0 = optional group. Negative passes schema; CONFLICT- rules flag it."
245
+ },
246
+ "max_select": {
247
+ "type": ["integer", "null"],
248
+ "description": "Maximum number of DISTINCT options selectable. null = unlimited. Required so importers must state it explicitly rather than us guessing a default."
249
+ },
250
+ "min_total_units": {
251
+ "type": "integer",
252
+ "description": "Minimum total selection units across all options (a quantity of 2 of one option counts as 2). Absent = unconstrained. Second constraint axis, alongside distinct-option min_select/max_select."
253
+ },
254
+ "max_total_units": {
255
+ "type": ["integer", "null"],
256
+ "description": "Maximum total selection units across all options. null = unlimited. Absent = unconstrained."
257
+ },
258
+ "included_quantity": {
259
+ "type": "integer",
260
+ "default": 0,
261
+ "description": "First N free within this group; selections beyond N are charged at their resolved option price. What N counts is set by included_counting; which selections go free is set by included_allocation. 0 = nothing included. Values exceeding the group's maxima or negative pass schema; CONFLICT- rules flag them."
262
+ },
263
+ "included_counting": {
264
+ "enum": ["units", "options"],
265
+ "default": "units",
266
+ "description": "What included_quantity counts: 'units' = total selection units (Uber charge_above style); 'options' = distinct options, all units of a free option are free (DoorDash num_free_options style)."
267
+ },
268
+ "included_allocation": {
269
+ "enum": ["cheapest_first", "most_expensive_first"],
270
+ "default": "cheapest_first",
271
+ "description": "Which selections the free allocation covers. Default cheapest_first — the only allocation order any studied platform documents (DoorDash). The cart solver depends on this being pinned."
272
+ },
273
+ "options": {
274
+ "type": "array",
275
+ "description": "The selectable options. Empty is valid shape; CONFLICT- rules flag a required group with no options.",
276
+ "items": { "$ref": "#/$defs/option" }
277
+ }
278
+ },
279
+ "patternProperties": { "^x_": {} },
280
+ "additionalProperties": false
281
+ },
282
+
283
+ "option": {
284
+ "type": "object",
285
+ "required": ["modifier_id"],
286
+ "properties": {
287
+ "modifier_id": { "$ref": "#/$defs/id" },
288
+ "price_override": {
289
+ "$ref": "#/$defs/minorUnits",
290
+ "description": "Overrides the modifier's base price within this group only. POS systems price the same modifier differently per group ('Cheese' free on a burger, 150 on fries). Precedence: matched conditional_prices > price_override > modifier.price."
291
+ },
292
+ "conditional_prices": {
293
+ "type": "array",
294
+ "description": "Price matrix keyed by a sibling selection: this option costs `price` when the option `when_modifier_id` is selected on the same item (topping-by-size, combo upsell priced by base combo size — Aloha-style price levels lower to this). Constraint enforced by CONFLICT- rules, not schema: every when_modifier_id in one option's list must reference options of a single max_select:1 group attached to the same item, so at most one entry can match. Unmatched -> fall back to price_override/base price. Slot pricing applies after resolution.",
295
+ "items": { "$ref": "#/$defs/conditionalPrice" }
296
+ },
297
+ "default_quantity": {
298
+ "type": "integer",
299
+ "default": 0,
300
+ "description": "Units of this option pre-selected when the item is added (replaces v0.1 boolean is_default; DoorDash recipes and Grubhub default_units have quantity defaults > 1). 0 = not a default. The cart solver's default cart starts from these."
301
+ },
302
+ "min_quantity": {
303
+ "type": "integer",
304
+ "default": 0,
305
+ "description": "Minimum units of this option IF it is selected at all (0 = no minimum)."
306
+ },
307
+ "max_quantity": {
308
+ "type": "integer",
309
+ "default": 1,
310
+ "description": "Maximum units of this option in one selection (extra-extra cheese). Each unit counts toward the group's total-unit constraints."
311
+ },
312
+ "child_modifier_group_ids": {
313
+ "$ref": "#/$defs/idArray",
314
+ "description": "Nested modifier groups presented when this option is selected. Attached at the option (link) level, not the modifier, because POS systems attach follow-up questions per context ('Fries' asks for size as a combo side but not a la carte). Depth counted from the item: item->group is 1. STRUCT- rules flag depth > 6 as error and depth > 4 as warn (DoorDash's canonical pizza pattern is 4 deep; Uber demonstrates 6; Grubhub documents 6)."
315
+ }
316
+ },
317
+ "patternProperties": { "^x_": {} },
318
+ "additionalProperties": false
319
+ },
320
+
321
+ "conditionalPrice": {
322
+ "type": "object",
323
+ "required": ["when_modifier_id", "price"],
324
+ "properties": {
325
+ "when_modifier_id": {
326
+ "$ref": "#/$defs/id",
327
+ "description": "The triggering option's modifier id in the single-select trigger group."
328
+ },
329
+ "price": { "$ref": "#/$defs/minorUnits" }
330
+ },
331
+ "patternProperties": { "^x_": {} },
332
+ "additionalProperties": false
333
+ },
334
+
335
+ "modifier": {
336
+ "type": "object",
337
+ "required": ["id", "name", "price"],
338
+ "properties": {
339
+ "id": { "$ref": "#/$defs/id" },
340
+ "name": { "type": "string" },
341
+ "description": { "type": "string" },
342
+ "price": {
343
+ "$ref": "#/$defs/minorUnits",
344
+ "description": "Base price adjustment, required and explicit (0 = free). Negative is a discounting modifier ('No cheese -50'); PRICE- rules decide if the resulting totals stay sane."
345
+ },
346
+ "active": {
347
+ "type": "boolean",
348
+ "default": true,
349
+ "description": "Menu-authored state; see item.active."
350
+ },
351
+ "suspended_until": {
352
+ "type": "string",
353
+ "format": "date-time",
354
+ "description": "Operationally 86'd until this instant; see item.suspended_until."
355
+ },
356
+ "allergens": { "$ref": "#/$defs/allergenInfo" },
357
+ "dietary": { "$ref": "#/$defs/dietaryList" },
358
+ "min_age": { "type": "integer" },
359
+ "calories": { "$ref": "#/$defs/calorieRange" },
360
+ "external_ids": { "$ref": "#/$defs/externalIds" }
361
+ },
362
+ "patternProperties": { "^x_": {} },
363
+ "additionalProperties": false
364
+ },
365
+
366
+ "allergenInfo": {
367
+ "type": "object",
368
+ "properties": {
369
+ "contains": { "$ref": "#/$defs/allergenList" },
370
+ "may_contain": { "$ref": "#/$defs/allergenList" },
371
+ "notes": { "type": "string", "description": "Free-text allergen statement from the source when it doesn't map to the enum." }
372
+ },
373
+ "patternProperties": { "^x_": {} },
374
+ "additionalProperties": false
375
+ },
376
+ "allergenList": {
377
+ "type": "array",
378
+ "uniqueItems": true,
379
+ "items": {
380
+ "enum": [
381
+ "milk", "eggs", "fish", "shellfish", "tree_nuts", "peanuts", "wheat", "soy", "sesame",
382
+ "gluten", "crustaceans", "molluscs", "celery", "mustard", "lupin", "sulphites"
383
+ ],
384
+ "description": "Union of the US FASTER Act big-9 and the EU-14 allergen vocabularies (Square's catalog standardized on EU-14; interop requires both). US declarations typically use the first nine; wheat vs gluten and shellfish vs crustaceans/molluscs are distinct entries because the regimes split them differently."
385
+ }
386
+ },
387
+ "dietaryList": {
388
+ "type": "array",
389
+ "uniqueItems": true,
390
+ "items": {
391
+ "enum": ["vegan", "vegetarian", "gluten_free"],
392
+ "description": "Dietary absence-claims (distinct from allergens, which are contains-claims). The intersection supported by all three major US delivery platforms."
393
+ }
394
+ },
395
+
396
+ "calorieRange": {
397
+ "type": "object",
398
+ "required": ["lower"],
399
+ "properties": {
400
+ "lower": { "type": "integer" },
401
+ "upper": { "type": "integer", "description": "Absent means exact value = lower." }
402
+ },
403
+ "additionalProperties": false,
404
+ "description": "kcal, as a range to match US menu-labeling practice for variable items."
405
+ },
406
+
407
+ "externalIds": {
408
+ "type": "object",
409
+ "description": "Source-system identifiers keyed by a neutral label (e.g. 'plu', 'sku', 'pos_id'). Vendor field mappings live in importer config, never here.",
410
+ "additionalProperties": { "type": "string" }
411
+ }
412
+ }
413
+ }
metadata ADDED
@@ -0,0 +1,86 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: menuconform
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Amit Solanki
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: json_schemer
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '2.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '2.0'
26
+ description: Checks whether an AI agent could build a valid, correctly priced, makeable
27
+ cart from a restaurant menu under the UCP Food Ordering catalog/cart model. Scored
28
+ report with per-rule findings and fix hints.
29
+ email:
30
+ - amitkssolanki@gmail.com
31
+ executables:
32
+ - menuconform
33
+ extensions: []
34
+ extra_rdoc_files: []
35
+ files:
36
+ - LICENSE
37
+ - README.md
38
+ - config/importers/ncr_menu.json
39
+ - exe/menuconform
40
+ - lib/menuconform.rb
41
+ - lib/menuconform/catalog.rb
42
+ - lib/menuconform/cli.rb
43
+ - lib/menuconform/engine.rb
44
+ - lib/menuconform/finding.rb
45
+ - lib/menuconform/importers.rb
46
+ - lib/menuconform/importers/base.rb
47
+ - lib/menuconform/importers/ncr_menu.rb
48
+ - lib/menuconform/menu.rb
49
+ - lib/menuconform/report.rb
50
+ - lib/menuconform/rules/age_rules.rb
51
+ - lib/menuconform/rules/allergen_rules.rb
52
+ - lib/menuconform/rules/avail_rules.rb
53
+ - lib/menuconform/rules/cart_rules.rb
54
+ - lib/menuconform/rules/conflict_rules.rb
55
+ - lib/menuconform/rules/name_rules.rb
56
+ - lib/menuconform/rules/price_rules.rb
57
+ - lib/menuconform/rules/struct_rules.rb
58
+ - lib/menuconform/scorer.rb
59
+ - lib/menuconform/solver.rb
60
+ - lib/menuconform/ucp_exporter.rb
61
+ - lib/menuconform/version.rb
62
+ - rules/catalog.json
63
+ - schema/menu_ir.schema.json
64
+ homepage: https://github.com/amitkssolanki/menuconform
65
+ licenses:
66
+ - Apache-2.0
67
+ metadata:
68
+ rubygems_mfa_required: 'true'
69
+ rdoc_options: []
70
+ require_paths:
71
+ - lib
72
+ required_ruby_version: !ruby/object:Gem::Requirement
73
+ requirements:
74
+ - - ">="
75
+ - !ruby/object:Gem::Version
76
+ version: '3.1'
77
+ required_rubygems_version: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: '0'
82
+ requirements: []
83
+ rubygems_version: 3.6.9
84
+ specification_version: 4
85
+ summary: UCP food-ordering menu conformance kit
86
+ test_files: []