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,265 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ module Menuconform
6
+ # Cart solver: answers, per item, whether a valid cart exists (CART-001),
7
+ # whether the item's own defaults violate its constraints (CART-002), whether
8
+ # any valid cart totals above zero (CART-003), and whether equivalent carts
9
+ # can price differently (CART-004).
10
+ #
11
+ # Exactness notes:
12
+ # - Satisfiability is exact for groups of up to 14 options (subset
13
+ # enumeration over distinct-count and total-unit constraints, with
14
+ # recursive option selectability); beyond 14 it falls back to greedy
15
+ # bounds, which can only over-report satisfiability (no false CART-001).
16
+ # - Max-total is a greedy upper approximation using each option's highest
17
+ # resolvable price: CART-003 (max <= 0) therefore never false-positives.
18
+ # - CART-004 is a deterministic reachability scan, not a random fuzzer: it is
19
+ # complete for the same-modifier-different-price class of ambiguity.
20
+ # - Items whose nesting is cyclic (STRUCT-005) are skipped entirely.
21
+ # - Groups a rule can't reason about (dangling refs) are skipped — STRUCT-002
22
+ # owns those.
23
+ class Solver
24
+ EXACT_ENUM_LIMIT = 14
25
+
26
+ def initialize(menu)
27
+ @menu = menu
28
+ end
29
+
30
+ def findings
31
+ out = []
32
+ @menu.items_by_id.each_value do |item|
33
+ next if @menu.depth_info(item)[:cyclic]
34
+ unless item_satisfiable?(item)
35
+ out << Finding.new("CART-001", "item", item["id"],
36
+ "no selection satisfies this item's modifier constraints — it cannot be ordered")
37
+ next
38
+ end
39
+ unless defaults_valid?(item)
40
+ out << Finding.new("CART-002", "item", item["id"],
41
+ "the pre-selected defaults violate this item's own group constraints")
42
+ end
43
+ max = max_total(item)
44
+ if max <= 0
45
+ out << Finding.new("CART-003", "item", item["id"],
46
+ "no valid cart totals above zero (maximum reachable total: #{max})")
47
+ end
48
+ end
49
+ out + ambiguities
50
+ end
51
+
52
+ # --- CART-001 -----------------------------------------------------------
53
+
54
+ def item_satisfiable?(item)
55
+ @menu.attached_group_ids(item).uniq.all? { |gid| group_satisfiable?(gid, []) }
56
+ end
57
+
58
+ def group_satisfiable?(gid, stack)
59
+ g = @menu.groups_by_id[gid]
60
+ return true if g.nil? # dangling ref: STRUCT-002 owns it
61
+ return false if stack.include?(gid) # cycle: conservatively uncompletable
62
+
63
+ selectable = existing_options(g).select do |o|
64
+ (o["child_modifier_group_ids"] || []).all? { |c| group_satisfiable?(c, stack + [gid]) }
65
+ end
66
+ feasible_selection_exists?(g, selectable)
67
+ end
68
+
69
+ def feasible_selection_exists?(g, selectable)
70
+ min_sel = [g["min_select"] || 0, 0].max
71
+ max_sel = g["max_select"] || selectable.size
72
+ min_tu = g["min_total_units"]
73
+ max_tu = g["max_total_units"]
74
+
75
+ return false if min_sel > max_sel
76
+ # Empty selection is a valid answer for an optional group.
77
+ return true if min_sel.zero? && (min_tu.nil? || min_tu <= 0)
78
+ return false if selectable.empty? || min_sel > selectable.size
79
+
80
+ ranges = selectable.map { |o| unit_range(o) }
81
+ if ranges.size <= EXACT_ENUM_LIMIT
82
+ (1..(2**ranges.size - 1)).any? do |mask|
83
+ k = mask.digits(2).sum
84
+ next false if k < min_sel || k > max_sel
85
+ lo = 0
86
+ hi = 0
87
+ ranges.each_with_index do |r, i|
88
+ next if mask[i].zero?
89
+ lo += r[0]
90
+ hi += r[1]
91
+ end
92
+ (max_tu.nil? || lo <= max_tu) && (min_tu.nil? || hi >= min_tu)
93
+ end
94
+ else
95
+ # Greedy bounds: can only over-report satisfiability.
96
+ (min_sel..[max_sel, ranges.size].min).any? do |k|
97
+ lo = ranges.map(&:first).min(k).sum
98
+ hi = ranges.map(&:last).max(k).sum
99
+ (max_tu.nil? || lo <= max_tu) && (min_tu.nil? || hi >= min_tu)
100
+ end
101
+ end
102
+ end
103
+
104
+ # --- CART-002 -----------------------------------------------------------
105
+
106
+ # Defaults are invalid only when they VIOLATE constraints (exceed maxima or
107
+ # per-option quantity bounds). A required group with no default is normal:
108
+ # the agent completes the selection.
109
+ def defaults_valid?(item)
110
+ queue = @menu.attached_group_ids(item).dup
111
+ seen = Set.new
112
+ until queue.empty?
113
+ gid = queue.shift
114
+ next if seen.include?(gid)
115
+ seen << gid
116
+ g = @menu.groups_by_id[gid]
117
+ next unless g
118
+ defaults = existing_options(g).select { |o| (o["default_quantity"] || 0).positive? }
119
+ max_sel = g["max_select"]
120
+ return false if !max_sel.nil? && defaults.size > max_sel
121
+ max_tu = g["max_total_units"]
122
+ return false if !max_tu.nil? && defaults.sum { |o| o["default_quantity"] } > max_tu
123
+ defaults.each do |o|
124
+ return false if o["default_quantity"] > (o["max_quantity"] || 1)
125
+ min_q = o["min_quantity"] || 0
126
+ return false if min_q.positive? && o["default_quantity"] < min_q
127
+ queue.concat(o["child_modifier_group_ids"] || [])
128
+ end
129
+ end
130
+ true
131
+ end
132
+
133
+ # --- CART-003 -----------------------------------------------------------
134
+
135
+ IDENTITY = ->(p) { p }
136
+
137
+ def max_total(item)
138
+ total = item["price"]
139
+ (item["modifier_group_ids"] || []).uniq.each { |gid| total += max_contribution(gid, [], IDENTITY) }
140
+ slots = item["slots"] || []
141
+ unless slots.empty?
142
+ case item["slot_pricing"]
143
+ when "proportional"
144
+ slots.each do |s|
145
+ # Rounded half away from zero, per unit — the pinned IR semantics.
146
+ scale = ->(p) { (p * s["fraction"]).round }
147
+ (s["modifier_group_ids"] || []).uniq.each { |gid| total += max_contribution(gid, [], scale) }
148
+ end
149
+ when "max_slot"
150
+ # Charged as if the most expensive slot's selections applied to the
151
+ # whole item: each distinct group counts once, unscaled.
152
+ slots.flat_map { |s| s["modifier_group_ids"] || [] }.uniq.each do |gid|
153
+ total += max_contribution(gid, [], IDENTITY)
154
+ end
155
+ else # full_price
156
+ slots.each do |s|
157
+ (s["modifier_group_ids"] || []).uniq.each { |gid| total += max_contribution(gid, [], IDENTITY) }
158
+ end
159
+ end
160
+ end
161
+ total
162
+ end
163
+
164
+ def max_contribution(gid, stack, transform)
165
+ g = @menu.groups_by_id[gid]
166
+ return 0 if g.nil? || stack.include?(gid)
167
+
168
+ selectable = existing_options(g).select do |o|
169
+ (o["child_modifier_group_ids"] || []).all? { |c| group_satisfiable?(c, stack + [gid]) }
170
+ end
171
+ min_sel = [g["min_select"] || 0, 0].max
172
+ max_sel = g["max_select"] || selectable.size
173
+ return 0 if selectable.empty? || min_sel > max_sel || min_sel > selectable.size
174
+
175
+ budget = g["max_total_units"] || Float::INFINITY
176
+ candidates = selectable.map do |o|
177
+ unit = transform.call(max_unit_price(o))
178
+ range = unit_range(o)
179
+ units = unit.positive? ? range[1] : range[0]
180
+ kids = (o["child_modifier_group_ids"] || []).sum { |c| max_contribution(c, stack + [gid], transform) }
181
+ { unit: unit, units: units, kids: kids, value: (unit * units) + kids }
182
+ end.sort_by { |c| -c[:value] }
183
+
184
+ chosen = []
185
+ used = 0
186
+ candidates.each do |c|
187
+ forced = chosen.size < min_sel
188
+ break if !forced && chosen.size >= max_sel
189
+ next unless forced || c[:value].positive?
190
+ units = [c[:units], budget - used].min
191
+ units = 1 if forced && units < 1
192
+ next if units < 1
193
+ chosen << { unit: c[:unit], units: units, kids: c[:kids] }
194
+ used += units
195
+ end
196
+
197
+ chosen.sum { |c| (c[:unit] * c[:units]) + c[:kids] } - included_discount(g, chosen)
198
+ end
199
+
200
+ # Free-quantity discount applied to a chosen selection (pricing semantics,
201
+ # not choice): included_counting units = free units by allocation order;
202
+ # options = whole cheapest/most-expensive selected options free. Negative
203
+ # unit prices never benefit from a free slot.
204
+ def included_discount(g, chosen)
205
+ inc = g["included_quantity"] || 0
206
+ return 0 unless inc.positive?
207
+ allocation = g["included_allocation"] || "cheapest_first"
208
+ if (g["included_counting"] || "units") == "options"
209
+ ordered = chosen.sort_by { |c| c[:unit] }
210
+ ordered.reverse! if allocation == "most_expensive_first"
211
+ ordered.first(inc).sum { |c| [c[:unit], 0].max * c[:units] }
212
+ else
213
+ units = chosen.flat_map { |c| Array.new(c[:units]) { c[:unit] } }.sort
214
+ units.reverse! if allocation == "most_expensive_first"
215
+ units.first(inc).sum { |u| [u, 0].max }
216
+ end
217
+ end
218
+
219
+ # --- CART-004 -----------------------------------------------------------
220
+
221
+ # Two carts selecting the same (modifier, quantity) multiset must price
222
+ # identically. They can't when one modifier is reachable on the same item
223
+ # through links with different price signatures.
224
+ def ambiguities
225
+ out = []
226
+ @menu.items_by_id.each_value do |item|
227
+ signatures = Hash.new { |h, k| h[k] = Set.new }
228
+ @menu.reachable_group_ids(item).sort.each do |gid|
229
+ g = @menu.groups_by_id[gid]
230
+ existing_options(g).each do |o|
231
+ m = @menu.modifiers_by_id[o["modifier_id"]]
232
+ base = o.key?("price_override") ? o["price_override"] : m["price"]
233
+ cond = (o["conditional_prices"] || []).map { |c| [c["when_modifier_id"], c["price"]] }.sort
234
+ signatures[o["modifier_id"]] << [base, cond]
235
+ end
236
+ end
237
+ signatures.each do |mid, sigs|
238
+ next if sigs.size <= 1
239
+ out << Finding.new("CART-004", "item", item["id"],
240
+ "modifier #{mid} is reachable at #{sigs.size} different prices on this item — " \
241
+ "equivalent carts price differently")
242
+ end
243
+ end
244
+ out
245
+ end
246
+
247
+ private
248
+
249
+ def existing_options(g)
250
+ (g["options"] || []).select { |o| @menu.modifiers_by_id.key?(o["modifier_id"]) }
251
+ end
252
+
253
+ # [min units if selected, max units] for one option.
254
+ def unit_range(o)
255
+ max_q = o["max_quantity"] || 1
256
+ [[[o["min_quantity"] || 0, 1].max, max_q].min, max_q]
257
+ end
258
+
259
+ def max_unit_price(o)
260
+ m = @menu.modifiers_by_id[o["modifier_id"]]
261
+ base = o.key?("price_override") ? o["price_override"] : m["price"]
262
+ ([base] + (o["conditional_prices"] || []).map { |c| c["price"] }).max
263
+ end
264
+ end
265
+ end
@@ -0,0 +1,253 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Menuconform
6
+ # Exports a Menu IR document to the shopping catalog shapes of the pinned
7
+ # UCP spec (product / variant / product_option / price), reporting what the
8
+ # export loses as EXPORT- findings.
9
+ #
10
+ # The pinned draft's catalog model is retail-shaped: a product carries
11
+ # enumerable fixed-price variants defined by option combinations. A food
12
+ # menu maps onto that only where an item's price is fully determined by
13
+ # required single-select groups (sizes) — those become product options and
14
+ # variants. Everything else has no UCP home today:
15
+ # - optional / multi-select / quantity-bearing / nested modifier groups,
16
+ # conditional prices, included quantities -> EXPORT-002 (dropped; the
17
+ # modifier model is stashed in the non-standard `metadata` extension)
18
+ # - half/half slots, allergens, min_age, dietary (degraded to free tags),
19
+ # availability windows, timed suspensions -> EXPORT-002
20
+ # - items whose price depends on a group that can't be a variant axis, or
21
+ # whose variant space exceeds the enumeration cap -> EXPORT-001 (the item
22
+ # cannot exist under the pinned draft)
23
+ #
24
+ # EXPORT- findings surface only through this stage (`menuconform export`),
25
+ # never through `check`: the check score judges the menu data, not the
26
+ # current gaps of the UCP draft.
27
+ class UcpExporter
28
+ UCP_SPEC_PIN = "2026-08-25"
29
+ DEFAULT_VARIANT_CAP = 50
30
+
31
+ def initialize(catalog:, variant_cap: DEFAULT_VARIANT_CAP)
32
+ @catalog = catalog
33
+ @variant_cap = variant_cap
34
+ end
35
+
36
+ # Returns { artifact: Hash, findings: [Finding] } (severities stamped).
37
+ def call(doc, reference_time: Time.now.utc)
38
+ menu = Menu.new(doc, reference_time: reference_time)
39
+ findings = []
40
+ products = []
41
+
42
+ findings << finding("EXPORT-002", "menu", doc["name"] || "(unnamed)",
43
+ "menu-level service windows have no UCP catalog construct; agents will not know the ordering hours") if present?(doc["availability"])
44
+ findings << finding("EXPORT-002", "menu", doc["name"] || "(unnamed)",
45
+ "special_hours (holiday/date exceptions) have no UCP catalog construct") if present?(doc["special_hours"])
46
+ menu.categories_by_id.each_value do |c|
47
+ next unless present?(c["availability"])
48
+ findings << finding("EXPORT-002", "category", c["id"],
49
+ "category availability windows (dayparts) have no UCP catalog construct")
50
+ end
51
+
52
+ menu.items_by_id.each_value do |item|
53
+ product, item_findings = export_item(menu, item)
54
+ findings.concat(item_findings)
55
+ products << product if product
56
+ end
57
+
58
+ artifact = {
59
+ "ucp_spec_version" => UCP_SPEC_PIN,
60
+ "generated_by" => "menuconform #{VERSION}",
61
+ "catalog" => { "products" => products }
62
+ }
63
+ { artifact: artifact, findings: findings.each { |f| f.severity = @catalog.severity(f.rule) } }
64
+ end
65
+
66
+ private
67
+
68
+ def export_item(menu, item)
69
+ findings = []
70
+ axes = []
71
+ dropped = []
72
+ blockers = []
73
+
74
+ (item["modifier_group_ids"] || []).uniq.each do |gid|
75
+ g = menu.groups_by_id[gid]
76
+ next unless g # dangling: STRUCT-002's problem
77
+ if variant_axis?(menu, g)
78
+ axes << g
79
+ else
80
+ required = (g["min_select"] || 0).positive? || (g["min_total_units"] || 0).positive?
81
+ if required && priced_options?(menu, g)
82
+ blockers << gid
83
+ else
84
+ dropped << gid
85
+ end
86
+ end
87
+ end
88
+ slot_gids = (item["slots"] || []).flat_map { |s| s["modifier_group_ids"] || [] }.uniq
89
+ slot_gids.each do |gid|
90
+ g = menu.groups_by_id[gid]
91
+ blockers << gid if g && ((g["min_select"] || 0).positive? || (g["min_total_units"] || 0).positive?) && priced_options?(menu, g)
92
+ end
93
+
94
+ unless blockers.empty?
95
+ findings << finding("EXPORT-001", "item", item["id"],
96
+ "price depends on selections in #{blockers.join(', ')}, which cannot become fixed-price " \
97
+ "variants under the pinned UCP catalog model — this item cannot be represented")
98
+ return [nil, findings]
99
+ end
100
+
101
+ variant_count = axes.reduce(1) { |acc, g| acc * axis_options(menu, g).size }
102
+ if variant_count > @variant_cap
103
+ findings << finding("EXPORT-001", "item", item["id"],
104
+ "#{variant_count} variants would be needed to enumerate the required selections " \
105
+ "(cap #{@variant_cap}) — the configuration space does not fit UCP's variant model")
106
+ return [nil, findings]
107
+ end
108
+
109
+ findings.concat(lossiness_findings(menu, item, dropped, slot_gids))
110
+ [build_product(menu, item, axes), findings]
111
+ end
112
+
113
+ # A group can be a variant axis when exactly one option must be chosen and
114
+ # the choice fully determines its price contribution: no per-option
115
+ # quantities, no child groups, no conditional prices.
116
+ def variant_axis?(menu, g)
117
+ return false unless g["min_select"] == 1 && g["max_select"] == 1
118
+ return false if (g["min_total_units"] || 0) > 1
119
+ opts = axis_options(menu, g)
120
+ return false if opts.empty?
121
+ opts.all? do |o|
122
+ (o["max_quantity"] || 1) == 1 &&
123
+ (o["child_modifier_group_ids"] || []).empty? &&
124
+ (o["conditional_prices"] || []).empty?
125
+ end
126
+ end
127
+
128
+ def axis_options(menu, g)
129
+ (g["options"] || []).select { |o| menu.modifiers_by_id.key?(o["modifier_id"]) }
130
+ end
131
+
132
+ def priced_options?(menu, g)
133
+ axis_options(menu, g).any? { |o| resolved_price(menu, o) != 0 }
134
+ end
135
+
136
+ def resolved_price(menu, option)
137
+ option.key?("price_override") ? option["price_override"] : menu.modifiers_by_id[option["modifier_id"]]["price"]
138
+ end
139
+
140
+ def build_product(menu, item, axes)
141
+ currency = menu.doc["currency"]
142
+ combos = axes.reduce([[]]) do |acc, g|
143
+ acc.flat_map { |combo| axis_options(menu, g).map { |o| combo + [[g, o]] } }
144
+ end
145
+
146
+ variants = combos.map do |combo|
147
+ amount = item["price"] + combo.sum { |(_, o)| resolved_price(menu, o) }
148
+ labels = combo.map { |(_, o)| menu.modifiers_by_id[o["modifier_id"]]["name"] }
149
+ v = {
150
+ "id" => ([item["id"]] + combo.map { |(_, o)| o["modifier_id"] }).join("~"),
151
+ "title" => labels.empty? ? item["name"] : "#{item['name']} — #{labels.join(' / ')}",
152
+ "description" => description_of(item),
153
+ "price" => { "amount" => amount, "currency" => currency }
154
+ }
155
+ unless combo.empty?
156
+ v["options"] = combo.map do |(g, o)|
157
+ { "name" => g["name"], "label" => menu.modifiers_by_id[o["modifier_id"]]["name"], "id" => o["modifier_id"] }
158
+ end
159
+ end
160
+ v["availability"] = { "available" => false, "status" => "out_of_stock" } unless orderable?(menu, item)
161
+ v
162
+ end
163
+
164
+ amounts = variants.map { |v| v["price"]["amount"] }
165
+ product = {
166
+ "id" => item["id"],
167
+ "title" => item["name"],
168
+ "description" => description_of(item),
169
+ "price_range" => {
170
+ "min" => { "amount" => amounts.min, "currency" => currency },
171
+ "max" => { "amount" => amounts.max, "currency" => currency }
172
+ },
173
+ "variants" => variants
174
+ }
175
+ unless axes.empty?
176
+ product["options"] = axes.map do |g|
177
+ { "name" => g["name"],
178
+ "values" => axis_options(menu, g).map { |o| { "id" => o["modifier_id"], "label" => menu.modifiers_by_id[o["modifier_id"]]["name"] } } }
179
+ end
180
+ end
181
+ product["tags"] = item["dietary"] if item["dietary"]&.any?
182
+ metadata = metadata_stash(item)
183
+ product["metadata"] = metadata unless metadata.empty?
184
+ product
185
+ end
186
+
187
+ def lossiness_findings(menu, item, dropped, slot_gids)
188
+ out = []
189
+ id = item["id"]
190
+ unless dropped.empty?
191
+ out << finding("EXPORT-002", "item", id,
192
+ "modifier group(s) #{dropped.join(', ')} have no UCP catalog construct — agents cannot customize this item")
193
+ end
194
+ if item["slots"]&.any?
195
+ out << finding("EXPORT-002", "item", id,
196
+ "half/half slot construct (#{slot_gids.join(', ')}) has no UCP representation; fractional pricing semantics are lost")
197
+ end
198
+ if item["allergens"]
199
+ out << finding("EXPORT-002", "item", id,
200
+ "allergen declarations survive only in the non-standard metadata extension — agents filtering by allergen will not see them")
201
+ end
202
+ if item["min_age"]
203
+ out << finding("EXPORT-002", "item", id,
204
+ "min_age #{item['min_age']} has no catalog field; age gating is left to checkout-time policy")
205
+ end
206
+ if item["dietary"]&.any?
207
+ out << finding("EXPORT-002", "item", id,
208
+ "dietary claims degraded to free-text tags (no defined vocabulary in the pinned draft)")
209
+ end
210
+ if present?(item["availability"])
211
+ out << finding("EXPORT-002", "item", id,
212
+ "item availability windows have no UCP catalog construct")
213
+ end
214
+ if item["suspended_until"]
215
+ out << finding("EXPORT-002", "item", id,
216
+ "timed suspension mapped to a bare out_of_stock flag; the automatic recovery time is lost")
217
+ end
218
+ out
219
+ end
220
+
221
+ def metadata_stash(item)
222
+ stash = {}
223
+ stash["allergens"] = item["allergens"] if item["allergens"]
224
+ stash["min_age"] = item["min_age"] if item["min_age"]
225
+ stash["availability"] = item["availability"] if present?(item["availability"])
226
+ stash.empty? ? {} : { "menuconform" => stash }
227
+ end
228
+
229
+ def orderable?(menu, item)
230
+ return false if item["active"] == false
231
+ raw = item["suspended_until"]
232
+ return true unless raw
233
+ t = begin
234
+ Time.iso8601(raw)
235
+ rescue ArgumentError
236
+ nil
237
+ end
238
+ t.nil? || t <= menu.reference_time
239
+ end
240
+
241
+ def description_of(entity)
242
+ { "plain" => entity["description"] || entity["name"] || "" }
243
+ end
244
+
245
+ def present?(value)
246
+ !value.nil? && !value.empty?
247
+ end
248
+
249
+ def finding(rule, type, id, message)
250
+ Finding.new(rule, type, id, message)
251
+ end
252
+ end
253
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Menuconform
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "menuconform/version"
4
+ require_relative "menuconform/finding"
5
+ require_relative "menuconform/catalog"
6
+ require_relative "menuconform/menu"
7
+ require_relative "menuconform/rules/struct_rules"
8
+ require_relative "menuconform/rules/conflict_rules"
9
+ require_relative "menuconform/rules/price_rules"
10
+ require_relative "menuconform/rules/avail_rules"
11
+ require_relative "menuconform/rules/name_rules"
12
+ require_relative "menuconform/rules/allergen_rules"
13
+ require_relative "menuconform/rules/age_rules"
14
+ require_relative "menuconform/importers"
15
+ require_relative "menuconform/solver"
16
+ require_relative "menuconform/rules/cart_rules"
17
+ require_relative "menuconform/scorer"
18
+ require_relative "menuconform/ucp_exporter"
19
+ require_relative "menuconform/report"
20
+ require_relative "menuconform/engine"
21
+
22
+ module Menuconform
23
+ end
@@ -0,0 +1,38 @@
1
+ {
2
+ "rules_version": "0.1.0",
3
+ "note": "Registry stub covering the rules referenced by fixtures 01/02/12 plus reserved core IDs. Rule IDs are stable forever: never renumber, never reuse. Descriptions/fix_hints are written for a restaurant ops / POS product audience. Engine logic lands days 3-8.",
4
+ "rules": [
5
+ { "id": "STRUCT-001", "severity": "error", "title": "Document fails IR schema validation", "description": "The menu document does not conform to the Menu IR JSON Schema (shape/type errors).", "fix_hint": "Fix the export or importer mapping so the document matches schema/menu_ir.schema.json; all other checks need a loadable document." },
6
+ { "id": "STRUCT-002", "severity": "error", "title": "Dangling reference", "description": "An ID is referenced (modifier_group_ids, option modifier_id, child_modifier_group_ids, category item_ids, conditional when_modifier_id) but no entity with that ID exists.", "fix_hint": "The referenced record was deleted or renamed in the POS without updating what points at it. Remove the reference or restore the record." },
7
+ { "id": "STRUCT-003", "severity": "error", "title": "Duplicate ID", "description": "Two entities in the same collection share an ID; references to it are ambiguous.", "fix_hint": "Deduplicate in the source system; an agent cannot tell which record it is ordering." },
8
+ { "id": "STRUCT-004", "severity": "warn", "title": "Orphan entity", "description": "An entity is defined but nothing references it.", "fix_hint": "Usually a leftover from a deleted menu section. Remove it, or reattach it if it was meant to be live." },
9
+ { "id": "STRUCT-005", "severity": "error", "title": "Modifier nesting deeper than 6", "description": "A chain of nested modifier groups exceeds depth 6 from the item; no major ordering platform supports this.", "fix_hint": "Flatten the deepest choices into their parent group." },
10
+ { "id": "STRUCT-006", "severity": "warn", "title": "Modifier nesting deeper than 4", "description": "Nesting depth 5-6 is exportable but degrades agent and diner experience; only depth up to 4 is common (pizza size/crust/topping/side).", "fix_hint": "Consider flattening: deep question chains cause abandoned agent carts." },
11
+ { "id": "STRUCT-007", "severity": "warn", "title": "Item not in any category", "description": "The item exists but no category lists it, so no agent or diner can discover it.", "fix_hint": "Add it to a category or remove it." },
12
+ { "id": "STRUCT-008", "severity": "warn", "title": "Empty category", "description": "A category lists no items.", "fix_hint": "Remove the category or fill it; empty sections read as broken menus to agents." },
13
+ { "id": "PRICE-001", "severity": "error", "title": "Negative item price", "description": "An item has a negative base price.", "fix_hint": "Set a real price; negative-price placeholder/test items must not reach a live menu." },
14
+ { "id": "PRICE-002", "severity": "warn", "title": "Incomplete conditional-price matrix", "description": "An option has conditional prices for some, but not all, options of its trigger group (e.g. topping priced for Small and Large but the menu also has X-Large). Unmatched selections silently fall back to the base price.", "fix_hint": "Add the missing matrix entries — this is the classic 'new size added, topping table not updated' undercharge." },
15
+ { "id": "PRICE-003", "severity": "error", "title": "Negative line total reachable", "description": "Some valid selection makes the item's total negative (a discounting modifier exceeds the item price).", "fix_hint": "Cap the discount modifier or raise the base price; a negative line breaks checkout math downstream." },
16
+ { "id": "AVAIL-001", "severity": "error", "title": "Availability contradicts parent scope", "description": "An item's windows never overlap its category's (or the menu's) windows, so it is never actually orderable despite having a schedule.", "fix_hint": "Align the item's schedule with its category, or move it to a category served during those hours." },
17
+ { "id": "AVAIL-002", "severity": "warn", "title": "Declared never-available", "description": "An entity has an explicit empty availability list.", "fix_hint": "If it is retired, remove it; if it should sell, give it real windows." },
18
+ { "id": "AVAIL-003", "severity": "warn", "title": "Indefinite suspension", "description": "suspended_until is more than a year in the future — effectively deleted via 86ing.", "fix_hint": "86 is for outages, not retirement. Deactivate or remove the record instead." },
19
+ { "id": "CONFLICT-001", "severity": "error", "title": "min exceeds max", "description": "A group's min_select exceeds max_select (or min_total_units exceeds max_total_units); no selection can satisfy it.", "fix_hint": "Fix the bounds; every item using this group is unorderable." },
20
+ { "id": "CONFLICT-002", "severity": "error", "title": "Required group with no options", "description": "A group requires a selection but offers no options.", "fix_hint": "Add options or make the group optional." },
21
+ { "id": "CONFLICT-003", "severity": "error", "title": "Defaults violate group constraints", "description": "The pre-selected defaults exceed the group's maxima (or fall short of minima).", "fix_hint": "Adjust default selections; agents start from defaults and will submit invalid carts." },
22
+ { "id": "CONFLICT-004", "severity": "warn", "title": "Included quantity exceeds group maximum", "description": "included_quantity is larger than what the group allows to be selected, so part of the 'free' allowance can never be used.", "fix_hint": "Align included_quantity with the group's max; mismatches confuse price expectations." },
23
+ { "id": "CONFLICT-005", "severity": "error", "title": "Invalid conditional-price trigger", "description": "conditional_prices reference options that are not all in one single-select (max_select 1) group attached to the same item, so the matched price is ambiguous.", "fix_hint": "Key conditional prices off exactly one single-choice group (typically the size group)." },
24
+ { "id": "NAME-001", "severity": "error", "title": "Empty name", "description": "A customer-facing entity has an empty or whitespace-only name.", "fix_hint": "Name it; agents cannot present or disambiguate a blank option." },
25
+ { "id": "NAME-002", "severity": "warn", "title": "Duplicate names in scope", "description": "Two entities in the same scope share a name (case-insensitive).", "fix_hint": "Merge the duplicates or differentiate the names; agents ordering 'Diet Coke' pick one at random." },
26
+ { "id": "NAME-003", "severity": "warn", "title": "POS junk in customer-facing name", "description": "The name carries back-of-house tokens (86, DNU, DO NOT USE, TEST, zzz) or POS abbreviation style.", "fix_hint": "Customer-facing names should read like a menu, not a kitchen printer tape." },
27
+ { "id": "ALLERGEN-001", "severity": "info", "title": "Missing allergen data", "description": "A food item declares no allergen information at all.", "fix_hint": "Declare contains/may_contain (an explicit empty list is a declaration); agents increasingly filter by allergen and skip unlabeled items." },
28
+ { "id": "ALLERGEN-002", "severity": "warn", "title": "Contradictory allergen declaration", "description": "The same allergen appears in both contains and may_contain.", "fix_hint": "Pick one: 'contains' and 'may contain' are different legal statements." },
29
+ { "id": "AGE-001", "severity": "error", "title": "Age-restricted product without min_age", "description": "An entity whose name/type indicates alcohol (or another age-restricted product) has no min_age.", "fix_hint": "Set min_age (21 for US alcohol); without it an agent may sell alcohol without age verification." },
30
+ { "id": "AGE-002", "severity": "warn", "title": "Age-restricted item in kids-oriented category", "description": "An age-restricted or alcohol-indicative item is listed in a category aimed at children.", "fix_hint": "Almost always a miscategorization; move the item." },
31
+ { "id": "CART-001", "severity": "error", "title": "No valid cart exists", "description": "The cart solver found no selection satisfying all of the item's constraints — the item cannot be ordered at all.", "fix_hint": "See the accompanying CONFLICT/STRUCT findings on this item's groups; fixing those usually resolves this." },
32
+ { "id": "CART-002", "severity": "error", "title": "Default cart invalid", "description": "The item's pre-selected defaults do not satisfy its own constraints; agents starting from defaults submit invalid carts.", "fix_hint": "Fix the default selections on the flagged groups." },
33
+ { "id": "CART-003", "severity": "error", "title": "No positive price reachable", "description": "No valid selection yields a total greater than zero.", "fix_hint": "Give the item a price or priced required options; zero/negative lines fail downstream checkout." },
34
+ { "id": "CART-004", "severity": "warn", "title": "Pricing ambiguity", "description": "The fuzzer found two equivalent selections with different totals, or unstable totals under slot/included-quantity interaction.", "fix_hint": "Review the flagged groups' pricing semantics; ambiguous totals become chargebacks." },
35
+ { "id": "EXPORT-001", "severity": "error", "title": "Construct cannot be exported", "description": "A construct in this menu has no representation at all under the pinned UCP draft.", "fix_hint": "See the finding detail for the construct and the suggested restructure." },
36
+ { "id": "EXPORT-002", "severity": "warn", "title": "Lossy export", "description": "A construct exports to the pinned UCP draft only with degraded semantics (the lossy-construct list).", "fix_hint": "Review what the agent-facing menu will lose; restructure if the loss is customer-visible." }
37
+ ]
38
+ }