lex-llm 0.7.6 → 0.8.4

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 (168) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +139 -0
  3. data/RULES.md +97 -0
  4. data/lib/legion/extensions/llm/auto_registration.rb +4 -13
  5. data/lib/legion/extensions/llm/canonical/chunk.rb +107 -94
  6. data/lib/legion/extensions/llm/canonical/content_block.rb +65 -75
  7. data/lib/legion/extensions/llm/canonical/message.rb +53 -69
  8. data/lib/legion/extensions/llm/canonical/params.rb +58 -34
  9. data/lib/legion/extensions/llm/canonical/request.rb +68 -56
  10. data/lib/legion/extensions/llm/canonical/response.rb +62 -78
  11. data/lib/legion/extensions/llm/canonical/strict.rb +105 -0
  12. data/lib/legion/extensions/llm/canonical/thinking.rb +36 -84
  13. data/lib/legion/extensions/llm/canonical/thinking_config.rb +149 -0
  14. data/lib/legion/extensions/llm/canonical/tool_call.rb +43 -53
  15. data/lib/legion/extensions/llm/canonical/tool_definition.rb +56 -46
  16. data/lib/legion/extensions/llm/canonical/tool_schema.rb +15 -22
  17. data/lib/legion/extensions/llm/canonical/usage.rb +47 -40
  18. data/lib/legion/extensions/llm/canonical.rb +7 -5
  19. data/lib/legion/extensions/llm/configuration.rb +40 -10
  20. data/lib/legion/extensions/llm/connection.rb +8 -30
  21. data/lib/legion/extensions/llm/credential_sources.rb +32 -49
  22. data/lib/legion/extensions/llm/discovery/actor.rb +92 -0
  23. data/lib/legion/extensions/llm/discovery/pipeline.rb +604 -0
  24. data/lib/legion/extensions/llm/error.rb +0 -14
  25. data/lib/legion/extensions/llm/fleet/contract_error.rb +15 -0
  26. data/lib/legion/extensions/llm/fleet/envelope_validation.rb +7 -6
  27. data/lib/legion/extensions/llm/fleet/fleet_envelope.rb +66 -0
  28. data/lib/legion/extensions/llm/fleet/protocol.rb +18 -5
  29. data/lib/legion/extensions/llm/fleet/provider_responder.rb +58 -156
  30. data/lib/legion/extensions/llm/fleet/token_validator.rb +15 -21
  31. data/lib/legion/extensions/llm/fleet/worker_execution.rb +107 -154
  32. data/lib/legion/extensions/llm/inventory/errors.rb +0 -1
  33. data/lib/legion/extensions/llm/inventory/evidence.rb +1 -1
  34. data/lib/legion/extensions/llm/inventory/identity.rb +55 -39
  35. data/lib/legion/extensions/llm/inventory/probe_token.rb +9 -12
  36. data/lib/legion/extensions/llm/inventory/publisher.rb +15 -53
  37. data/lib/legion/extensions/llm/inventory/records.rb +36 -100
  38. data/lib/legion/extensions/llm/inventory/registry.rb +54 -48
  39. data/lib/legion/extensions/llm/inventory/snapshot.rb +7 -21
  40. data/lib/legion/extensions/llm/inventory/weight_reconciler.rb +13 -6
  41. data/lib/legion/extensions/llm/inventory/weight_schema.rb +47 -8
  42. data/lib/legion/extensions/llm/provider/open_ai_compatible.rb +178 -182
  43. data/lib/legion/extensions/llm/provider.rb +192 -315
  44. data/lib/legion/extensions/llm/provider_contract.rb +25 -8
  45. data/lib/legion/extensions/llm/provider_settings.rb +5 -26
  46. data/lib/legion/extensions/llm/responses/thinking_extractor.rb +8 -1
  47. data/lib/legion/extensions/llm/responses/tool_arguments.rb +48 -0
  48. data/lib/legion/extensions/llm/routing/provider_outcome.rb +25 -0
  49. data/lib/legion/extensions/llm/routing/records.rb +34 -17
  50. data/lib/legion/extensions/llm/stream_accumulator.rb +186 -270
  51. data/lib/legion/extensions/llm/streaming.rb +50 -35
  52. data/lib/legion/extensions/llm/taxonomies.rb +14 -26
  53. data/lib/legion/extensions/llm/transport/fleet_lane.rb +8 -10
  54. data/lib/legion/extensions/llm/transport/messages/fleet_error.rb +3 -2
  55. data/lib/legion/extensions/llm/transport/messages/fleet_request.rb +6 -8
  56. data/lib/legion/extensions/llm/transport/messages/fleet_response.rb +10 -9
  57. data/lib/legion/extensions/llm/utils.rb +23 -5
  58. data/lib/legion/extensions/llm/version.rb +1 -1
  59. data/lib/legion/extensions/llm.rb +8 -98
  60. data/spec/legion/extensions/llm/auto_registration_spec.rb +4 -9
  61. data/spec/legion/extensions/llm/canonical/chunk_spec.rb +66 -252
  62. data/spec/legion/extensions/llm/canonical/content_block_spec.rb +52 -197
  63. data/spec/legion/extensions/llm/canonical/message_spec.rb +89 -204
  64. data/spec/legion/extensions/llm/canonical/params_spec.rb +55 -136
  65. data/spec/legion/extensions/llm/canonical/request_spec.rb +81 -143
  66. data/spec/legion/extensions/llm/canonical/response_spec.rb +68 -204
  67. data/spec/legion/extensions/llm/canonical/thinking/config_spec.rb +222 -0
  68. data/spec/legion/extensions/llm/canonical/thinking_spec.rb +23 -171
  69. data/spec/legion/extensions/llm/canonical/tool_call_spec.rb +59 -162
  70. data/spec/legion/extensions/llm/canonical/tool_definition_spec.rb +55 -191
  71. data/spec/legion/extensions/llm/canonical/tool_schema_spec.rb +26 -67
  72. data/spec/legion/extensions/llm/canonical/usage_spec.rb +46 -155
  73. data/spec/legion/extensions/llm/configuration_spec.rb +31 -5
  74. data/spec/legion/extensions/llm/conformance/canonical_type_examples.rb +106 -0
  75. data/spec/legion/extensions/llm/conformance/client_translator_examples.rb +1 -2
  76. data/spec/legion/extensions/llm/conformance/conformance.rb +10 -2
  77. data/spec/legion/extensions/llm/conformance/fixtures/canonical_fleet_round_trip.json +1 -1
  78. data/spec/legion/extensions/llm/conformance/fixtures/canonical_thinking_request.json +2 -2
  79. data/spec/legion/extensions/llm/conformance/provider_translator_examples.rb +1 -1
  80. data/spec/legion/extensions/llm/conformance/ssot_contract_conformance_spec.rb +128 -0
  81. data/spec/legion/extensions/llm/conformance/ssot_contract_examples.rb +505 -0
  82. data/spec/legion/extensions/llm/conformance/ssot_provider_examples.rb +11 -10
  83. data/spec/legion/extensions/llm/credential_sources_spec.rb +12 -13
  84. data/spec/legion/extensions/llm/error_spec.rb +2 -12
  85. data/spec/legion/extensions/llm/fleet/exact_offering_spec.rb +59 -45
  86. data/spec/legion/extensions/llm/fleet/provider_responder_spec.rb +173 -95
  87. data/spec/legion/extensions/llm/fleet/token_validator_spec.rb +7 -2
  88. data/spec/legion/extensions/llm/fleet/worker_execution_spec.rb +93 -74
  89. data/spec/legion/extensions/llm/fleet_messages_spec.rb +119 -125
  90. data/spec/legion/extensions/llm/gemspec_spec.rb +1 -2
  91. data/spec/legion/extensions/llm/inventory/boot_spec.rb +4 -4
  92. data/spec/legion/extensions/llm/inventory/identity_spec.rb +127 -110
  93. data/spec/legion/extensions/llm/inventory/probe_token_spec.rb +4 -4
  94. data/spec/legion/extensions/llm/inventory/publisher_spec.rb +8 -61
  95. data/spec/legion/extensions/llm/inventory/records_spec.rb +82 -47
  96. data/spec/legion/extensions/llm/inventory/registry_activation_spec.rb +49 -16
  97. data/spec/legion/extensions/llm/inventory/registry_replacement_spec.rb +5 -4
  98. data/spec/legion/extensions/llm/inventory/snapshot_spec.rb +11 -5
  99. data/spec/legion/extensions/llm/inventory/weight_reconciler_spec.rb +7 -3
  100. data/spec/legion/extensions/llm/inventory/weight_schema_spec.rb +60 -10
  101. data/spec/legion/extensions/llm/provider/open_ai_compatible_spec.rb +128 -68
  102. data/spec/legion/extensions/llm/provider/open_ai_compatible_tool_calls_array_spec.rb +7 -31
  103. data/spec/legion/extensions/llm/provider_contract_spec.rb +10 -15
  104. data/spec/legion/extensions/llm/provider_spec.rb +97 -78
  105. data/spec/legion/extensions/llm/routing/records_spec.rb +38 -5
  106. data/spec/legion/extensions/llm/stream_accumulator_spec.rb +174 -144
  107. data/spec/legion/extensions/llm/streaming_spec.rb +27 -0
  108. data/spec/legion/extensions/llm/taxonomies_spec.rb +43 -43
  109. data/spec/legion/extensions/llm/transport/fleet_lane_spec.rb +1 -1
  110. data/spec/legion/extensions/llm/utils_spec.rb +26 -7
  111. data/spec/legion/extensions/llm_base_contract_spec.rb +55 -90
  112. data/spec/legion/extensions/llm_extension_spec.rb +5 -5
  113. data/spec/support/fake_llm_provider.rb +45 -39
  114. data/spec/support/fake_ssot_harness.rb +7 -2
  115. metadata +13 -54
  116. data/lib/legion/extensions/llm/agent.rb +0 -366
  117. data/lib/legion/extensions/llm/aliases.json +0 -436
  118. data/lib/legion/extensions/llm/aliases.rb +0 -67
  119. data/lib/legion/extensions/llm/attachment.rb +0 -229
  120. data/lib/legion/extensions/llm/chat.rb +0 -354
  121. data/lib/legion/extensions/llm/chunk.rb +0 -10
  122. data/lib/legion/extensions/llm/content.rb +0 -81
  123. data/lib/legion/extensions/llm/context.rb +0 -33
  124. data/lib/legion/extensions/llm/embedding.rb +0 -33
  125. data/lib/legion/extensions/llm/image.rb +0 -109
  126. data/lib/legion/extensions/llm/inventory/capabilities.rb +0 -40
  127. data/lib/legion/extensions/llm/inventory/scoped_refresher.rb +0 -310
  128. data/lib/legion/extensions/llm/message.rb +0 -118
  129. data/lib/legion/extensions/llm/mime_type.rb +0 -75
  130. data/lib/legion/extensions/llm/model/info.rb +0 -286
  131. data/lib/legion/extensions/llm/model/modalities.rb +0 -26
  132. data/lib/legion/extensions/llm/model/pricing.rb +0 -52
  133. data/lib/legion/extensions/llm/model/pricing_category.rb +0 -50
  134. data/lib/legion/extensions/llm/model/pricing_tier.rb +0 -37
  135. data/lib/legion/extensions/llm/model.rb +0 -11
  136. data/lib/legion/extensions/llm/models.json +0 -57313
  137. data/lib/legion/extensions/llm/models.rb +0 -530
  138. data/lib/legion/extensions/llm/models_schema.json +0 -168
  139. data/lib/legion/extensions/llm/moderation.rb +0 -60
  140. data/lib/legion/extensions/llm/registry_event_builder.rb +0 -141
  141. data/lib/legion/extensions/llm/registry_publisher.rb +0 -107
  142. data/lib/legion/extensions/llm/responses/chat_response.rb +0 -43
  143. data/lib/legion/extensions/llm/responses/embedding_response.rb +0 -38
  144. data/lib/legion/extensions/llm/responses/stream_chunk.rb +0 -43
  145. data/lib/legion/extensions/llm/routing/lane_key.rb +0 -66
  146. data/lib/legion/extensions/llm/routing/model_offering.rb +0 -241
  147. data/lib/legion/extensions/llm/routing/offering_registry.rb +0 -101
  148. data/lib/legion/extensions/llm/routing/registry_event.rb +0 -167
  149. data/lib/legion/extensions/llm/thinking.rb +0 -53
  150. data/lib/legion/extensions/llm/tokens.rb +0 -51
  151. data/lib/legion/extensions/llm/tool_call.rb +0 -34
  152. data/lib/legion/extensions/llm/transcription.rb +0 -39
  153. data/lib/legion/extensions/llm/transport/messages/registry_event.rb +0 -44
  154. data/spec/legion/extensions/llm/agent_spec.rb +0 -179
  155. data/spec/legion/extensions/llm/attachment_spec.rb +0 -25
  156. data/spec/legion/extensions/llm/conformance/fixtures/ssot_identity_vectors.json +0 -84
  157. data/spec/legion/extensions/llm/context_spec.rb +0 -127
  158. data/spec/legion/extensions/llm/inventory/capabilities_spec.rb +0 -43
  159. data/spec/legion/extensions/llm/inventory/scoped_refresher_spec.rb +0 -340
  160. data/spec/legion/extensions/llm/message_spec.rb +0 -64
  161. data/spec/legion/extensions/llm/model/info_spec.rb +0 -222
  162. data/spec/legion/extensions/llm/models_spec.rb +0 -104
  163. data/spec/legion/extensions/llm/registry_event_builder_spec.rb +0 -68
  164. data/spec/legion/extensions/llm/registry_publisher_spec.rb +0 -22
  165. data/spec/legion/extensions/llm/responses/response_objects_spec.rb +0 -75
  166. data/spec/legion/extensions/llm/routing/model_offering_spec.rb +0 -281
  167. data/spec/legion/extensions/llm/routing/offering_registry_spec.rb +0 -50
  168. data/spec/legion/extensions/llm/routing/registry_event_spec.rb +0 -120
@@ -0,0 +1,222 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require_relative '../../conformance/conformance'
5
+
6
+ RSpec.describe Legion::Extensions::Llm::Canonical::Thinking::Config do
7
+ subject(:config_class) { described_class }
8
+
9
+ let(:type_class) { described_class }
10
+ let(:auto_generated_members) { [] }
11
+ let(:type_source) do
12
+ { enabled: true, effort: 'high', budget: 4096, summary: :concise, metadata: { source: 'client' } }
13
+ end
14
+
15
+ it_behaves_like 'a canonical type'
16
+
17
+ it 'converts from a plain class to a Data with one name (04 §8)' do
18
+ expect(config_class).to be_a(Class)
19
+ expect(config_class.new(enabled: true, effort: 'low', budget: nil, summary: nil, metadata: {})).to be_a(config_class)
20
+ end
21
+
22
+ it 'normalizes symbol effort to String' do
23
+ config = config_class.build(effort: :low)
24
+ expect(config.effort).to eq('low')
25
+ end
26
+
27
+ it 'raises on a wrong-class effort' do
28
+ expect { config_class.build(effort: 3) }
29
+ .to raise_error(ArgumentError, /effort expected String, got Integer/)
30
+ end
31
+
32
+ describe 'enabled member' do
33
+ it 'defaults to true in .build' do
34
+ config = config_class.build
35
+ expect(config.enabled).to be(true)
36
+ end
37
+
38
+ it 'defaults to true in .from_hash when key absent' do
39
+ config = config_class.from_hash({})
40
+ expect(config.enabled).to be(true)
41
+ end
42
+
43
+ it 'accepts explicit true' do
44
+ config = config_class.build(enabled: true)
45
+ expect(config.enabled).to be(true)
46
+ end
47
+
48
+ it 'accepts explicit false' do
49
+ config = config_class.build(enabled: false)
50
+ expect(config.enabled).to be(false)
51
+ end
52
+
53
+ it 'rejects non-boolean in .new' do
54
+ expect { config_class.new(enabled: 'yes', effort: nil, budget: nil, summary: nil, metadata: {}) }
55
+ .to raise_error(ArgumentError, /enabled must be true or false/)
56
+ end
57
+
58
+ it 'rejects nil in .new (must be an explicit boolean)' do
59
+ expect { config_class.new(enabled: nil, effort: nil, budget: nil, summary: nil, metadata: {}) }
60
+ .to raise_error(ArgumentError, /enabled must be true or false/)
61
+ end
62
+
63
+ it 'enabled? returns the enabled member' do
64
+ expect(config_class.build(enabled: true).enabled?).to be(true)
65
+ expect(config_class.build(enabled: false).enabled?).to be(false)
66
+ end
67
+ end
68
+
69
+ describe 'H1/M4 — effort is a closed enum, validated in every constructor' do
70
+ it 'rejects an unrecognized effort in build, from_hash, and .new' do
71
+ expect { config_class.build(effort: 'banana') }
72
+ .to raise_error(ArgumentError, /Invalid effort: "banana"/)
73
+ expect { config_class.from_hash(effort: 'banana') }
74
+ .to raise_error(ArgumentError, /Invalid effort: "banana"/)
75
+ expect { config_class.new(enabled: true, effort: 'banana', budget: nil, summary: nil, metadata: {}) }
76
+ .to raise_error(ArgumentError, /Invalid effort: "banana"/)
77
+ end
78
+
79
+ it 'accepts the enum case-insensitively (symbol or string)' do
80
+ expect(config_class.build(effort: :HIGH).effort).to eq('high')
81
+ expect(config_class.new(enabled: true, effort: 'Medium', budget: nil, summary: nil, metadata: {}).effort).to eq('medium')
82
+ end
83
+
84
+ it 'accepts all six effort levels' do
85
+ %w[none low medium high xhigh max].each do |level|
86
+ expect(config_class.build(effort: level).effort).to eq(level)
87
+ end
88
+ end
89
+
90
+ it 'resolved_budget derives from the validated effort (no silent medium fallback)' do
91
+ expect(config_class.build(effort: 'low').resolved_budget).to eq(1024)
92
+ expect(config_class.build(effort: 'medium').resolved_budget).to eq(8192)
93
+ expect(config_class.build(effort: 'high').resolved_budget).to eq(16_384)
94
+ expect(config_class.build(effort: 'xhigh').resolved_budget).to eq(24_576)
95
+ expect(config_class.build(effort: 'max').resolved_budget).to eq(32_768)
96
+ expect(config_class.build(effort: 'none').resolved_budget).to be_nil
97
+ expect(config_class.build(budget: 123).resolved_budget).to eq(123)
98
+ expect(config_class.build(enabled: false).resolved_budget).to be_nil
99
+ end
100
+ end
101
+
102
+ describe 'budget validation' do
103
+ it 'accepts a positive integer' do
104
+ expect(config_class.build(budget: 1).budget).to eq(1)
105
+ expect(config_class.build(budget: 50_000).budget).to eq(50_000)
106
+ end
107
+
108
+ it 'accepts nil' do
109
+ expect(config_class.build(budget: nil).budget).to be_nil
110
+ end
111
+
112
+ it 'rejects zero' do
113
+ expect { config_class.build(budget: 0) }
114
+ .to raise_error(ArgumentError, /budget must be positive/)
115
+ end
116
+
117
+ it 'rejects negative integers' do
118
+ expect { config_class.build(budget: -1) }
119
+ .to raise_error(ArgumentError, /budget must be positive/)
120
+ expect { config_class.build(budget: -100) }
121
+ .to raise_error(ArgumentError, /budget must be positive/)
122
+ end
123
+
124
+ it 'rejects non-Integer types' do
125
+ expect { config_class.build(budget: 1.5) }
126
+ .to raise_error(ArgumentError, /budget expected Integer/)
127
+ expect { config_class.build(budget: '1024') }
128
+ .to raise_error(ArgumentError, /budget expected Integer/)
129
+ end
130
+ end
131
+
132
+ describe 'summary enum' do
133
+ it 'accepts nil (default)' do
134
+ expect(config_class.build(summary: nil).summary).to be_nil
135
+ end
136
+
137
+ it 'accepts :auto, :none, :concise, :detailed' do
138
+ %i[auto none concise detailed].each do |level|
139
+ expect(config_class.build(summary: level).summary).to eq(level)
140
+ end
141
+ end
142
+
143
+ it 'coerces string to symbol' do
144
+ expect(config_class.from_hash(summary: 'concise').summary).to eq(:concise)
145
+ end
146
+
147
+ it 'rejects invalid summary values' do
148
+ expect { config_class.build(summary: :verbose) }
149
+ .to raise_error(ArgumentError, /Invalid summary/)
150
+ expect { config_class.build(summary: :full) }
151
+ .to raise_error(ArgumentError, /Invalid summary/)
152
+ end
153
+
154
+ it 'rejects non-symbol/string types' do
155
+ expect { config_class.build(summary: 42) }
156
+ .to raise_error(ArgumentError, /summary expected Symbol/)
157
+ end
158
+ end
159
+
160
+ it 'keeps the effort<->budget SSOT conversions' do
161
+ expect(config_class.build(effort: 'low').resolved_budget).to eq(1024)
162
+ expect(config_class.build(effort: 'medium').resolved_budget).to eq(8192)
163
+ expect(config_class.build(effort: 'high').resolved_budget).to eq(16_384)
164
+ expect(config_class.build(effort: 'xhigh').resolved_budget).to eq(24_576)
165
+ expect(config_class.build(effort: 'max').resolved_budget).to eq(32_768)
166
+ expect(config_class.build(budget: 512).resolved_effort).to eq('low')
167
+ expect(config_class.build(budget: 1024).resolved_effort).to eq('low')
168
+ expect(config_class.build(budget: 8192).resolved_effort).to eq('medium')
169
+ expect(config_class.build(budget: 16_384).resolved_effort).to eq('high')
170
+ expect(config_class.build(budget: 24_576).resolved_effort).to eq('xhigh')
171
+ expect(config_class.build(budget: 32_768).resolved_effort).to eq('max')
172
+ expect(config_class.build(budget: 50_000).resolved_effort).to eq('max')
173
+ end
174
+
175
+ describe 'resolved_effort band boundaries' do
176
+ it 'maps budget at boundary to the correct effort level' do
177
+ # At the low boundary
178
+ expect(config_class.build(budget: 1024).resolved_effort).to eq('low')
179
+ # Just above low
180
+ expect(config_class.build(budget: 1025).resolved_effort).to eq('medium')
181
+ # At medium boundary
182
+ expect(config_class.build(budget: 8192).resolved_effort).to eq('medium')
183
+ # Just above medium
184
+ expect(config_class.build(budget: 8193).resolved_effort).to eq('high')
185
+ # At high boundary
186
+ expect(config_class.build(budget: 16_384).resolved_effort).to eq('high')
187
+ # Just above high
188
+ expect(config_class.build(budget: 16_385).resolved_effort).to eq('xhigh')
189
+ # At xhigh boundary
190
+ expect(config_class.build(budget: 24_576).resolved_effort).to eq('xhigh')
191
+ # Just above xhigh
192
+ expect(config_class.build(budget: 24_577).resolved_effort).to eq('max')
193
+ end
194
+
195
+ it 'returns nil when budget is nil (no fabrication)' do
196
+ expect(config_class.build.resolved_effort).to be_nil
197
+ end
198
+
199
+ it 'returns the supplied effort when both are set (no overwrite)' do
200
+ config = config_class.build(effort: 'low', budget: 30_000)
201
+ expect(config.resolved_effort).to eq('low')
202
+ expect(config.resolved_budget).to eq(30_000)
203
+ end
204
+ end
205
+
206
+ it 'to_h is faithful to what was set (no fabricated axis)' do
207
+ expect(config_class.build(effort: 'low').to_h).to eq(enabled: true, effort: 'low', metadata: {})
208
+ # enabled:false is explicit OFF signal — it IS set, so it appears in to_h
209
+ expect(config_class.build(enabled: false).to_h).to eq(enabled: false, metadata: {})
210
+ end
211
+
212
+ it 'to_h omits nil members (compact)' do
213
+ config = config_class.build(enabled: true, effort: 'high', budget: 4096, summary: :detailed)
214
+ expect(config.to_h).to eq(enabled: true, effort: 'high', budget: 4096, summary: :detailed, metadata: {})
215
+ end
216
+
217
+ it 'from_hash round-trips through to_h' do
218
+ original = config_class.build(enabled: true, effort: 'xhigh', budget: 24_576, summary: :auto)
219
+ rebuilt = config_class.from_hash(original.to_h)
220
+ expect(rebuilt).to eq(original)
221
+ end
222
+ end
@@ -1,189 +1,41 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'spec_helper'
4
+ require_relative '../conformance/conformance'
4
5
 
5
6
  RSpec.describe Legion::Extensions::Llm::Canonical::Thinking do
6
- describe '.from_hash' do
7
- it 'returns a Thinking instance with content and signature' do
8
- thinking = described_class.from_hash(content: 'reasoning here', signature: 'sig-abc')
9
-
10
- expect(thinking).to be_a(described_class)
11
- expect(thinking.content).to eq('reasoning here')
12
- expect(thinking.signature).to eq('sig-abc')
13
- end
7
+ let(:type_class) { described_class }
8
+ let(:auto_generated_members) { [] }
9
+ let(:type_source) do
10
+ { content: 'reasoning here', signature: 'sig-abc', metadata: { origin: 'provider' } }
11
+ end
14
12
 
15
- it 'handles string keys' do
16
- thinking = described_class.from_hash('content' => 'reasoning', 'signature' => 'sig-123')
13
+ it_behaves_like 'a canonical type'
17
14
 
15
+ describe 'T5/T4 — construction law' do
16
+ it 'builds from keyword args' do
17
+ thinking = described_class.build(content: 'reasoning', signature: 'sig')
18
18
  expect(thinking.content).to eq('reasoning')
19
- expect(thinking.signature).to eq('sig-123')
20
- end
21
-
22
- it 'returns nil for nil source' do
23
- expect(described_class.from_hash(nil)).to be_nil
24
- end
25
-
26
- it 'returns nil for empty content and signature' do
27
- result = described_class.from_hash(content: '', signature: '')
28
-
29
- expect(result).to be_nil
30
- end
31
-
32
- it 'returns nil when both fields are nil' do
33
- result = described_class.from_hash(content: nil, signature: nil)
34
-
35
- expect(result).to be_nil
19
+ expect(thinking.signature).to eq('sig')
36
20
  end
37
21
 
38
- it 'returns instance with only content' do
39
- thinking = described_class.from_hash(content: 'just reasoning')
40
-
41
- expect(thinking.content).to eq('just reasoning')
42
- expect(thinking.signature).to be_nil
43
- end
44
-
45
- it 'returns instance with only signature' do
46
- thinking = described_class.from_hash(signature: 'sig-only')
47
-
22
+ it 'normalizes empty strings to nil (absence, not data — 04 §8)' do
23
+ thinking = described_class.build(content: '', signature: '')
48
24
  expect(thinking.content).to be_nil
49
- expect(thinking.signature).to eq('sig-only')
50
- end
51
- end
52
-
53
- describe '#to_h' do
54
- it 'serializes to compact hash' do
55
- thinking = described_class.new(content: 'reasoning', signature: 'sig-1')
56
- hash = thinking.to_h
57
-
58
- expect(hash).to eq(content: 'reasoning', signature: 'sig-1')
59
- end
60
-
61
- it 'omits nil values' do
62
- thinking = described_class.new(content: 'reasoning', signature: nil)
63
- hash = thinking.to_h
64
-
65
- expect(hash).to eq(content: 'reasoning')
66
- end
67
- end
68
-
69
- describe '#empty?' do
70
- it 'returns true when both fields are nil' do
71
- thinking = described_class.new(content: nil, signature: nil)
72
- expect(thinking.empty?).to be true
73
- end
74
-
75
- it 'returns false when content is present' do
76
- thinking = described_class.new(content: 'reasoning', signature: nil)
77
- expect(thinking.empty?).to be false
78
- end
79
- end
80
-
81
- describe 'round-trip' do
82
- it 'preserves content and signature through from_hash/to_h' do
83
- original = { content: 'deep reasoning', signature: 'sig-xyz' }
84
- thinking = described_class.from_hash(original)
85
- serialized = thinking.to_h
86
-
87
- expect(serialized).to eq(original)
88
- end
89
- end
90
-
91
- describe '::Config' do
92
- let(:config_class) { Legion::Extensions::Llm::Canonical::Thinking::Config }
93
-
94
- describe '.build' do
95
- it 'creates a config with effort and budget' do
96
- config = config_class.build(effort: 'high', budget: 10_000)
97
-
98
- expect(config.effort).to eq('high')
99
- expect(config.budget).to eq(10_000)
100
- expect(config.enabled?).to be true
101
- end
102
-
103
- it 'converts symbol effort to string' do
104
- config = config_class.build(effort: :high)
105
-
106
- expect(config.effort).to eq('high')
107
- end
108
-
109
- it 'creates disabled config when no values' do
110
- config = config_class.build
111
-
112
- expect(config.enabled?).to be false
113
- end
114
- end
115
-
116
- describe '.from_hash' do
117
- it 'parses config from hash' do
118
- config = config_class.from_hash(effort: 'medium', budget: 5000)
119
-
120
- expect(config.effort).to eq('medium')
121
- expect(config.budget).to eq(5000)
122
- end
123
-
124
- it 'handles string keys' do
125
- config = config_class.from_hash('effort' => 'low')
126
-
127
- expect(config.effort).to eq('low')
128
- end
129
-
130
- it 'returns nil for nil source' do
131
- expect(config_class.from_hash(nil)).to be_nil
132
- end
133
-
134
- it 'returns nil for empty hash' do
135
- expect(config_class.from_hash({})).to be_nil
136
- end
25
+ expect(thinking.signature).to be_nil
26
+ expect(thinking.empty?).to be(true)
137
27
  end
138
28
 
139
- describe '#to_h' do
140
- it 'serializes to compact hash' do
141
- config = config_class.build(effort: 'high', budget: 10_000)
142
- expect(config.to_h).to eq(effort: 'high', budget: 10_000)
143
- end
144
-
145
- it 'omits nil values' do
146
- config = config_class.build(effort: 'low')
147
- expect(config.to_h).to eq(effort: 'low')
148
- end
29
+ it 'raises on a wrong-class member' do
30
+ expect { described_class.build(content: 42) }
31
+ .to raise_error(ArgumentError, /content expected String, got Integer/)
149
32
  end
150
33
 
151
- # SSOT / one-oracle for cross-provider thinking (NxN best-effort): a client
152
- # dialect supplies only ONE axis (Anthropic = budget_tokens only, OpenAI =
153
- # effort only). Every provider translator must still get a usable value for
154
- # whichever axis IT needs, so thinking survives ANY client×provider pair
155
- # instead of being silently dropped. The conversion lives HERE (the canonical
156
- # config), not scattered/one-directional in each translator. Derived accessors
157
- # do NOT mutate stored state or #to_h — they only fill the gap on read.
158
- describe 'derived cross-axis accessors' do
159
- it 'derives budget from effort when only effort was set' do
160
- expect(config_class.build(effort: 'low').resolved_budget).to eq(1024)
161
- expect(config_class.build(effort: 'medium').resolved_budget).to eq(8192)
162
- expect(config_class.build(effort: 'high').resolved_budget).to eq(16_384)
163
- end
164
-
165
- it 'derives effort from budget by band (< medium=low, < high=medium, else high)' do
166
- expect(config_class.build(budget: 500).resolved_effort).to eq('low') # < 8192
167
- expect(config_class.build(budget: 10_000).resolved_effort).to eq('medium') # 8192..16383
168
- expect(config_class.build(budget: 20_000).resolved_effort).to eq('high') # >= 16384
169
- end
170
-
171
- it 'prefers the explicitly-set value over derivation' do
172
- c = config_class.build(effort: 'high', budget: 2048)
173
- expect(c.resolved_budget).to eq(2048)
174
- expect(c.resolved_effort).to eq('high')
175
- end
176
-
177
- it 'returns nil derivations when neither axis is set' do
178
- c = config_class.build
179
- expect(c.resolved_budget).to be_nil
180
- expect(c.resolved_effort).to be_nil
181
- end
182
-
183
- it 'does not fabricate the missing axis in to_h (stored state stays faithful)' do
184
- expect(config_class.build(effort: 'high').to_h).to eq(effort: 'high')
185
- expect(config_class.build(budget: 2048).to_h).to eq(budget: 2048)
186
- end
34
+ it 'survives build → to_h → JSON → from_hash (T4 signature)' do
35
+ thinking = described_class.build(content: 'x', signature: 'sig-1')
36
+ round_tripped = described_class.from_hash(Legion::JSON.load(Legion::JSON.dump(thinking.to_h)))
37
+ expect(round_tripped.content).to eq('x')
38
+ expect(round_tripped.signature).to eq('sig-1')
187
39
  end
188
40
  end
189
41
  end