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.
- checksums.yaml +7 -0
- data/LICENSE +202 -0
- data/README.md +156 -0
- data/config/importers/ncr_menu.json +12 -0
- data/exe/menuconform +7 -0
- data/lib/menuconform/catalog.rb +28 -0
- data/lib/menuconform/cli.rb +256 -0
- data/lib/menuconform/engine.rb +66 -0
- data/lib/menuconform/finding.rb +26 -0
- data/lib/menuconform/importers/base.rb +64 -0
- data/lib/menuconform/importers/ncr_menu.rb +322 -0
- data/lib/menuconform/importers.rb +40 -0
- data/lib/menuconform/menu.rb +168 -0
- data/lib/menuconform/report.rb +60 -0
- data/lib/menuconform/rules/age_rules.rb +57 -0
- data/lib/menuconform/rules/allergen_rules.rb +36 -0
- data/lib/menuconform/rules/avail_rules.rb +114 -0
- data/lib/menuconform/rules/cart_rules.rb +14 -0
- data/lib/menuconform/rules/conflict_rules.rb +69 -0
- data/lib/menuconform/rules/name_rules.rb +84 -0
- data/lib/menuconform/rules/price_rules.rb +92 -0
- data/lib/menuconform/rules/struct_rules.rb +95 -0
- data/lib/menuconform/scorer.rb +53 -0
- data/lib/menuconform/solver.rb +265 -0
- data/lib/menuconform/ucp_exporter.rb +253 -0
- data/lib/menuconform/version.rb +5 -0
- data/lib/menuconform.rb +23 -0
- data/rules/catalog.json +38 -0
- data/schema/menu_ir.schema.json +413 -0
- metadata +86 -0
|
@@ -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
|
data/lib/menuconform.rb
ADDED
|
@@ -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
|
data/rules/catalog.json
ADDED
|
@@ -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
|
+
}
|