ltntorch 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.
Files changed (41) hide show
  1. checksums.yaml +7 -0
  2. data/AGENTS.md +34 -0
  3. data/CONTRIBUTING.md +66 -0
  4. data/changelog.md +68 -0
  5. data/code_of_conduct.md +84 -0
  6. data/examples/1_binary_classification.rb +108 -0
  7. data/examples/2_multi_class_single_label_classification.rb +130 -0
  8. data/examples/3_multi_class_multi_label_classification.rb +158 -0
  9. data/examples/4_semi_supervised_pattern_recognition.rb +249 -0
  10. data/examples/5_regression.rb +103 -0
  11. data/examples/6_clustering.rb +126 -0
  12. data/examples/7_learning_embeddings_with_ltn.rb +166 -0
  13. data/examples/datasets/crabs.dat +201 -0
  14. data/examples/datasets/iris_test.csv +31 -0
  15. data/examples/datasets/iris_training.csv +121 -0
  16. data/examples/datasets/readme.md +9 -0
  17. data/examples/datasets/real-estate.csv +415 -0
  18. data/examples/support/example.rb +92 -0
  19. data/examples/support/mnist.rb +77 -0
  20. data/lib/ltn/connective.rb +56 -0
  21. data/lib/ltn/constant.rb +30 -0
  22. data/lib/ltn/diag.rb +54 -0
  23. data/lib/ltn/function.rb +28 -0
  24. data/lib/ltn/fuzzy_ops.rb +428 -0
  25. data/lib/ltn/lambda_model.rb +24 -0
  26. data/lib/ltn/ltn_object.rb +47 -0
  27. data/lib/ltn/model_wrapper.rb +72 -0
  28. data/lib/ltn/predicate.rb +38 -0
  29. data/lib/ltn/process_ltn_objects.rb +58 -0
  30. data/lib/ltn/quantifier.rb +176 -0
  31. data/lib/ltn/variable.rb +49 -0
  32. data/lib/ltn/version.rb +8 -0
  33. data/lib/ltn.rb +45 -0
  34. data/license.md +22 -0
  35. data/readme.md +166 -0
  36. data/sig/ltn.rbs +207 -0
  37. data/tutorials/1_grounding_non_logical_symbols.rb +146 -0
  38. data/tutorials/2_grounding_connectives.rb +126 -0
  39. data/tutorials/2b_operators_and_gradients.rb +78 -0
  40. data/tutorials/3_knowledgebase_and_learning.rb +124 -0
  41. metadata +94 -0
data/lib/ltn/diag.rb ADDED
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Mauro Tortonesi.
5
+ # Ported from LTNtorch, copyright 2021-2024 by Tommaso Carraro.
6
+
7
+ module LTN
8
+ # Sets diagonal quantification on the given variables: they are no longer
9
+ # broadcast against each other but paired element-wise, the i-th individual
10
+ # of each with the i-th of the others. A quantifier over the variables
11
+ # resets them (see LTN.undiag).
12
+ #
13
+ # forall.call(LTN.diag(x, y), formula) # for all i, formula(x_i, y_i)
14
+ #
15
+ # This modifies the variables, replacing their free variables with a shared
16
+ # `diag_...` label.
17
+ #
18
+ # @parameter vars [Array(Variable)] at least two variables with the same number of individuals.
19
+ # @returns [Array(Variable)] the same variables.
20
+ # @raises [TypeError] if an argument is not a Variable.
21
+ # @raises [ArgumentError] if there is only one variable, or their numbers of individuals differ.
22
+ def self.diag(*vars)
23
+ unless vars.all?(Variable)
24
+ raise TypeError, "Expected parameter 'vars' to be Variables, but got #{vars.map(&:class).inspect}"
25
+ end
26
+ if vars.size < 2
27
+ raise ArgumentError, "Expected parameter 'vars' to be more than one Variable, but got just one Variable."
28
+ end
29
+ n_individuals = vars.map { |var| var.shape[0] }
30
+ if n_individuals.uniq.size != 1
31
+ raise ArgumentError, "Expected the given LTN variables to have the same number of individuals, " \
32
+ "but got the following numbers of individuals #{n_individuals.inspect}"
33
+ end
34
+
35
+ diag_label = "diag_" + vars.map(&:latent_var).join("_")
36
+ vars.each { |var| var.free_vars = [diag_label] }
37
+ vars
38
+ end
39
+
40
+ # Resets the given variables from diagonal quantification (see LTN.diag)
41
+ # to their own labels.
42
+ #
43
+ # @parameter vars [Array(Variable)] the variables.
44
+ # @returns [Array(Variable)] the same variables.
45
+ # @raises [TypeError] if an argument is not a Variable.
46
+ def self.undiag(*vars)
47
+ unless vars.all?(Variable)
48
+ raise TypeError, "Expected parameter 'vars' to be Variables, but got #{vars.map(&:class).inspect}"
49
+ end
50
+
51
+ vars.each { |var| var.free_vars = [var.latent_var] }
52
+ vars
53
+ end
54
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Mauro Tortonesi.
5
+ # Ported from LTNtorch, copyright 2021-2024 by Tommaso Carraro.
6
+
7
+ module LTN
8
+ # An LTN function: maps individuals to individuals (tensors of any shape).
9
+ class Function < Torch::NN::Module
10
+ include ModelWrapper
11
+
12
+ # Computes the function, after broadcasting the inputs (see
13
+ # LTN.process_ltn_objects): the result has one leading axis per free
14
+ # variable of the inputs, followed by the dimensions of each output.
15
+ #
16
+ # @parameter inputs [Array(LTNObject)] the arguments.
17
+ # @parameter kwargs [Hash] passed to the model.
18
+ # @returns [LTNObject]
19
+ # @raises [TypeError] if an input is not an LTNObject.
20
+ def forward(*inputs, **kwargs)
21
+ check_inputs(inputs)
22
+ processed, output_vars, output_shape = LTN.process_ltn_objects(inputs)
23
+ output = model.call(*processed.map(&:value), **kwargs)
24
+
25
+ LTNObject.new(output.reshape(output_shape + output.shape.drop(1)).float, output_vars)
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,428 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Mauro Tortonesi.
5
+ # Ported from LTNtorch, copyright 2021-2024 by Tommaso Carraro.
6
+
7
+ module LTN
8
+ # Fuzzy logic operators and aggregators, the building blocks of
9
+ # LTN::Connective and LTN::Quantifier. See the appendix of the LTN paper
10
+ # (https://arxiv.org/abs/2012.13635) for their definitions.
11
+ #
12
+ # All operators work on tensors of truth values and support torch's usual
13
+ # broadcasting. Each one responds to #call, so it can be invoked as
14
+ # `op.(x, y)`.
15
+ #
16
+ # Differences from LTNtorch's `ltn.fuzzy_ops`:
17
+ # - optional arguments are keywords: `AndProd.new(stable: false)`,
18
+ # `op.(x, y, stable: false)`, `agg.(xs, dim: [0], mask: m, p: 3)`;
19
+ # - Python's ValueError becomes ArgumentError;
20
+ # - SatAgg reports the shapes of non-scalar formulas instead of raising an
21
+ # unrelated error while building that message.
22
+ #
23
+ # Arithmetic between floats and tensors is written tensor-first
24
+ # (`x * c`, `Torch.rsub(x, 1.0)` for `1.0 - x`), and `where` goes through
25
+ # FuzzyOps.where: in torch.rb 0.27.0, `Float op Tensor` converts the float
26
+ # to a float32 tensor, and 0-dim tensors that do not require grad can
27
+ # dispatch to Scalar overloads that return float32 (see TODO_torch-rb.md).
28
+ # Either way, float64 truth values would lose their dtype or precision.
29
+ module FuzzyOps
30
+ # The epsilon of the projections #pi_0 and #pi_1, which keep the stable
31
+ # operators away from the regions where their gradients vanish or explode.
32
+ EPS = 1e-4
33
+
34
+ module_function
35
+
36
+ # Maps truth values from [0, 1] to (0, 1], so that 0 is never an input.
37
+ #
38
+ # @parameter x [Torch::Tensor] truth values.
39
+ # @returns [Torch::Tensor]
40
+ def pi_0(x)
41
+ x * (1 - EPS) + EPS
42
+ end
43
+
44
+ # Maps truth values from [0, 1] to [0, 1), so that 1 is never an input.
45
+ #
46
+ # @parameter x [Torch::Tensor] truth values.
47
+ # @returns [Torch::Tensor]
48
+ def pi_1(x)
49
+ x * (1 - EPS)
50
+ end
51
+
52
+ # @parameter values [Array(Torch::Tensor)] truth values.
53
+ # @raises [ArgumentError] if any value is outside [0, 1] (or NaN).
54
+ def check_values(*values)
55
+ values.each do |v|
56
+ next if Torch.all(Torch.logical_and(v.ge(0.0), v.le(1.0))).item
57
+
58
+ raise ArgumentError, "Expected inputs of connectives and quantifiers to be tensors of truth values " \
59
+ "in the range [0., 1.], but got some values outside this range."
60
+ end
61
+ end
62
+
63
+ # Checks the mask of a guarded quantification.
64
+ #
65
+ # @parameter mask [Torch::Tensor] the mask: true includes a value, false excludes it.
66
+ # @parameter xs [Torch::Tensor] the grounding of the formula being aggregated.
67
+ # @raises [ArgumentError] if the shapes differ or the mask is not boolean.
68
+ def check_mask(mask, xs)
69
+ raise ArgumentError, "'xs' and 'mask' must have the same shape." if mask.shape != xs.shape
70
+ raise ArgumentError, "'mask' must be a boolean tensor." unless mask.dtype == :bool
71
+ end
72
+
73
+ # Torch.where, safe for 0-dim tensors.
74
+ #
75
+ # In torch.rb 0.27.0, a 0-dim tensor that does not require grad also
76
+ # matches the Scalar parameters of Torch.where's overloads, and the Scalar
77
+ # overloads win, returning the default dtype (float32) whatever the dtype
78
+ # of the tensors. Lifting 0-dim tensors to shape [1] for the call avoids
79
+ # that; the result is the same as PyTorch's.
80
+ #
81
+ # @parameter condition [Torch::Tensor] where to take `input`.
82
+ # @parameter input [Torch::Tensor, Numeric] the values where `condition` holds.
83
+ # @parameter other [Torch::Tensor, Numeric] the values elsewhere.
84
+ # @returns [Torch::Tensor]
85
+ def where(condition, input, other)
86
+ arguments = [condition, input, other]
87
+ tensors = arguments.grep(Torch::Tensor)
88
+ return Torch.where(condition, input, other) unless tensors.any? { |t| t.dim == 0 }
89
+
90
+ lifted = arguments.map { |a| (a.is_a?(Torch::Tensor) && a.dim == 0) ? a.reshape([1]) : a }
91
+ result = Torch.where(*lifted)
92
+ (tensors.all? { |t| t.dim == 0 }) ? result.reshape([]) : result
93
+ end
94
+
95
+ # @parameter dim [Integer, Array(Integer), nil] the dimension(s) to reduce;
96
+ # nil reduces all of them.
97
+ # @returns [Array(Integer)] the dimensions in the form torch.rb expects,
98
+ # where the empty array reduces all of them.
99
+ def reduction_dims(dim)
100
+ dim.nil? ? [] : Array(dim)
101
+ end
102
+
103
+ # Shared by the operators with a stable variant: the `stable` flag can be
104
+ # set in the constructor and overridden on each call.
105
+ module Stable
106
+ # @attribute [Boolean] whether to use the stable variant by default.
107
+ attr_accessor :stable
108
+
109
+ # @parameter stable [Boolean] whether to use the stable variant by default.
110
+ def initialize(stable: true)
111
+ @stable = stable
112
+ end
113
+
114
+ def inspect
115
+ "#{self.class.name.split("::").last}(stable: #{stable})"
116
+ end
117
+
118
+ private
119
+
120
+ def stable?(override)
121
+ override.nil? ? stable : override
122
+ end
123
+ end
124
+
125
+ # Abstract base class of the connective operators.
126
+ class ConnectiveOperator
127
+ # @raises [NotImplementedError] always: subclasses implement #call.
128
+ def call(*args, **kwargs)
129
+ raise NotImplementedError, "#{self.class} must implement #call"
130
+ end
131
+
132
+ def inspect
133
+ "#{self.class.name.split("::").last}()"
134
+ end
135
+
136
+ def to_s
137
+ inspect
138
+ end
139
+ end
140
+
141
+ # Abstract base class of the unary connective operators.
142
+ class UnaryConnectiveOperator < ConnectiveOperator
143
+ end
144
+
145
+ # Abstract base class of the binary connective operators.
146
+ class BinaryConnectiveOperator < ConnectiveOperator
147
+ end
148
+
149
+ # Standard negation: 1 - x.
150
+ class NotStandard < UnaryConnectiveOperator
151
+ def call(x)
152
+ Torch.rsub(x, 1.0)
153
+ end
154
+ end
155
+
156
+ # Gödel negation: 1 if x == 0, 0 otherwise.
157
+ class NotGodel < UnaryConnectiveOperator
158
+ def call(x)
159
+ Torch.eq(x, 0.0).float
160
+ end
161
+ end
162
+
163
+ # Gödel conjunction: min(x, y).
164
+ class AndMin < BinaryConnectiveOperator
165
+ def call(x, y)
166
+ Torch.minimum(x, y)
167
+ end
168
+ end
169
+
170
+ # Goguen (product) conjunction: x * y.
171
+ class AndProd < BinaryConnectiveOperator
172
+ include Stable
173
+
174
+ def call(x, y, stable: nil)
175
+ x, y = FuzzyOps.pi_0(x), FuzzyOps.pi_0(y) if stable?(stable)
176
+ Torch.mul(x, y)
177
+ end
178
+ end
179
+
180
+ # Łukasiewicz conjunction: max(x + y - 1, 0).
181
+ class AndLuk < BinaryConnectiveOperator
182
+ def call(x, y)
183
+ Torch.maximum(x + y - 1.0, Torch.zeros_like(x))
184
+ end
185
+ end
186
+
187
+ # Gödel disjunction: max(x, y).
188
+ class OrMax < BinaryConnectiveOperator
189
+ def call(x, y)
190
+ Torch.maximum(x, y)
191
+ end
192
+ end
193
+
194
+ # Goguen disjunction (probabilistic sum): x + y - x * y.
195
+ class OrProbSum < BinaryConnectiveOperator
196
+ include Stable
197
+
198
+ def call(x, y, stable: nil)
199
+ x, y = FuzzyOps.pi_1(x), FuzzyOps.pi_1(y) if stable?(stable)
200
+ x + y - Torch.mul(x, y)
201
+ end
202
+ end
203
+
204
+ # Łukasiewicz disjunction: min(x + y, 1).
205
+ class OrLuk < BinaryConnectiveOperator
206
+ def call(x, y)
207
+ Torch.minimum(x + y, Torch.ones_like(x))
208
+ end
209
+ end
210
+
211
+ # Kleene-Dienes implication: max(1 - x, y).
212
+ class ImpliesKleeneDienes < BinaryConnectiveOperator
213
+ def call(x, y)
214
+ Torch.maximum(Torch.rsub(x, 1.0), y)
215
+ end
216
+ end
217
+
218
+ # Gödel implication: 1 if x <= y, y otherwise.
219
+ class ImpliesGodel < BinaryConnectiveOperator
220
+ def call(x, y)
221
+ FuzzyOps.where(Torch.le(x, y), Torch.ones_like(x), y)
222
+ end
223
+ end
224
+
225
+ # Reichenbach implication: 1 - x + x * y.
226
+ class ImpliesReichenbach < BinaryConnectiveOperator
227
+ include Stable
228
+
229
+ def call(x, y, stable: nil)
230
+ x, y = FuzzyOps.pi_0(x), FuzzyOps.pi_1(y) if stable?(stable)
231
+ Torch.rsub(x, 1.0) + Torch.mul(x, y)
232
+ end
233
+ end
234
+
235
+ # Goguen implication: 1 if x <= y, y / x otherwise.
236
+ class ImpliesGoguen < BinaryConnectiveOperator
237
+ include Stable
238
+
239
+ def call(x, y, stable: nil)
240
+ x = FuzzyOps.pi_0(x) if stable?(stable)
241
+ FuzzyOps.where(Torch.le(x, y), Torch.ones_like(x), Torch.div(y, x))
242
+ end
243
+ end
244
+
245
+ # Łukasiewicz implication: min(1 - x + y, 1).
246
+ class ImpliesLuk < BinaryConnectiveOperator
247
+ def call(x, y)
248
+ Torch.minimum(Torch.rsub(x, 1.0) + y, Torch.ones_like(x))
249
+ end
250
+ end
251
+
252
+ # Equivalence, as the conjunction of the implications in both directions.
253
+ class Equiv < BinaryConnectiveOperator
254
+ attr_reader :and_op, :implies_op
255
+
256
+ # @parameter and_op [BinaryConnectiveOperator] the conjunction.
257
+ # @parameter implies_op [BinaryConnectiveOperator] the implication.
258
+ def initialize(and_op, implies_op)
259
+ @and_op = and_op
260
+ @implies_op = implies_op
261
+ end
262
+
263
+ def call(x, y)
264
+ and_op.call(implies_op.call(x, y), implies_op.call(y, x))
265
+ end
266
+
267
+ def inspect
268
+ "Equiv(and_op: #{and_op.inspect}, implies_op: #{implies_op.inspect})"
269
+ end
270
+ end
271
+
272
+ # Abstract base class of the aggregation operators.
273
+ #
274
+ # Every aggregator responds to `call(xs, dim: nil, keepdim: false, mask: nil)`:
275
+ # `dim` is the dimension (or dimensions) to aggregate, nil for all of them,
276
+ # and `mask` is a boolean tensor shaped like `xs` that excludes the values
277
+ # where it is false (used for guarded quantification).
278
+ class AggregationOperator
279
+ # @raises [NotImplementedError] always: subclasses implement #call.
280
+ def call(*args, **kwargs)
281
+ raise NotImplementedError, "#{self.class} must implement #call"
282
+ end
283
+
284
+ def inspect
285
+ "#{self.class.name.split("::").last}()"
286
+ end
287
+
288
+ def to_s
289
+ inspect
290
+ end
291
+
292
+ private
293
+
294
+ # Mean of `xs` over `dims`, counting only the values where `mask` is true.
295
+ def masked_mean(xs, mask, dims, keepdim)
296
+ FuzzyOps.check_mask(mask, xs)
297
+ numerator = Torch.sum(FuzzyOps.where(mask.logical_not, Torch.zeros_like(xs), xs), dim: dims, keepdim: keepdim)
298
+ denominator = Torch.sum(mask, dim: dims, keepdim: keepdim)
299
+ Torch.div(numerator, denominator)
300
+ end
301
+ end
302
+
303
+ # Shared by the pMean aggregators: `p` and `stable` can be set in the
304
+ # constructor and overridden on each call.
305
+ module PMeanParameters
306
+ # @attribute [Numeric] the exponent of the generalized mean.
307
+ attr_accessor :p
308
+
309
+ # @attribute [Boolean] whether to use the stable variant by default.
310
+ attr_accessor :stable
311
+
312
+ # @parameter p [Numeric] the exponent of the generalized mean.
313
+ # @parameter stable [Boolean] whether to use the stable variant by default.
314
+ def initialize(p: 2, stable: true)
315
+ @p = p
316
+ @stable = stable
317
+ end
318
+
319
+ def inspect
320
+ "#{self.class.name.split("::").last}(p: #{p}, stable: #{stable})"
321
+ end
322
+ end
323
+
324
+ # Minimum, an approximation of the universal quantifier.
325
+ class AggregMin < AggregationOperator
326
+ def call(xs, dim: nil, keepdim: false, mask: nil)
327
+ unless mask.nil?
328
+ FuzzyOps.check_mask(mask, xs)
329
+ # 1 is the largest truth value, so masked-out values never win the
330
+ # minimum. As in LTNtorch, this turns the result into float64.
331
+ xs = FuzzyOps.where(mask.logical_not, 1.0, xs.double)
332
+ end
333
+ Torch.amin(xs, dim: FuzzyOps.reduction_dims(dim), keepdim: keepdim)
334
+ end
335
+ end
336
+
337
+ # Arithmetic mean.
338
+ class AggregMean < AggregationOperator
339
+ def call(xs, dim: nil, keepdim: false, mask: nil)
340
+ dims = FuzzyOps.reduction_dims(dim)
341
+ return masked_mean(xs, mask, dims, keepdim) unless mask.nil?
342
+
343
+ Torch.mean(xs, dim: dims, keepdim: keepdim)
344
+ end
345
+ end
346
+
347
+ # Generalized mean (pMean), an approximation of the existential quantifier:
348
+ # (mean(x ** p)) ** (1 / p).
349
+ class AggregPMean < AggregationOperator
350
+ include PMeanParameters
351
+
352
+ def call(xs, dim: nil, keepdim: false, mask: nil, p: nil, stable: nil)
353
+ p = self.p if p.nil?
354
+ stable = self.stable if stable.nil?
355
+ dims = FuzzyOps.reduction_dims(dim)
356
+
357
+ xs = FuzzyOps.pi_0(xs) if stable
358
+ xs = Torch.pow(xs, p)
359
+ mean = mask.nil? ? Torch.mean(xs, dim: dims, keepdim: keepdim) : masked_mean(xs, mask, dims, keepdim)
360
+ Torch.pow(mean, 1.0 / p)
361
+ end
362
+ end
363
+
364
+ # Generalized mean of the errors (pMeanError), an approximation of the
365
+ # universal quantifier: 1 - (mean((1 - x) ** p)) ** (1 / p).
366
+ class AggregPMeanError < AggregationOperator
367
+ include PMeanParameters
368
+
369
+ def call(xs, dim: nil, keepdim: false, mask: nil, p: nil, stable: nil)
370
+ p = self.p if p.nil?
371
+ stable = self.stable if stable.nil?
372
+ dims = FuzzyOps.reduction_dims(dim)
373
+
374
+ xs = FuzzyOps.pi_1(xs) if stable
375
+ xs = Torch.pow(Torch.rsub(xs, 1.0), p)
376
+ mean = mask.nil? ? Torch.mean(xs, dim: dims, keepdim: keepdim) : masked_mean(xs, mask, dims, keepdim)
377
+ Torch.rsub(Torch.pow(mean, 1.0 / p), 1.0)
378
+ end
379
+ end
380
+
381
+ # Aggregates the truth values of the closed formulas of a knowledge base
382
+ # into its overall satisfaction level, typically to build a loss as
383
+ # `1 - sat_agg.(...)`.
384
+ class SatAgg
385
+ attr_reader :agg_op
386
+
387
+ # @parameter agg_op [AggregationOperator] how to aggregate the truth values.
388
+ # @raises [TypeError] if `agg_op` is not an aggregation operator.
389
+ def initialize(agg_op = AggregPMeanError.new(p: 2))
390
+ unless agg_op.is_a?(AggregationOperator)
391
+ raise TypeError, "SatAgg.new: argument 'agg_op' (position 1) must be an AggregationOperator, not #{agg_op.class}"
392
+ end
393
+
394
+ @agg_op = agg_op
395
+ end
396
+
397
+ # @parameter closed_formulas [Array(LTNObject | Torch::Tensor)] scalar truth values.
398
+ # @returns [Torch::Tensor] the aggregated (scalar) truth value.
399
+ # @raises [TypeError] if a formula is neither an LTNObject nor a tensor.
400
+ # @raises [ArgumentError] if a formula is not closed (its value is not a
401
+ # scalar) or not in [0, 1].
402
+ def call(*closed_formulas)
403
+ unless closed_formulas.all? { |f| f.is_a?(LTNObject) || f.is_a?(Torch::Tensor) }
404
+ raise TypeError, "Expected parameter 'closed_formulas' to be LTNObjects and/or tensors, " \
405
+ "but got #{closed_formulas.map(&:class).inspect}"
406
+ end
407
+
408
+ truth_values = closed_formulas.map { |f| f.is_a?(LTNObject) ? f.value : f }
409
+ unless truth_values.all? { |v| v.shape == [] }
410
+ raise ArgumentError, "Expected parameter 'closed_formulas' to be LTNObjects and/or tensors containing " \
411
+ "scalars, but got the following shapes: #{truth_values.map(&:shape).inspect}"
412
+ end
413
+
414
+ truth_values = Torch.stack(truth_values, dim: 0)
415
+ FuzzyOps.check_values(truth_values)
416
+ agg_op.call(truth_values, dim: 0)
417
+ end
418
+
419
+ def inspect
420
+ "SatAgg(agg_op: #{agg_op.inspect})"
421
+ end
422
+
423
+ def to_s
424
+ inspect
425
+ end
426
+ end
427
+ end
428
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Mauro Tortonesi.
5
+ # Ported from LTNtorch, copyright 2021-2024 by Tommaso Carraro.
6
+
7
+ module LTN
8
+ # Wraps a function (a lambda, proc, method or block) in a Torch::NN::Module,
9
+ # to ground a non-trainable predicate or function.
10
+ class LambdaModel < Torch::NN::Module
11
+ # @attribute [#call] the wrapped function.
12
+ attr_reader :func
13
+
14
+ # @parameter func [#call] the function to wrap.
15
+ def initialize(func)
16
+ super()
17
+ @func = func
18
+ end
19
+
20
+ def forward(*inputs, **kwargs)
21
+ func.call(*inputs, **kwargs)
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Mauro Tortonesi.
5
+ # Ported from LTNtorch, copyright 2021-2024 by Tommaso Carraro.
6
+
7
+ module LTN
8
+ # The output of every LTN operation: a grounding (a tensor) together with
9
+ # the labels of the free variables appearing in it, one per leading axis of
10
+ # the tensor, in order.
11
+ #
12
+ # Named `LTNObject`, as in LTNtorch, rather than `LTN::Object`, which would
13
+ # shadow Ruby's `Object` for every unqualified reference inside `LTN`.
14
+ class LTNObject
15
+ # @attribute [Torch::Tensor] the grounding.
16
+ attr_accessor :value
17
+
18
+ # @attribute [Array(String)] labels of the free variables, one per leading axis of #value.
19
+ attr_accessor :free_vars
20
+
21
+ # @parameter value [Torch::Tensor] the grounding.
22
+ # @parameter var_labels [Array(String)] labels of the free variables in `value`.
23
+ # @raises [TypeError] if `value` is not a tensor or `var_labels` is not an array of strings.
24
+ def initialize(value, var_labels)
25
+ unless value.is_a?(Torch::Tensor)
26
+ raise TypeError, "LTNObject.new: argument 'value' (position 1) must be a Torch::Tensor, not #{value.class}"
27
+ end
28
+ unless var_labels.is_a?(Array) && var_labels.all?(String)
29
+ raise TypeError,
30
+ "LTNObject.new: argument 'var_labels' (position 2) must be an Array of String, not #{var_labels.class}"
31
+ end
32
+
33
+ @value = value
34
+ @free_vars = var_labels
35
+ end
36
+
37
+ # @returns [Array(Integer)] the shape of the grounding.
38
+ def shape
39
+ value.shape
40
+ end
41
+
42
+ def inspect
43
+ "LTNObject(value=#{value.inspect}, free_vars=#{free_vars.inspect})"
44
+ end
45
+ alias_method :to_s, :inspect
46
+ end
47
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Mauro Tortonesi.
5
+ # Ported from LTNtorch, copyright 2021-2024 by Tommaso Carraro.
6
+
7
+ module LTN
8
+ # The construction shared by Predicate and Function: they are grounded
9
+ # either by a Torch::NN::Module (trainable) or by a function (not trainable).
10
+ module ModelWrapper
11
+ # @attribute [Torch::NN::Module] the grounding; a LambdaModel for a function.
12
+ attr_reader :model
13
+
14
+ # Ground the symbol with either `model` or a function, given as `func:`
15
+ # or as a block:
16
+ #
17
+ # LTN::Predicate.new(model)
18
+ # LTN::Predicate.new(func: ->(x) { Torch.sigmoid(x.sum(1)) })
19
+ # LTN::Predicate.new { |x| Torch.sigmoid(x.sum(1)) }
20
+ #
21
+ # @parameter model [Torch::NN::Module, nil] a model taking one tensor per argument.
22
+ # @parameter func [#call, nil] a function taking one tensor per argument.
23
+ # @raises [ArgumentError] unless exactly one of model, func and block is given.
24
+ # @raises [TypeError] if `model` is not a Torch::NN::Module or `func` is not callable.
25
+ def initialize(model = nil, func: nil, &block)
26
+ super()
27
+ groundings = [model, func, block].compact
28
+ if groundings.size > 1
29
+ raise ArgumentError, "More than one of model, func and block have been specified. " \
30
+ "Expected only one of them to be specified."
31
+ end
32
+ if groundings.empty?
33
+ raise ArgumentError, "None of model, func and block have been specified. " \
34
+ "Expected one of them to be specified."
35
+ end
36
+
37
+ @model = if model
38
+ unless model.is_a?(Torch::NN::Module)
39
+ raise TypeError, "#{symbol_name}.new: argument 'model' (position 1) must be a Torch::NN::Module, " \
40
+ "not #{model.class}"
41
+ end
42
+ model
43
+ else
44
+ func ||= block
45
+ if !func.respond_to?(:call) || func.is_a?(Torch::NN::Module)
46
+ raise TypeError, "#{symbol_name}.new: argument 'func' must be a function, not #{func.class}"
47
+ end
48
+ LambdaModel.new(func)
49
+ end
50
+ end
51
+
52
+ def inspect
53
+ "#{symbol_name}(model=#{model.inspect})"
54
+ end
55
+
56
+ def to_s
57
+ inspect
58
+ end
59
+
60
+ private
61
+
62
+ def symbol_name
63
+ self.class.name.split("::").last
64
+ end
65
+
66
+ def check_inputs(inputs)
67
+ return if inputs.all?(LTNObject)
68
+
69
+ raise TypeError, "Expected parameter 'inputs' to be LTNObjects, but got #{inputs.map(&:class).inspect}"
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Mauro Tortonesi.
5
+ # Ported from LTNtorch, copyright 2021-2024 by Tommaso Carraro.
6
+
7
+ module LTN
8
+ # An LTN predicate: maps individuals to truth values in [0, 1].
9
+ #
10
+ # is_positive = LTN::Predicate.new { |x| Torch.sigmoid(x.sum(1)) }
11
+ # x = LTN::Variable.new("x", Torch.randn(4, 3))
12
+ # is_positive.call(x) # => an LTNObject with 4 truth values and free_vars ["x"]
13
+ class Predicate < Torch::NN::Module
14
+ include ModelWrapper
15
+
16
+ # Computes the truth values of the predicate, after broadcasting the
17
+ # inputs (see LTN.process_ltn_objects): the result has one axis per free
18
+ # variable of the inputs.
19
+ #
20
+ # @parameter inputs [Array(LTNObject)] the arguments.
21
+ # @parameter kwargs [Hash] passed to the model.
22
+ # @returns [LTNObject]
23
+ # @raises [TypeError] if an input is not an LTNObject.
24
+ # @raises [ArgumentError] if the model returns values outside [0, 1].
25
+ def forward(*inputs, **kwargs)
26
+ check_inputs(inputs)
27
+ processed, output_vars, output_shape = LTN.process_ltn_objects(inputs)
28
+ output = model.call(*processed.map(&:value), **kwargs)
29
+
30
+ unless Torch.all(Torch.logical_and(output.ge(0.0), output.le(1.0))).item
31
+ raise ArgumentError, "Expected the output of a predicate to be in the range [0., 1.], but got some " \
32
+ "values outside of this range. Check your predicate implementation!"
33
+ end
34
+
35
+ LTNObject.new(output.reshape(output_shape).float, output_vars)
36
+ end
37
+ end
38
+ end