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.
- checksums.yaml +7 -0
- data/LICENSE +176 -0
- data/README.md +121 -0
- data/exe/lattice +7 -0
- data/lib/lattice/agent.rb +153 -0
- data/lib/lattice/cli.rb +469 -0
- data/lib/lattice/constraints.rb +357 -0
- data/lib/lattice/formatters.rb +126 -0
- data/lib/lattice/ipog.rb +222 -0
- data/lib/lattice/mersenne_twister.rb +102 -0
- data/lib/lattice/model.rb +83 -0
- data/lib/lattice/parser.rb +387 -0
- data/lib/lattice/resources/claude/lattice-workflow/SKILL.md +142 -0
- data/lib/lattice/resources/claude/lattice-workflow/references/modeling-rules.md +52 -0
- data/lib/lattice/resources/claude/lattice-workflow/references/worked-examples.md +59 -0
- data/lib/lattice/resources/codex/lattice-workflow/SKILL.md +142 -0
- data/lib/lattice/resources/codex/lattice-workflow/agents/openai.yaml +7 -0
- data/lib/lattice/resources/codex/lattice-workflow/references/modeling-rules.md +52 -0
- data/lib/lattice/resources/codex/lattice-workflow/references/worked-examples.md +59 -0
- data/lib/lattice/resources/generic/lattice-workflow/SKILL.md +150 -0
- data/lib/lattice/resources/generic/lattice-workflow/references/modeling-rules.md +52 -0
- data/lib/lattice/resources/generic/lattice-workflow/references/worked-examples.md +59 -0
- data/lib/lattice/version.rb +5 -0
- data/lib/lattice.rb +87 -0
- data/lib/latticerb.rb +4 -0
- metadata +86 -0
|
@@ -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
|