menuconform 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 497b0ace6a30de42bc5d0d494ae7c9e23206331407902b3a84a3636d809c40d0
4
- data.tar.gz: cea2dfe8a743d55e2957381401d666eee1fb4bf339dabe3f147b37e938ae9ee2
3
+ metadata.gz: d7ec9be4b3e1a835ffc1fe2c723c22208ac41ab9826560c3cbc32238dc10bd8e
4
+ data.tar.gz: fddcc3464cd86ace1764f20f4ffdb8f50e9d0bf879f86147501691846ba9462c
5
5
  SHA512:
6
- metadata.gz: 199304d9779008366dec8be4740bfb1b19e70cf89e77c84fe427b22ead45c0870f5c3a19faf60ff2c83951a97a6ce50003f1cfe4b691e817dcbd1243d2f3a8e6
7
- data.tar.gz: 938be138bea321d6b64ada27c482fe46f5c56eb5b47a3cf72f16cd8923e3bfe88f99476c131eb281333d576658a799dd1440ece3b08c1a1f1502a13a3f43177e
6
+ metadata.gz: 2b7c7a7e881f869019dd09e4a4e11c9db19c41ffa2f293c74dd585050721391d0cc64eb8acfb6361beb8b0b8811c5bf25f442a368a1c6a36f52a42eba0f6b7e4
7
+ data.tar.gz: 4eeb1651ec3de2eae25f59567eb083c19509f96e55f6b3307fff6edf6132c53770c9f3352c60eee245dc0d2004aece3c42eca5b92914c7b7e5e9d4974cffc620
data/CHANGELOG.md ADDED
@@ -0,0 +1,22 @@
1
+ # Changelog
2
+
3
+ ## 0.2.0 — 2026-09-13
4
+
5
+ - Experimental export target for UCP proposal [#821 (Nested Options)](https://github.com/Universal-Commerce-Protocol/ucp/issues/821):
6
+ `menuconform export --target nested-options-821`. Every modifier group lowers
7
+ to a product option whose values carry `product_id` references; measured
8
+ corpus results in [docs/export-lossiness.md](docs/export-lossiness.md).
9
+ - Experimental export target for UCP proposal [#824 (Selection Rules)](https://github.com/Universal-Commerce-Protocol/ucp/issues/824),
10
+ composing with #821: `--target selection-rules-824` emits two-axis
11
+ `selection_rule` on options and `quantity_rule` on option values, at depth
12
+ cap 6. The selection-constraints loss class disappears across the corpus.
13
+ - Allergen vocabulary extended to the union of US big-9 and EU-14.
14
+ - CI (GitHub Actions, Ruby 3.1 and 3.4) and issue templates.
15
+
16
+ ## 0.1.0 — 2026-08-31
17
+
18
+ - Initial release: Menu IR v0.2 (JSON Schema 2020-12), 33-rule engine across
19
+ 9 families including the exact cart solver (CART-001..004), published 0-100
20
+ scoring formula, JSON report with fix hints, NCR Voyix Menu API importer,
21
+ UCP catalog exporter with EXPORT- lossiness findings, 12-fixture corpus
22
+ with pinned expected findings, CLI (`check` / `import` / `export` / `rules`).
data/README.md CHANGED
@@ -72,7 +72,12 @@ schema-invalid document at 59. Deterministic: the only time input is an injectab
72
72
 
73
73
  ## Install & use
74
74
 
75
- Not yet on RubyGems (soon). From source (Ruby ≥ 3.1):
75
+ ```
76
+ gem install menuconform
77
+ menuconform check menu.json
78
+ ```
79
+
80
+ Or from source (Ruby ≥ 3.1):
76
81
 
77
82
  ```
78
83
  git clone https://github.com/amitkssolanki/menuconform && cd menuconform
@@ -125,7 +130,10 @@ shapes (`product`/`variant`/`option`/`price`) and reports what the export loses
125
130
  everything else — multi-select modifiers, nested groups, conditional prices,
126
131
  half/half, allergens, age restrictions, schedules — is dropped, degraded, or makes
127
132
  the item unrepresentable (`EXPORT-001`, exit 1). The full lossy-construct analysis
128
- lives in [docs/export-lossiness.md](docs/export-lossiness.md). Export findings
133
+ lives in [docs/export-lossiness.md](docs/export-lossiness.md), including a measured
134
+ evaluation of proposal
135
+ [ucp#821](https://github.com/Universal-Commerce-Protocol/ucp/issues/821) via the
136
+ experimental `--target nested-options-821`. Export findings
129
137
  deliberately do **not** affect the `check` score: `check` judges your menu data,
130
138
  `export` judges the current draft's fit for food.
131
139
 
@@ -147,11 +147,19 @@ module Menuconform
147
147
 
148
148
  # Artifact goes to --out (or stdout); EXPORT- findings go to stderr.
149
149
  # Exit 1 when any EXPORT-001 (item cannot exist under the pinned draft).
150
+ EXPORT_TARGETS = {
151
+ "catalog" => :catalog,
152
+ "catalog-2026-08-25" => :catalog,
153
+ "nested-options-821" => :nested_options,
154
+ "selection-rules-824" => :selection_rules
155
+ }.freeze
156
+
150
157
  def export(args)
151
- options = { from: nil, config: nil, out: nil, reference_time: Time.now.utc }
158
+ options = { from: nil, config: nil, out: nil, reference_time: Time.now.utc, target: "catalog" }
152
159
  parser = OptionParser.new do |o|
153
160
  o.on("--from FORMAT") { |f| options[:from] = f }
154
161
  o.on("--config PATH") { |p| options[:config] = p }
162
+ o.on("--target TARGET") { |t| options[:target] = t }
155
163
  o.on("-o PATH", "--out PATH") { |p| options[:out] = p }
156
164
  o.on("--reference-time TIME") do |t|
157
165
  options[:reference_time] = Time.iso8601(t)
@@ -183,6 +191,9 @@ module Menuconform
183
191
  doc = result.doc
184
192
  end
185
193
 
194
+ target = EXPORT_TARGETS[options[:target]]
195
+ return usage_error("unknown export target #{options[:target].inspect} (known: #{EXPORT_TARGETS.keys.join(', ')})") unless target
196
+
186
197
  engine = Engine.new
187
198
  schema_gate = engine.run(doc, reference_time: options[:reference_time])
188
199
  if schema_gate.any? { |f| f.rule == "STRUCT-001" }
@@ -190,7 +201,11 @@ module Menuconform
190
201
  return 1
191
202
  end
192
203
 
193
- exporter = UcpExporter.new(catalog: engine.catalog)
204
+ exporter = case target
205
+ when :nested_options then NestedOptionsExporter.new(catalog: engine.catalog)
206
+ when :selection_rules then NestedOptionsExporter.new(catalog: engine.catalog, selection_rules: true)
207
+ else UcpExporter.new(catalog: engine.catalog)
208
+ end
194
209
  result = exporter.call(doc, reference_time: options[:reference_time])
195
210
 
196
211
  result[:findings].each do |f|
@@ -199,7 +214,12 @@ module Menuconform
199
214
  errors = result[:findings].count { |f| f.severity == "error" }
200
215
  warns = result[:findings].count { |f| f.severity == "warn" }
201
216
  exported = result[:artifact]["catalog"]["products"].size
202
- @stderr.puts "exported #{exported} product(s) against UCP #{UcpExporter::UCP_SPEC_PIN}; " \
217
+ label = case target
218
+ when :nested_options then "UCP #{UcpExporter::UCP_SPEC_PIN} + proposal #821 (experimental)"
219
+ when :selection_rules then "UCP #{UcpExporter::UCP_SPEC_PIN} + proposals #821+#824 (experimental)"
220
+ else "UCP #{UcpExporter::UCP_SPEC_PIN}"
221
+ end
222
+ @stderr.puts "exported #{exported} product(s) against #{label}; " \
203
223
  "#{errors} unrepresentable item(s), #{warns} lossy construct(s)"
204
224
 
205
225
  json = JSON.pretty_generate(result[:artifact])
@@ -0,0 +1,297 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Menuconform
6
+ # EXPERIMENTAL export target for UCP proposal #821 ("Nested Options",
7
+ # Universal-Commerce-Protocol/ucp#821): option values may carry a product_id
8
+ # referencing another product; nesting is capped at 5 products deep; pricing
9
+ # is recursive addition; no selection constraints (min/max/defaults) and no
10
+ # non-additive pricing — both are the proposal's stated non-goals.
11
+ #
12
+ # Mapping:
13
+ # - Every IR modifier group becomes a product option; every option link
14
+ # becomes an option value referencing a modifier-product. Nested groups
15
+ # recurse. Items with any priced required group are now representable —
16
+ # the class that is EXPORT-001 under the shipped catalog model.
17
+ # - A modifier-product is shared across links only when the link-resolved
18
+ # price and child groups match; a link price_override otherwise forces a
19
+ # duplicated product (#821 has no per-reference override — measured and
20
+ # reported as a menu-level finding).
21
+ # - Conditional prices export at base/override price (estimate): exact
22
+ # expression would require duplicating the whole subtree per trigger
23
+ # selection, the same explosion the proposal exists to avoid.
24
+ # - Depth: IR group-depth d costs d+1 products (item + one per level), so
25
+ # the 5-product cap holds IR depth <= 4; deeper items are EXPORT-001.
26
+ # - Selection constraints, included quantities, and non-full_price slot
27
+ # pricing are EXPORT-002 (proposal non-goals). Allergens / min_age /
28
+ # dietary / schedules are unchanged by #821 and carry the same findings
29
+ # as the shipped-catalog exporter.
30
+ class NestedOptionsExporter
31
+ PROPOSAL = "Universal-Commerce-Protocol/ucp#821 (nested options, experimental)"
32
+ PROPOSAL_WITH_RULES = "Universal-Commerce-Protocol/ucp#821 + ucp#824 (nested options + selection rules, experimental)"
33
+ PRODUCT_DEPTH_CAP = 5
34
+ # ucp#824 composes with #821; depth 6 per the #821 thread agreement.
35
+ DEPTH_CAP_WITH_RULES = 6
36
+
37
+ # selection_rules: true adds ucp#824's selection_rule (on product options)
38
+ # and quantity_rule (on option values) — the IR's two-axis constraints,
39
+ # per-link quantity bounds, and defaults all become expressible, so the
40
+ # constraints loss class disappears from the findings.
41
+ def initialize(catalog:, selection_rules: false)
42
+ @catalog = catalog
43
+ @selection_rules = selection_rules
44
+ @depth_cap = selection_rules ? DEPTH_CAP_WITH_RULES : PRODUCT_DEPTH_CAP
45
+ @proposal = selection_rules ? PROPOSAL_WITH_RULES : PROPOSAL
46
+ end
47
+
48
+ # Returns { artifact: Hash, findings: [Finding] } (severities stamped).
49
+ def call(doc, reference_time: Time.now.utc)
50
+ menu = Menu.new(doc, reference_time: reference_time)
51
+ findings = []
52
+ products = []
53
+ @modifier_products = {}
54
+ @mod_dup_counts = Hash.new(0)
55
+
56
+ findings << finding("EXPORT-002", "menu", doc["name"] || "(unnamed)",
57
+ "menu-level service windows have no UCP catalog construct (unchanged by #821)") if present?(doc["availability"])
58
+ findings << finding("EXPORT-002", "menu", doc["name"] || "(unnamed)",
59
+ "special_hours have no UCP catalog construct (unchanged by #821)") if present?(doc["special_hours"])
60
+ menu.categories_by_id.each_value do |c|
61
+ next unless present?(c["availability"])
62
+ findings << finding("EXPORT-002", "category", c["id"],
63
+ "category availability windows have no UCP catalog construct (unchanged by #821)")
64
+ end
65
+
66
+ menu.items_by_id.each_value do |item|
67
+ next if menu.depth_info(item)[:cyclic]
68
+ product, item_findings = export_item(menu, item)
69
+ findings.concat(item_findings)
70
+ products << product if product
71
+ end
72
+
73
+ duplicated = @mod_dup_counts.select { |_, n| n > 1 }
74
+ unless duplicated.empty?
75
+ extra = duplicated.values.sum - duplicated.size
76
+ findings << finding("EXPORT-002", "menu", doc["name"] || "(unnamed)",
77
+ "#{duplicated.size} modifier(s) carry context-dependent prices and had to be duplicated into " \
78
+ "#{extra} extra product(s) — #821 option values have no per-reference price override " \
79
+ "(affected: #{duplicated.keys.sort.join(', ')})")
80
+ end
81
+
82
+ artifact = {
83
+ "ucp_spec_version" => UcpExporter::UCP_SPEC_PIN,
84
+ "experimental_proposal" => @proposal,
85
+ "generated_by" => "menuconform #{VERSION}",
86
+ "catalog" => { "products" => products + @modifier_products.values.sort_by { |p| p["id"] } }
87
+ }
88
+ { artifact: artifact, findings: findings.each { |f| f.severity = @catalog.severity(f.rule) } }
89
+ end
90
+
91
+ private
92
+
93
+ def export_item(menu, item)
94
+ findings = []
95
+ depth = menu.depth_info(item)[:max_depth]
96
+ if depth + 1 > @depth_cap
97
+ findings << finding("EXPORT-001", "item", item["id"],
98
+ "nesting needs #{depth + 1} products (item + #{depth} modifier levels); " \
99
+ "the cap is #{@depth_cap} — this item cannot be represented")
100
+ return [nil, findings]
101
+ end
102
+
103
+ findings.concat(lossiness_findings(menu, item))
104
+ [build_product(menu, item), findings]
105
+ end
106
+
107
+ def build_product(menu, item)
108
+ currency = menu.doc["currency"]
109
+ options = (item["modifier_group_ids"] || []).uniq.filter_map { |gid| group_option(menu, gid, [], nil) }
110
+ (item["slots"] || []).each do |slot|
111
+ (slot["modifier_group_ids"] || []).uniq.each do |gid|
112
+ opt = group_option(menu, gid, [], slot["name"])
113
+ options << opt if opt
114
+ end
115
+ end
116
+
117
+ base = {
118
+ "id" => item["id"],
119
+ "title" => item["name"],
120
+ "description" => { "plain" => item["description"] || item["name"] || "" },
121
+ "price_range" => {
122
+ "min" => { "amount" => item["price"], "currency" => currency },
123
+ "max" => { "amount" => item["price"], "currency" => currency }
124
+ },
125
+ "variants" => [base_variant(menu, item, currency)]
126
+ }
127
+ base["options"] = options unless options.empty?
128
+ base["tags"] = item["dietary"] if item["dietary"]&.any?
129
+ base
130
+ end
131
+
132
+ def base_variant(menu, item, currency)
133
+ v = {
134
+ "id" => "#{item['id']}~base",
135
+ "title" => item["name"],
136
+ "description" => { "plain" => item["description"] || item["name"] || "" },
137
+ "price" => { "amount" => item["price"], "currency" => currency }
138
+ }
139
+ v["availability"] = { "available" => false, "status" => "out_of_stock" } unless orderable?(menu, item)
140
+ v
141
+ end
142
+
143
+ def group_option(menu, gid, stack, slot_name)
144
+ g = menu.groups_by_id[gid]
145
+ return nil if g.nil? || stack.include?(gid)
146
+ values = (g["options"] || []).filter_map do |link|
147
+ next unless menu.modifiers_by_id.key?(link["modifier_id"])
148
+ pid = modifier_product_id(menu, link, stack + [gid])
149
+ value = { "id" => "#{gid}:#{link['modifier_id']}", "label" => menu.modifiers_by_id[link["modifier_id"]]["name"],
150
+ "product_id" => pid }
151
+ if @selection_rules && (qr = quantity_rule(link))
152
+ value["quantity_rule"] = qr
153
+ end
154
+ value
155
+ end
156
+ return nil if values.empty?
157
+ option = { "name" => slot_name ? "#{slot_name}: #{g['name']}" : g["name"], "values" => values }
158
+ if @selection_rules && (sr = selection_rule(g))
159
+ option["selection_rule"] = sr
160
+ end
161
+ option
162
+ end
163
+
164
+ # ucp#824 selection_rule from the IR group's two constraint axes.
165
+ def selection_rule(g)
166
+ rule = {}
167
+ rule["min_distinct"] = g["min_select"] if (g["min_select"] || 0).positive?
168
+ rule["max_distinct"] = g["max_select"] unless g["max_select"].nil?
169
+ rule["min_units"] = g["min_total_units"] if (g["min_total_units"] || 0).positive?
170
+ rule["max_units"] = g["max_total_units"] unless g["max_total_units"].nil?
171
+ rule.empty? ? nil : rule
172
+ end
173
+
174
+ # ucp#824 quantity_rule from the IR option link.
175
+ def quantity_rule(link)
176
+ rule = {}
177
+ rule["min"] = link["min_quantity"] if (link["min_quantity"] || 0) > 1
178
+ rule["max"] = link["max_quantity"] if (link["max_quantity"] || 1) != 1
179
+ rule["default"] = link["default_quantity"] if (link["default_quantity"] || 0).positive?
180
+ rule.empty? ? nil : rule
181
+ end
182
+
183
+ # Shared when link-resolved price and child groups agree; duplicated
184
+ # otherwise (measured for the per-reference-override argument).
185
+ def modifier_product_id(menu, link, stack)
186
+ mod = menu.modifiers_by_id[link["modifier_id"]]
187
+ price = link.key?("price_override") ? link["price_override"] : mod["price"]
188
+ children = (link["child_modifier_group_ids"] || []).uniq
189
+ signature = [mod["id"], price, children]
190
+ return @modifier_products_by_sig[signature] if @modifier_products_by_sig&.key?(signature)
191
+
192
+ @modifier_products_by_sig ||= {}
193
+ @mod_dup_counts[mod["id"]] += 1
194
+ pid = @mod_dup_counts[mod["id"]] == 1 ? "mod:#{mod['id']}" : "mod:#{mod['id']}~#{@mod_dup_counts[mod['id']]}"
195
+ @modifier_products_by_sig[signature] = pid
196
+
197
+ currency = menu.doc["currency"]
198
+ product = {
199
+ "id" => pid,
200
+ "title" => mod["name"],
201
+ "description" => { "plain" => mod["description"] || mod["name"] || "" },
202
+ "price_range" => {
203
+ "min" => { "amount" => price, "currency" => currency },
204
+ "max" => { "amount" => price, "currency" => currency }
205
+ },
206
+ "variants" => [{
207
+ "id" => "#{pid}~base",
208
+ "title" => mod["name"],
209
+ "description" => { "plain" => mod["description"] || mod["name"] || "" },
210
+ "price" => { "amount" => price, "currency" => currency }
211
+ }]
212
+ }
213
+ child_options = children.filter_map { |cgid| group_option(menu, cgid, stack, nil) }
214
+ product["options"] = child_options unless child_options.empty?
215
+ product["tags"] = mod["dietary"] if mod["dietary"]&.any?
216
+ @modifier_products[pid] = product
217
+ pid
218
+ end
219
+
220
+ def lossiness_findings(menu, item)
221
+ out = []
222
+ id = item["id"]
223
+ reach = menu.reachable_group_ids(item).sort.filter_map { |gid| menu.groups_by_id[gid] }
224
+
225
+ unless @selection_rules
226
+ constrained = reach.select { |g| constrained?(g) }.map { |g| g["id"] }
227
+ unless constrained.empty?
228
+ out << finding("EXPORT-002", "item", id,
229
+ "selection constraints on #{constrained.join(', ')} dropped (#821 non-goal) — " \
230
+ "an agent can traverse this item but cannot validate a cart client-side")
231
+ end
232
+ end
233
+
234
+ conditional = reach.flat_map { |g| (g["options"] || []).select { |o| (o["conditional_prices"] || []).any? }.map { |o| o["modifier_id"] } }.uniq
235
+ unless conditional.empty?
236
+ out << finding("EXPORT-002", "item", id,
237
+ "conditional prices on #{conditional.join(', ')} exported at base price (estimate) — exact " \
238
+ "expression under #821 requires duplicating the subtree per trigger selection")
239
+ end
240
+
241
+ included = reach.select { |g| (g["included_quantity"] || 0).positive? }.map { |g| g["id"] }
242
+ unless included.empty?
243
+ out << finding("EXPORT-002", "item", id,
244
+ "included/free quantities on #{included.join(', ')} dropped — #821 pricing is strictly additive")
245
+ end
246
+
247
+ if item["slots"]&.any? && item["slot_pricing"] != "full_price"
248
+ out << finding("EXPORT-002", "item", id,
249
+ "half/half structure exports, but #{item['slot_pricing']} slot pricing is not additive — " \
250
+ "totals are estimates until cart-create")
251
+ end
252
+
253
+ if item["allergens"]
254
+ out << finding("EXPORT-002", "item", id, "allergen declarations still have no catalog field (unchanged by #821)")
255
+ end
256
+ if item["min_age"]
257
+ out << finding("EXPORT-002", "item", id, "min_age #{item['min_age']} still has no catalog field (unchanged by #821)")
258
+ end
259
+ if item["dietary"]&.any?
260
+ out << finding("EXPORT-002", "item", id, "dietary claims still degrade to free-text tags (unchanged by #821)")
261
+ end
262
+ if present?(item["availability"])
263
+ out << finding("EXPORT-002", "item", id, "item availability windows still have no catalog construct (unchanged by #821)")
264
+ end
265
+ if item["suspended_until"]
266
+ out << finding("EXPORT-002", "item", id, "timed suspension still maps to a bare out_of_stock flag (unchanged by #821)")
267
+ end
268
+ out
269
+ end
270
+
271
+ def constrained?(g)
272
+ (g["min_select"] || 0).positive? || !g["max_select"].nil? ||
273
+ g.key?("min_total_units") || !g["max_total_units"].nil? ||
274
+ (g["options"] || []).any? { |o| (o["default_quantity"] || 0).positive? || (o["max_quantity"] || 1) != 1 || (o["min_quantity"] || 0).positive? }
275
+ end
276
+
277
+ def orderable?(menu, item)
278
+ return false if item["active"] == false
279
+ raw = item["suspended_until"]
280
+ return true unless raw
281
+ t = begin
282
+ Time.iso8601(raw)
283
+ rescue ArgumentError
284
+ nil
285
+ end
286
+ t.nil? || t <= menu.reference_time
287
+ end
288
+
289
+ def present?(value)
290
+ !value.nil? && !value.empty?
291
+ end
292
+
293
+ def finding(rule, type, id, message)
294
+ Finding.new(rule, type, id, message)
295
+ end
296
+ end
297
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Menuconform
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
data/lib/menuconform.rb CHANGED
@@ -16,6 +16,7 @@ require_relative "menuconform/solver"
16
16
  require_relative "menuconform/rules/cart_rules"
17
17
  require_relative "menuconform/scorer"
18
18
  require_relative "menuconform/ucp_exporter"
19
+ require_relative "menuconform/nested_options_exporter"
19
20
  require_relative "menuconform/report"
20
21
  require_relative "menuconform/engine"
21
22
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: menuconform
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Amit Solanki
@@ -33,6 +33,7 @@ executables:
33
33
  extensions: []
34
34
  extra_rdoc_files: []
35
35
  files:
36
+ - CHANGELOG.md
36
37
  - LICENSE
37
38
  - README.md
38
39
  - config/importers/ncr_menu.json
@@ -46,6 +47,7 @@ files:
46
47
  - lib/menuconform/importers/base.rb
47
48
  - lib/menuconform/importers/ncr_menu.rb
48
49
  - lib/menuconform/menu.rb
50
+ - lib/menuconform/nested_options_exporter.rb
49
51
  - lib/menuconform/report.rb
50
52
  - lib/menuconform/rules/age_rules.rb
51
53
  - lib/menuconform/rules/allergen_rules.rb