latticerb 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,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lattice
4
+ # MT19937 matching CPython's `random.Random` bit-for-bit — same integer
5
+ # seeding (init_by_array over 32-bit words), same 53-bit `random`, same
6
+ # little-endian word order for `getrandbits` — so a given seed produces
7
+ # identical covering arrays here and in the Python implementation.
8
+ class MersenneTwister
9
+ N = 624
10
+ M = 397
11
+ MATRIX_A = 0x9908b0df
12
+ UPPER_MASK = 0x80000000
13
+ LOWER_MASK = 0x7fffffff
14
+ MASK32 = 0xffffffff
15
+
16
+ def initialize(seed)
17
+ seed = seed.abs
18
+ key = []
19
+ while seed.positive?
20
+ key << (seed & MASK32)
21
+ seed >>= 32
22
+ end
23
+ key << 0 if key.empty?
24
+ init_by_array(key)
25
+ end
26
+
27
+ def random
28
+ a = genrand_uint32 >> 5
29
+ b = genrand_uint32 >> 6
30
+ (a * 67_108_864.0 + b) * (1.0 / 9_007_199_254_740_992.0)
31
+ end
32
+
33
+ def getrandbits(bits)
34
+ result = 0
35
+ shift = 0
36
+ while bits.positive?
37
+ word = genrand_uint32
38
+ word >>= (32 - bits) if bits < 32
39
+ result |= word << shift
40
+ shift += 32
41
+ bits -= 32
42
+ end
43
+ result
44
+ end
45
+
46
+ private
47
+
48
+ def init_genrand(seed)
49
+ @mt = Array.new(N)
50
+ @mt[0] = seed & MASK32
51
+ (1...N).each do |i|
52
+ @mt[i] = (1_812_433_253 * (@mt[i - 1] ^ (@mt[i - 1] >> 30)) + i) & MASK32
53
+ end
54
+ @mti = N
55
+ end
56
+
57
+ def init_by_array(key)
58
+ init_genrand(19_650_218)
59
+ i = 1
60
+ j = 0
61
+ count = [N, key.length].max
62
+ while count.positive?
63
+ @mt[i] = ((@mt[i] ^ ((@mt[i - 1] ^ (@mt[i - 1] >> 30)) * 1_664_525)) + key[j] + j) & MASK32
64
+ i += 1
65
+ j += 1
66
+ if i >= N
67
+ @mt[0] = @mt[N - 1]
68
+ i = 1
69
+ end
70
+ j = 0 if j >= key.length
71
+ count -= 1
72
+ end
73
+ count = N - 1
74
+ while count.positive?
75
+ @mt[i] = ((@mt[i] ^ ((@mt[i - 1] ^ (@mt[i - 1] >> 30)) * 1_566_083_941)) - i) & MASK32
76
+ i += 1
77
+ if i >= N
78
+ @mt[0] = @mt[N - 1]
79
+ i = 1
80
+ end
81
+ count -= 1
82
+ end
83
+ @mt[0] = 0x80000000
84
+ end
85
+
86
+ def genrand_uint32
87
+ if @mti >= N
88
+ (0...N).each do |kk|
89
+ y = (@mt[kk] & UPPER_MASK) | (@mt[(kk + 1) % N] & LOWER_MASK)
90
+ @mt[kk] = @mt[(kk + M) % N] ^ (y >> 1) ^ (y.odd? ? MATRIX_A : 0)
91
+ end
92
+ @mti = 0
93
+ end
94
+ y = @mt[@mti]
95
+ @mti += 1
96
+ y ^= (y >> 11)
97
+ y = (y ^ ((y << 7) & 0x9d2c5680)) & MASK32
98
+ y = (y ^ ((y << 15) & 0xefc60000)) & MASK32
99
+ y ^ (y >> 18)
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ module Lattice
6
+ NA_VALUE = "N/A"
7
+
8
+ def self.normalize_scalar(value)
9
+ value.nil? ? "null" : value.to_s
10
+ end
11
+
12
+ Assignment = Data.define(:parameter, :value) do
13
+ include Comparable
14
+
15
+ def <=>(other)
16
+ [parameter, value] <=> [other.parameter, other.value]
17
+ end
18
+ end
19
+
20
+ class Parameter
21
+ attr_reader :name, :values, :weights
22
+
23
+ def initialize(name:, values:, weights: nil)
24
+ @name = name
25
+ @values = values.to_a.freeze
26
+ @weights = weights&.to_a&.freeze
27
+ end
28
+
29
+ def value_set
30
+ @value_set ||= Set.new(values).freeze
31
+ end
32
+
33
+ # Weight normalization mirrors the Python implementation, including its
34
+ # naive left-to-right float summation, so scores tie-break identically.
35
+ def normalized_weights
36
+ @normalized_weights ||=
37
+ if weights.nil?
38
+ Array.new(values.length, 1.0 / values.length).freeze
39
+ else
40
+ total = weights.inject(0.0) { |acc, weight| acc + weight }
41
+ if total <= 0
42
+ Array.new(values.length, 1.0 / values.length).freeze
43
+ else
44
+ weights.map { |weight| weight / total }.freeze
45
+ end
46
+ end
47
+ end
48
+ end
49
+
50
+ InvalidPairConstraint = Data.define(:if_assignment, :then_not)
51
+ BidirectionalConstraint = Data.define(:pair)
52
+ ForwardDependencyConstraint = Data.define(:source, :target)
53
+ ConditionalConstraint = Data.define(:parameter, :values, :parent, :applies_when)
54
+ ForcedConstraint = Data.define(:assignments)
55
+ HigherOrderConstraint = Data.define(:if_all, :then_not)
56
+
57
+ class Model
58
+ attr_reader :model_name, :parameters, :constraints, :strength
59
+
60
+ def initialize(model_name:, parameters:, constraints:, strength: 2)
61
+ @model_name = model_name
62
+ @parameters = parameters.to_a.freeze
63
+ @constraints = constraints.to_a.freeze
64
+ @strength = strength
65
+ end
66
+
67
+ def parameter_names
68
+ @parameter_names ||= parameters.map(&:name).freeze
69
+ end
70
+
71
+ def parameter_lookup
72
+ @parameter_lookup ||= parameters.to_h { |parameter| [parameter.name, parameter] }.freeze
73
+ end
74
+
75
+ def exhaustive_count
76
+ @exhaustive_count ||= parameters.inject(1) { |total, parameter| total * parameter.values.length }
77
+ end
78
+
79
+ def with(strength:)
80
+ Model.new(model_name:, parameters:, constraints:, strength:)
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,387 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "yaml"
5
+
6
+ require_relative "model"
7
+
8
+ module Lattice
9
+ class ModelIOError < StandardError; end
10
+
11
+ class ValidationError < StandardError
12
+ attr_reader :errors
13
+
14
+ def initialize(errors)
15
+ @errors = errors
16
+ super(errors.join("\n"))
17
+ end
18
+ end
19
+
20
+ module Parser
21
+ extend self
22
+
23
+ def load_model(path, strength_override: nil)
24
+ text, source_name, extension = read_model_input(path)
25
+ data = deserialize_model(text, source_name, extension)
26
+ parse_model_data(data, strength_override:)
27
+ end
28
+
29
+ def parse_model_data(data, strength_override: nil)
30
+ raise ValidationError, ["Model root must be a JSON/YAML object."] unless data.is_a?(Hash)
31
+
32
+ errors = []
33
+ parameters = parse_parameters(data["parameters"], errors)
34
+ raw_constraints = data.fetch("constraints", []) || []
35
+ unless raw_constraints.is_a?(Array)
36
+ errors << "`constraints` must be a list."
37
+ raw_constraints = []
38
+ end
39
+
40
+ apply_conditional_parameters(parameters, raw_constraints, errors)
41
+ constraints = parse_constraints(raw_constraints, parameters, errors)
42
+ strength = parse_strength(strength_override.nil? ? data["strength"] : strength_override, parameters.length, errors)
43
+ model_name = Lattice.normalize_scalar(data.fetch("model_name", "unnamed_model"))
44
+
45
+ raise ValidationError, errors unless errors.empty?
46
+
47
+ Model.new(
48
+ model_name:,
49
+ parameters: parameters.values,
50
+ constraints:,
51
+ strength:
52
+ )
53
+ end
54
+
55
+ def read_model_input(path)
56
+ if path.nil? || path == "-"
57
+ text = $stdin.read
58
+ raise ModelIOError, "No model content found on stdin." if text.strip.empty?
59
+
60
+ return [text, "stdin", nil]
61
+ end
62
+
63
+ begin
64
+ text = File.read(path, encoding: "UTF-8")
65
+ rescue SystemCallError, IOError => e
66
+ raise ModelIOError, "Unable to read model file `#{path}`: #{e.message}"
67
+ end
68
+ [text, path, File.extname(path).downcase]
69
+ end
70
+
71
+ def deserialize_model(text, source_name, extension)
72
+ loaders =
73
+ case extension
74
+ when ".json" then [:json]
75
+ when ".yaml", ".yml" then [:yaml]
76
+ else [:json, :yaml]
77
+ end
78
+
79
+ errors = []
80
+ loaders.each do |loader|
81
+ return loader == :json ? JSON.parse(text) : safe_load_yaml(text)
82
+ rescue StandardError => e
83
+ errors << e.message
84
+ end
85
+
86
+ raise ModelIOError, "Unable to parse model from `#{source_name}`: #{errors.join('; ')}"
87
+ end
88
+
89
+ def safe_load_yaml(text)
90
+ YAML.safe_load(text, permitted_classes: [Date, Time], aliases: true)
91
+ end
92
+
93
+ def parse_parameters(raw_parameters, errors)
94
+ parameters = {}
95
+ if raw_parameters.nil?
96
+ errors << "`parameters` is required."
97
+ return parameters
98
+ end
99
+
100
+ case raw_parameters
101
+ when Hash
102
+ raw_parameters.each { |name, spec| add_parameter(parameters, name, spec, errors) }
103
+ when Array
104
+ raw_parameters.each_with_index do |raw_parameter, index|
105
+ unless raw_parameter.is_a?(Hash)
106
+ errors << "`parameters[#{index}]` must be an object."
107
+ next
108
+ end
109
+ name = raw_parameter["name"]
110
+ if name.nil?
111
+ errors << "`parameters[#{index}]` is missing `name`."
112
+ next
113
+ end
114
+ add_parameter(parameters, name, raw_parameter, errors)
115
+ end
116
+ else
117
+ errors << "`parameters` must be a mapping or list."
118
+ end
119
+ parameters
120
+ end
121
+
122
+ def add_parameter(parameters, name, spec, errors)
123
+ normalized_name = Lattice.normalize_scalar(name)
124
+ if parameters.key?(normalized_name)
125
+ errors << "Duplicate parameter `#{normalized_name}`."
126
+ return
127
+ end
128
+
129
+ if spec.is_a?(Hash)
130
+ values = spec["values"]
131
+ weights = spec["weights"]
132
+ else
133
+ values = spec
134
+ weights = nil
135
+ end
136
+
137
+ parsed_values = parse_values(values, "parameter `#{normalized_name}`", errors)
138
+ parsed_weights = parse_weights(weights, parsed_values, normalized_name, errors)
139
+ return if parsed_values.empty?
140
+
141
+ parameters[normalized_name] = Parameter.new(
142
+ name: normalized_name,
143
+ values: parsed_values,
144
+ weights: parsed_weights
145
+ )
146
+ end
147
+
148
+ def apply_conditional_parameters(parameters, raw_constraints, errors)
149
+ raw_constraints.each_with_index do |raw_constraint, index|
150
+ next unless raw_constraint.is_a?(Hash)
151
+ next unless raw_constraint["type"] == "conditional"
152
+
153
+ parameter_name = Lattice.normalize_scalar(raw_constraint["parameter"])
154
+ values = parse_values(raw_constraint["values"], "`constraints[#{index}]` conditional values", errors)
155
+ next if values.empty?
156
+
157
+ conditional_values = values
158
+ conditional_values = conditional_values + [NA_VALUE] unless conditional_values.include?(NA_VALUE)
159
+
160
+ existing = parameters[parameter_name]
161
+ if existing.nil?
162
+ parameters[parameter_name] = Parameter.new(name: parameter_name, values: conditional_values)
163
+ next
164
+ end
165
+
166
+ next if Set.new(existing.values) == Set.new(conditional_values)
167
+
168
+ errors << (
169
+ "Conditional parameter `#{parameter_name}` conflicts with an existing parameter definition. " \
170
+ "Use `conditional` only to define a child parameter that applies under parent values. " \
171
+ "For exclusions or requirements between existing parameters, use `invalid_pair`, " \
172
+ "`higher_order`, `forward_dep`, or `bidirectional`; run `lattice agent instructions` " \
173
+ "for the full schema reference."
174
+ )
175
+ end
176
+ end
177
+
178
+ def parse_constraints(raw_constraints, parameters, errors)
179
+ parsed = []
180
+ raw_constraints.each_with_index do |raw_constraint, index|
181
+ unless raw_constraint.is_a?(Hash)
182
+ errors << "`constraints[#{index}]` must be an object."
183
+ next
184
+ end
185
+
186
+ case raw_constraint["type"]
187
+ when "invalid_pair"
188
+ if_assignment = parse_assignment(raw_constraint["if"], parameters, "`constraints[#{index}].if`", errors)
189
+ then_not = parse_assignment(raw_constraint["then_not"], parameters, "`constraints[#{index}].then_not`", errors)
190
+ parsed << InvalidPairConstraint.new(if_assignment:, then_not:) if if_assignment && then_not
191
+ when "bidirectional"
192
+ pair = raw_constraint["pair"]
193
+ unless pair.is_a?(Array) && pair.length == 2
194
+ errors << "`constraints[#{index}].pair` must contain exactly two assignments."
195
+ next
196
+ end
197
+ left = parse_assignment(pair[0], parameters, "`constraints[#{index}].pair[0]`", errors)
198
+ right = parse_assignment(pair[1], parameters, "`constraints[#{index}].pair[1]`", errors)
199
+ parsed << BidirectionalConstraint.new(pair: [left, right]) if left && right
200
+ when "forward_dep"
201
+ source = parse_assignment(raw_constraint["source"], parameters, "`constraints[#{index}].source`", errors)
202
+ target = parse_assignment(raw_constraint["target"], parameters, "`constraints[#{index}].target`", errors)
203
+ parsed << ForwardDependencyConstraint.new(source:, target:) if source && target
204
+ when "conditional"
205
+ constraint = parse_conditional_constraint(raw_constraint, index, parameters, errors)
206
+ parsed << constraint if constraint
207
+ when "forced"
208
+ constraint = parse_forced_constraint(raw_constraint, index, parameters, errors)
209
+ parsed << constraint if constraint
210
+ when "higher_order"
211
+ constraint = parse_higher_order_constraint(raw_constraint, index, parameters, errors)
212
+ parsed << constraint if constraint
213
+ else
214
+ errors << "`constraints[#{index}]` has unknown type `#{raw_constraint['type']}`."
215
+ end
216
+ end
217
+ parsed
218
+ end
219
+
220
+ def parse_conditional_constraint(raw_constraint, index, parameters, errors)
221
+ parent = Lattice.normalize_scalar(raw_constraint["parent"])
222
+ parameter_name = Lattice.normalize_scalar(raw_constraint["parameter"])
223
+ values = parse_values(raw_constraint["values"], "`constraints[#{index}].values`", errors)
224
+ applies_when = parse_values(raw_constraint["applies_when"], "`constraints[#{index}].applies_when`", errors)
225
+
226
+ unless parameters.key?(parent)
227
+ errors << "`constraints[#{index}]` references unknown parent parameter `#{parent}`."
228
+ return nil
229
+ end
230
+ unless parameters.key?(parameter_name)
231
+ errors << (
232
+ "`constraints[#{index}]` references unknown conditional parameter `#{parameter_name}`. " \
233
+ "`conditional` must define a child parameter with `values` and a parent gate. " \
234
+ "For exclusions or requirements between existing parameters, use `invalid_pair`, " \
235
+ "`higher_order`, `forward_dep`, or `bidirectional`; run `lattice agent instructions` " \
236
+ "for the full schema reference."
237
+ )
238
+ return nil
239
+ end
240
+
241
+ parent_parameter = parameters[parent]
242
+ missing = applies_when.reject { |value| parent_parameter.value_set.include?(value) }
243
+ unless missing.empty?
244
+ errors << "`constraints[#{index}]` applies_when contains unknown values for `#{parent}`: #{missing.join(', ')}."
245
+ return nil
246
+ end
247
+
248
+ return nil if values.empty?
249
+
250
+ ConditionalConstraint.new(
251
+ parameter: parameter_name,
252
+ values: values.reject { |value| value == NA_VALUE },
253
+ parent:,
254
+ applies_when:
255
+ )
256
+ end
257
+
258
+ def parse_forced_constraint(raw_constraint, index, parameters, errors)
259
+ assignments = raw_constraint["assignments"]
260
+ unless assignments.is_a?(Array) && !assignments.empty?
261
+ errors << "`constraints[#{index}].assignments` must contain at least one assignment."
262
+ return nil
263
+ end
264
+
265
+ seen_parameters = Set.new
266
+ parsed_assignments = []
267
+ assignments.each_with_index do |raw_assignment, assignment_index|
268
+ assignment = parse_assignment(
269
+ raw_assignment,
270
+ parameters,
271
+ "`constraints[#{index}].assignments[#{assignment_index}]`",
272
+ errors
273
+ )
274
+ next if assignment.nil?
275
+
276
+ if seen_parameters.include?(assignment.parameter)
277
+ errors << "`constraints[#{index}]` repeats parameter `#{assignment.parameter}` inside a forced interaction."
278
+ next
279
+ end
280
+ seen_parameters.add(assignment.parameter)
281
+ parsed_assignments << assignment
282
+ end
283
+ return nil if parsed_assignments.empty?
284
+
285
+ ForcedConstraint.new(assignments: parsed_assignments)
286
+ end
287
+
288
+ def parse_higher_order_constraint(raw_constraint, index, parameters, errors)
289
+ if_all = raw_constraint["if_all"]
290
+ unless if_all.is_a?(Array) && if_all.length >= 2
291
+ errors << "`constraints[#{index}].if_all` must contain at least two assignments."
292
+ return nil
293
+ end
294
+
295
+ parsed_if_all = if_all.each_with_index.filter_map do |raw_assignment, assignment_index|
296
+ parse_assignment(raw_assignment, parameters, "`constraints[#{index}].if_all[#{assignment_index}]`", errors)
297
+ end
298
+ parsed_then_not = parse_assignment(raw_constraint["then_not"], parameters, "`constraints[#{index}].then_not`", errors)
299
+ return nil unless !parsed_if_all.empty? && parsed_then_not
300
+
301
+ HigherOrderConstraint.new(if_all: parsed_if_all, then_not: parsed_then_not)
302
+ end
303
+
304
+ def parse_assignment(raw_assignment, parameters, field_name, errors)
305
+ unless raw_assignment.is_a?(Hash)
306
+ errors << "#{field_name} must be an object."
307
+ return nil
308
+ end
309
+
310
+ parameter_name = Lattice.normalize_scalar(raw_assignment["parameter"])
311
+ value = Lattice.normalize_scalar(raw_assignment["value"])
312
+
313
+ parameter = parameters[parameter_name]
314
+ if parameter.nil?
315
+ errors << "#{field_name} references unknown parameter `#{parameter_name}`."
316
+ return nil
317
+ end
318
+
319
+ unless parameter.value_set.include?(value)
320
+ errors << "#{field_name} references unknown value `#{value}` for parameter `#{parameter_name}`."
321
+ return nil
322
+ end
323
+
324
+ Assignment.new(parameter: parameter_name, value:)
325
+ end
326
+
327
+ def parse_values(raw_values, field_name, errors)
328
+ unless raw_values.is_a?(Array) && !raw_values.empty?
329
+ errors << "#{field_name} must be a non-empty list."
330
+ return []
331
+ end
332
+
333
+ values = raw_values.map { |value| Lattice.normalize_scalar(value) }
334
+ if values.uniq.length != values.length
335
+ errors << "#{field_name} contains duplicate values."
336
+ return []
337
+ end
338
+ values
339
+ end
340
+
341
+ def parse_weights(raw_weights, values, parameter_name, errors)
342
+ return nil if raw_weights.nil?
343
+
344
+ unless raw_weights.is_a?(Array)
345
+ errors << "`weights` for parameter `#{parameter_name}` must be a list."
346
+ return nil
347
+ end
348
+ if raw_weights.length != values.length
349
+ errors << "`weights` for parameter `#{parameter_name}` must have the same length as its values."
350
+ return nil
351
+ end
352
+
353
+ parsed = []
354
+ raw_weights.each do |weight|
355
+ unless weight.is_a?(Numeric)
356
+ errors << "`weights` for parameter `#{parameter_name}` must be numeric."
357
+ return nil
358
+ end
359
+ unless weight.positive?
360
+ errors << "`weights` for parameter `#{parameter_name}` must be greater than zero."
361
+ return nil
362
+ end
363
+ parsed << weight.to_f
364
+ end
365
+ parsed
366
+ end
367
+
368
+ def parse_strength(raw_strength, parameter_count, errors)
369
+ strength =
370
+ case raw_strength
371
+ when nil then 2
372
+ when Numeric then raw_strength.to_i
373
+ else
374
+ begin
375
+ Integer(raw_strength)
376
+ rescue TypeError, ArgumentError
377
+ errors << "`strength` must be an integer."
378
+ return 2
379
+ end
380
+ end
381
+
382
+ errors << "`strength` must be at least 1." if strength < 1
383
+ errors << "`strength` cannot exceed the number of parameters." if parameter_count.positive? && strength > parameter_count
384
+ strength
385
+ end
386
+ end
387
+ end