easy_talk 3.1.0 → 3.3.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 (68) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +15 -39
  3. data/.yardopts +13 -0
  4. data/CHANGELOG.md +164 -0
  5. data/README.md +442 -1529
  6. data/Rakefile +27 -0
  7. data/docs/.gitignore +1 -0
  8. data/docs/about.markdown +28 -8
  9. data/docs/getting-started.markdown +102 -0
  10. data/docs/index.markdown +51 -4
  11. data/docs/json_schema_compliance.md +169 -0
  12. data/docs/nested-models.markdown +216 -0
  13. data/docs/primitive-schema-rfc.md +894 -0
  14. data/docs/property-types.markdown +212 -0
  15. data/docs/schema-definition.markdown +180 -0
  16. data/lib/easy_talk/builders/base_builder.rb +6 -3
  17. data/lib/easy_talk/builders/boolean_builder.rb +2 -1
  18. data/lib/easy_talk/builders/collection_helpers.rb +4 -0
  19. data/lib/easy_talk/builders/composition_builder.rb +16 -13
  20. data/lib/easy_talk/builders/integer_builder.rb +2 -1
  21. data/lib/easy_talk/builders/null_builder.rb +4 -1
  22. data/lib/easy_talk/builders/number_builder.rb +4 -1
  23. data/lib/easy_talk/builders/object_builder.rb +109 -33
  24. data/lib/easy_talk/builders/registry.rb +182 -0
  25. data/lib/easy_talk/builders/string_builder.rb +3 -1
  26. data/lib/easy_talk/builders/temporal_builder.rb +7 -0
  27. data/lib/easy_talk/builders/tuple_builder.rb +89 -0
  28. data/lib/easy_talk/builders/typed_array_builder.rb +19 -6
  29. data/lib/easy_talk/builders/union_builder.rb +5 -1
  30. data/lib/easy_talk/configuration.rb +47 -2
  31. data/lib/easy_talk/error_formatter/base.rb +100 -0
  32. data/lib/easy_talk/error_formatter/error_code_mapper.rb +82 -0
  33. data/lib/easy_talk/error_formatter/flat.rb +38 -0
  34. data/lib/easy_talk/error_formatter/json_pointer.rb +38 -0
  35. data/lib/easy_talk/error_formatter/jsonapi.rb +64 -0
  36. data/lib/easy_talk/error_formatter/path_converter.rb +53 -0
  37. data/lib/easy_talk/error_formatter/rfc7807.rb +69 -0
  38. data/lib/easy_talk/error_formatter.rb +143 -0
  39. data/lib/easy_talk/errors.rb +3 -0
  40. data/lib/easy_talk/errors_helper.rb +66 -34
  41. data/lib/easy_talk/json_schema_equality.rb +46 -0
  42. data/lib/easy_talk/keywords.rb +0 -1
  43. data/lib/easy_talk/model.rb +148 -89
  44. data/lib/easy_talk/model_helper.rb +17 -0
  45. data/lib/easy_talk/naming_strategies.rb +24 -0
  46. data/lib/easy_talk/property.rb +23 -94
  47. data/lib/easy_talk/ref_helper.rb +33 -0
  48. data/lib/easy_talk/schema.rb +199 -0
  49. data/lib/easy_talk/schema_definition.rb +57 -5
  50. data/lib/easy_talk/schema_methods.rb +111 -0
  51. data/lib/easy_talk/sorbet_extension.rb +1 -0
  52. data/lib/easy_talk/tools/function_builder.rb +1 -1
  53. data/lib/easy_talk/type_introspection.rb +222 -0
  54. data/lib/easy_talk/types/base_composer.rb +2 -1
  55. data/lib/easy_talk/types/composer.rb +4 -0
  56. data/lib/easy_talk/types/tuple.rb +77 -0
  57. data/lib/easy_talk/validation_adapters/active_model_adapter.rb +617 -0
  58. data/lib/easy_talk/validation_adapters/active_model_schema_validation.rb +106 -0
  59. data/lib/easy_talk/validation_adapters/base.rb +156 -0
  60. data/lib/easy_talk/validation_adapters/none_adapter.rb +45 -0
  61. data/lib/easy_talk/validation_adapters/registry.rb +87 -0
  62. data/lib/easy_talk/validation_builder.rb +29 -309
  63. data/lib/easy_talk/version.rb +1 -1
  64. data/lib/easy_talk.rb +42 -0
  65. metadata +38 -7
  66. data/docs/404.html +0 -25
  67. data/docs/_posts/2024-05-07-welcome-to-jekyll.markdown +0 -29
  68. data/easy_talk.gemspec +0 -39
@@ -0,0 +1,894 @@
1
+ # RFC: Primitive Schema Feature with Convention-Based Type Inference
2
+
3
+ **Status**: Proposed
4
+ **Author**: Claude Code
5
+ **Created**: 2025-12-30
6
+ **Updated**: 2025-12-30
7
+
8
+ ---
9
+
10
+ ## Abstract
11
+
12
+ This document proposes adding three interrelated features to EasyTalk:
13
+
14
+ 1. **Primitive Type Classes** — Reusable primitive schema definitions via `EasyTalk::Primitive`
15
+ 2. **Convention-Based Type Inference** — Auto-infer types from property names (conservative defaults)
16
+ 3. **Symbol-Based Type Syntax** — Alternative to Ruby constants for type declarations
17
+
18
+ These features address user feedback about API verbosity and enable more expressive, DRY schema definitions while maintaining full backward compatibility.
19
+
20
+ ---
21
+
22
+ ## Motivation
23
+
24
+ ### Problem Statement
25
+
26
+ **1. API Verbosity**
27
+
28
+ Users have expressed that the current API requires explicit type declarations that can feel verbose:
29
+
30
+ ```ruby
31
+ # Current API
32
+ property :email, String, format: 'email'
33
+ property :phone, String, pattern: '^\+?[1-9]\d{1,14}$'
34
+ property :age, Integer, minimum: 0
35
+ property :is_active, T::Boolean
36
+ ```
37
+
38
+ Common patterns are repeated across models, and property names often imply their types (`:email` is almost always an email-formatted string).
39
+
40
+ **2. JSON Schema Compliance Gap**
41
+
42
+ EasyTalk cannot generate or validate root-level primitive schemas. The `EasyTalk::Model` mixin always produces object schemas (`{ "type": "object", ... }`). This means:
43
+
44
+ - The [JSON Schema Test Suite](https://github.com/json-schema-org/JSON-Schema-Test-Suite) tests for root integers, strings, booleans, etc. must be skipped (see [json_schema_compliance.md](json_schema_compliance.md))
45
+ - Users cannot define standalone primitive schemas for LLM function calls or API validation
46
+
47
+ ```ruby
48
+ # NOT POSSIBLE TODAY:
49
+ # Generate { "type": "string", "format": "email" } as a root schema
50
+ # Validate a raw string value against that schema
51
+ ```
52
+
53
+ ### Goals
54
+
55
+ 1. **Reduce boilerplate** for common patterns
56
+ 2. **Enable reusable type definitions** across models
57
+ 3. **Provide intuitive defaults** based on naming conventions
58
+ 4. **Enable JSON Schema compliance** for root-level primitive schemas
59
+ 5. **Maintain backward compatibility** — existing code must continue to work unchanged
60
+
61
+ ---
62
+
63
+ ## Detailed Design
64
+
65
+ ### Feature 1: Primitive Type Classes
66
+
67
+ #### Overview
68
+
69
+ Create reusable primitive schema definitions that encapsulate type + constraints.
70
+
71
+ #### Syntax
72
+
73
+ ```ruby
74
+ class Email < EasyTalk::Primitive(:string, format: 'email')
75
+ class PhoneNumber < EasyTalk::Primitive(:string, pattern: '^\+?[1-9]\d{1,14}$')
76
+ class PositiveInteger < EasyTalk::Primitive(:integer, minimum: 0)
77
+ class Percentage < EasyTalk::Primitive(:number, minimum: 0, maximum: 100)
78
+ ```
79
+
80
+ This syntax passes constraints directly as keyword arguments, leveraging the existing builder `VALID_OPTIONS` for validation. No separate DSL is needed.
81
+
82
+ #### Usage in Models
83
+
84
+ ```ruby
85
+ class User
86
+ include EasyTalk::Model
87
+
88
+ define_schema do
89
+ property :email, Email
90
+ property :phone, PhoneNumber
91
+ property :age, PositiveInteger
92
+ end
93
+ end
94
+ ```
95
+
96
+ #### Generated Schema
97
+
98
+ ```json
99
+ {
100
+ "type": "object",
101
+ "properties": {
102
+ "email": { "type": "string", "format": "email" },
103
+ "phone": { "type": "string", "pattern": "^\\+?[1-9]\\d{1,14}$" },
104
+ "age": { "type": "integer", "minimum": 0 }
105
+ },
106
+ "required": ["email", "phone", "age"]
107
+ }
108
+ ```
109
+
110
+ #### Implementation
111
+
112
+ **New File**: `lib/easy_talk/primitive.rb`
113
+
114
+ ```ruby
115
+ module EasyTalk
116
+ class Primitive
117
+ class << self
118
+ # Factory method for creating primitive type classes
119
+ # Usage: class Email < EasyTalk::Primitive(:string, format: 'email')
120
+ #
121
+ # Constraints are passed as keyword arguments and validated by the
122
+ # underlying builder's VALID_OPTIONS - no separate DSL needed.
123
+ def call(type_name, **constraints)
124
+ Class.new(self) do
125
+ @base_type = type_name
126
+ @constraints = constraints.freeze
127
+
128
+ class << self
129
+ attr_reader :base_type, :constraints
130
+
131
+ # IMPORTANT: Must be named `schema` (not `json_schema`) to match
132
+ # Property#build expectation at lib/easy_talk/property.rb:111
133
+ # Property checks: type.respond_to?(:schema) and calls type.schema
134
+ def schema
135
+ @schema ||= build_schema
136
+ end
137
+
138
+ # Public: Returns the underlying Ruby type (String, Integer, etc.)
139
+ # Used by ActiveModelAdapter to apply correct validations
140
+ def ruby_type
141
+ EasyTalk::TypeResolver.resolve(@base_type)
142
+ end
143
+
144
+ private
145
+
146
+ def build_schema
147
+ builder_class = EasyTalk::Builders::Registry.fetch(ruby_type)
148
+ # Builder validates constraints via its VALID_OPTIONS
149
+ builder = builder_class.new(:value, **@constraints)
150
+ builder.build
151
+ end
152
+ end
153
+ end
154
+ end
155
+ alias_method :[], :call
156
+ end
157
+ end
158
+ end
159
+ ```
160
+
161
+ **Key Design Decision**: No `ConstraintCollector` DSL is needed. The existing builders already define `VALID_OPTIONS` that enumerate valid constraints with type checking. By passing constraints as keyword arguments directly to `EasyTalk::Primitive(:type, **constraints)`, we:
162
+
163
+ 1. **Eliminate duplication** — builders remain the single source of truth for constraint validation
164
+ 2. **Get automatic validation** — `BaseBuilder#build` validates constraint types via `ErrorHelper.validate_constraint_value`
165
+ 3. **Simplify the API** — one syntax (`key: value`) instead of learning DSL methods
166
+
167
+ #### ActiveModel Validation Integration (CRITICAL)
168
+
169
+ **Problem**: The `ActiveModelAdapter#apply_type_validations` method checks for `String`, `Integer`, `Float`, etc., but has no handling for `Primitive` subclasses. Without a fix:
170
+ - ✅ JSON Schema generation works correctly
171
+ - ❌ ActiveModel validations are NOT generated
172
+
173
+ **Solution**: Unwrap Primitive types in the adapter.
174
+
175
+ **Modified File**: `lib/easy_talk/validation_adapters/active_model_adapter.rb`
176
+
177
+ ```ruby
178
+ def apply_type_validations(type)
179
+ # Handle Primitive subclasses - unwrap to base type and merge constraints
180
+ if type.respond_to?(:ancestors) && type.ancestors.include?(EasyTalk::Primitive)
181
+ # Merge Primitive's constraints with property constraints (property wins on conflict)
182
+ @constraints = type.constraints.merge(@constraints)
183
+ # Recurse with the underlying Ruby type
184
+ return apply_type_validations(type.ruby_type)
185
+ end
186
+
187
+ # ... existing type checks for String, Integer, etc. ...
188
+ end
189
+ ```
190
+
191
+ #### Property Integration
192
+
193
+ **No changes needed to `lib/easy_talk/property.rb`**. The existing code already handles types with `.schema`:
194
+
195
+ ```ruby
196
+ # Line 111-114 in property.rb (existing code)
197
+ elsif type.respond_to?(:schema)
198
+ type.schema.merge!(constraints)
199
+ ```
200
+
201
+ Since Primitive implements `.schema`, Property will:
202
+ 1. Detect `Email.respond_to?(:schema)` → true
203
+ 2. Call `Email.schema` → `{ type: 'string', format: 'email' }`
204
+ 3. Merge property-level constraints
205
+
206
+ #### Validation: Two Contexts
207
+
208
+ Primitives can be validated in two different contexts:
209
+
210
+ **Context 1: Wrapped in a Model (property validation)**
211
+
212
+ When a Primitive is used as a property type within an `EasyTalk::Model`, validation happens at the Model level via ActiveModel:
213
+
214
+ ```ruby
215
+ class Email < EasyTalk::Primitive(:string, format: 'email')
216
+
217
+ class User
218
+ include EasyTalk::Model
219
+ define_schema do
220
+ property :email, Email
221
+ end
222
+ end
223
+
224
+ user = User.new(email: "invalid")
225
+ user.valid? # => false (ActiveModel validation)
226
+ user.errors[:email] # => ["must be a valid email address"]
227
+ ```
228
+
229
+ The `ActiveModelAdapter` unwraps the Primitive to its base type (`String`) and merges its constraints (`format: 'email'`), then applies standard ActiveModel validations. **No special Primitive validation logic runs** — it's all standard Model validation.
230
+
231
+ **Context 2: Standalone (direct validation)**
232
+
233
+ For root-level primitive validation (e.g., JSON Schema compliance tests), Primitives provide class-level validation:
234
+
235
+ ```ruby
236
+ class Email < EasyTalk::Primitive(:string, format: 'email')
237
+
238
+ # Class-level validation for raw values
239
+ Email.valid?("test@example.com") # => true
240
+ Email.valid?("invalid") # => false
241
+ Email.validate("invalid") # => ["must be a valid email address"]
242
+ ```
243
+
244
+ This enables JSON Schema compliance testing for root primitives:
245
+
246
+ ```ruby
247
+ # In spec/integration/json_schema_compliance_spec.rb
248
+ PositiveInteger = EasyTalk::Primitive(:integer, minimum: 0)
249
+
250
+ # Test cases from JSON Schema Test Suite
251
+ expect(PositiveInteger.valid?(5)).to eq(true)
252
+ expect(PositiveInteger.valid?(-1)).to eq(false)
253
+ expect(PositiveInteger.valid?("foo")).to eq(false)
254
+ ```
255
+
256
+ **Implementation for standalone validation:**
257
+
258
+ Standalone validation reuses `ActiveModelAdapter` to ensure identical behavior in both contexts. A lightweight validator class is created once and cached:
259
+
260
+ ```ruby
261
+ module EasyTalk
262
+ class Primitive
263
+ class << self
264
+ def call(type_name, **constraints)
265
+ Class.new(self) do
266
+ @base_type = type_name
267
+ @constraints = constraints.freeze
268
+
269
+ class << self
270
+ attr_reader :base_type, :constraints
271
+
272
+ # ... existing schema/ruby_type methods ...
273
+
274
+ # Validate a raw value against this Primitive's schema
275
+ # Returns true if valid, false otherwise
276
+ def valid?(value)
277
+ validator_instance(value).valid?
278
+ end
279
+
280
+ # Returns ActiveModel::Errors-style error messages
281
+ # Example: { value: ["must be a valid email address"] }
282
+ def validate(value)
283
+ instance = validator_instance(value)
284
+ instance.valid?
285
+ instance.errors.to_hash
286
+ end
287
+
288
+ private
289
+
290
+ # Creates a validator instance with the given value
291
+ def validator_instance(value)
292
+ validator_class.new(value)
293
+ end
294
+
295
+ # Lazily builds and caches a validator class with ActiveModel validations
296
+ # This class is created once per Primitive subclass
297
+ def validator_class
298
+ @validator_class ||= build_validator_class
299
+ end
300
+
301
+ def build_validator_class
302
+ primitive_type = ruby_type
303
+ primitive_constraints = @constraints
304
+
305
+ Class.new do
306
+ include ActiveModel::Validations
307
+
308
+ attr_accessor :value
309
+
310
+ def initialize(value)
311
+ @value = value
312
+ end
313
+
314
+ # Make error messages cleaner (not "Value must be..." but "must be...")
315
+ def self.name
316
+ 'PrimitiveValidator'
317
+ end
318
+ end.tap do |klass|
319
+ # Reuse ActiveModelAdapter to apply the same validations
320
+ # used when Primitive is wrapped in a Model
321
+ ValidationAdapters::ActiveModelAdapter.build_validations(
322
+ klass,
323
+ :value,
324
+ primitive_type,
325
+ primitive_constraints
326
+ )
327
+ end
328
+ end
329
+ end
330
+ end
331
+ end
332
+ end
333
+ end
334
+ end
335
+ ```
336
+
337
+ **Key benefits of this approach:**
338
+
339
+ 1. **Single source of truth** — `ActiveModelAdapter` is the only place validation logic lives
340
+ 2. **Identical behavior** — Standalone and Model-wrapped validation produce the same results
341
+ 3. **Same error messages** — "must be a valid email address" in both contexts
342
+ 4. **No duplication** — No separate constraint validation code to maintain
343
+
344
+ ---
345
+
346
+ ### Feature 2: Symbol-Based Type Syntax
347
+
348
+ #### Overview
349
+
350
+ Allow symbols as type declarations, providing a more JSON Schema-aligned syntax.
351
+
352
+ #### Syntax
353
+
354
+ ```ruby
355
+ class User
356
+ include EasyTalk::Model
357
+
358
+ define_schema do
359
+ property :name, :string, min_length: 1
360
+ property :age, :integer, minimum: 0
361
+ property :score, :number
362
+ property :active, :boolean
363
+ end
364
+ end
365
+ ```
366
+
367
+ #### Supported Symbols
368
+
369
+ | Symbol | Maps To |
370
+ |--------|---------|
371
+ | `:string` | `String` |
372
+ | `:integer` | `Integer` |
373
+ | `:number` | `Float` |
374
+ | `:boolean` | `T::Boolean` |
375
+ | `:array` | `Array` |
376
+ | `:null` | `NilClass` |
377
+
378
+ #### Implementation
379
+
380
+ **New File**: `lib/easy_talk/type_resolver.rb`
381
+
382
+ **CRITICAL**: Symbol resolution MUST happen in SchemaDefinition BEFORE the type is passed to:
383
+ 1. Property (for JSON Schema generation)
384
+ 2. ActiveModelAdapter (for validation generation)
385
+
386
+ Both components expect Ruby classes, not symbols. Passing `:string` instead of `String` will break `type.is_a?(Class)` checks.
387
+
388
+ ```ruby
389
+ module EasyTalk
390
+ class TypeResolver
391
+ SYMBOL_TO_TYPE = {
392
+ string: String,
393
+ integer: Integer,
394
+ number: Float,
395
+ boolean: T::Boolean,
396
+ array: Array,
397
+ null: NilClass
398
+ }.freeze
399
+
400
+ class << self
401
+ # Resolves type to a Ruby class
402
+ # @param type [Symbol, Class, Object] the type to resolve
403
+ # @return [Class] the resolved Ruby class
404
+ # @raise [ArgumentError] if symbol is unknown
405
+ def resolve(type)
406
+ case type
407
+ when Symbol
408
+ SYMBOL_TO_TYPE.fetch(type) do
409
+ raise ArgumentError, "Unknown type symbol: #{type.inspect}. Valid symbols: #{SYMBOL_TO_TYPE.keys.join(', ')}"
410
+ end
411
+ when Class
412
+ type
413
+ else
414
+ type # Pass through T:: types, etc.
415
+ end
416
+ end
417
+
418
+ # Check if a value is a resolvable symbol
419
+ def symbol_type?(type)
420
+ type.is_a?(Symbol) && SYMBOL_TO_TYPE.key?(type)
421
+ end
422
+ end
423
+ end
424
+ end
425
+ ```
426
+
427
+ ---
428
+
429
+ ### Feature 3: Convention-Based Type Inference
430
+
431
+ #### Overview
432
+
433
+ Automatically infer property types and constraints based on naming patterns.
434
+
435
+ #### Design Principles
436
+
437
+ - **Conservative defaults** — Only unambiguous, safe patterns
438
+ - **No Float inference for financial fields** — Risk of precision loss
439
+ - **Per-schema override capability** — Full control when needed
440
+ - **Explicit opt-in required** — No magic by default
441
+
442
+ #### Activation
443
+
444
+ **Per-schema** (recommended):
445
+ ```ruby
446
+ define_schema do
447
+ infer_types true
448
+ property :email # Inferred as String with format: 'email'
449
+ end
450
+ ```
451
+
452
+ **With custom conventions** (merged with global):
453
+ ```ruby
454
+ define_schema do
455
+ infer_types conventions: {
456
+ /\A.*_at\z/ => { type: String, constraints: { format: 'date-time' } },
457
+ /\Aurl\z/i => { type: String, constraints: { format: 'uri' } }
458
+ }
459
+ end
460
+ ```
461
+
462
+ **With ONLY specific conventions** (ignores global):
463
+ ```ruby
464
+ define_schema do
465
+ infer_types only: {
466
+ /\Aemail\z/i => { type: String, constraints: { format: 'email' } }
467
+ }
468
+ end
469
+ ```
470
+
471
+ #### Conservative Default Conventions
472
+
473
+ Only patterns that are **unambiguous**, **safe**, and **standard**:
474
+
475
+ | Pattern | Type | Constraints | Why Safe |
476
+ |---------|------|-------------|----------|
477
+ | `/\Aemail\z/i` | String | `format: 'email'` | Always a string, always email format |
478
+ | `/\A(is_\|has_\|can_\|should_\|was_\|will_)/` | T::Boolean | — | Ruby/Rails boolean naming convention |
479
+ | `/\Auuid\z/i` | String | `format: 'uuid'` | Explicit field name, clear intent |
480
+
481
+ #### Intentionally EXCLUDED Patterns
482
+
483
+ | Pattern | Why Excluded |
484
+ |---------|--------------|
485
+ | `price`, `cost`, `total` → Float | **Dangerous for financial data**. Float precision issues cause real bugs. Use Integer (cents) or BigDecimal. |
486
+ | `*_id` → uuid format | Could be integer auto-increment IDs, not UUIDs |
487
+ | `*_count` → Integer | Could be Float for averages |
488
+ | `*_at`, `*_date` → date-time | Could be DateTime objects, not strings |
489
+
490
+ #### Usage Examples
491
+
492
+ ```ruby
493
+ # Per-schema activation with conservative global defaults
494
+ class User
495
+ include EasyTalk::Model
496
+
497
+ define_schema do
498
+ infer_types true
499
+
500
+ property :email # Inferred: String, format: 'email'
501
+ property :is_admin # Inferred: T::Boolean (is_ prefix)
502
+ property :has_verified # Inferred: T::Boolean (has_ prefix)
503
+ property :uuid # Inferred: String, format: 'uuid'
504
+ property :name, String # Explicit type - no inference
505
+ property :age, Integer # Explicit type - no inference
506
+ end
507
+ end
508
+
509
+ # Per-schema with custom conventions (merged with global)
510
+ class Event
511
+ include EasyTalk::Model
512
+
513
+ define_schema do
514
+ infer_types conventions: {
515
+ /\A.*_at\z/ => { type: String, constraints: { format: 'date-time' } },
516
+ /\Aurl\z/i => { type: String, constraints: { format: 'uri' } }
517
+ }
518
+
519
+ property :email # From global: String, format: 'email'
520
+ property :created_at # From local: String, format: 'date-time'
521
+ property :url # From local: String, format: 'uri'
522
+ end
523
+ end
524
+
525
+ # Per-schema with ONLY specific conventions (ignores global)
526
+ class Payment
527
+ include EasyTalk::Model
528
+
529
+ define_schema do
530
+ infer_types only: {
531
+ /\Aemail\z/i => { type: String, constraints: { format: 'email' } }
532
+ }
533
+
534
+ property :email # Inferred: String, format: 'email'
535
+ property :is_refunded, T::Boolean # Must be explicit (only: ignores global)
536
+ end
537
+ end
538
+ ```
539
+
540
+ #### Implementation
541
+
542
+ **Modified File**: `lib/easy_talk/configuration.rb`
543
+
544
+ ```ruby
545
+ class Configuration
546
+ attr_accessor :infer_types
547
+ attr_reader :type_conventions
548
+
549
+ def initialize
550
+ @infer_types = false # Disabled by default
551
+ @type_conventions = default_conventions
552
+ end
553
+
554
+ def register_convention(pattern, type:, **constraints)
555
+ @type_conventions[pattern] = { type: type, constraints: constraints }
556
+ end
557
+
558
+ def clear_conventions!
559
+ @type_conventions = {}
560
+ end
561
+
562
+ def reset_conventions!
563
+ @type_conventions = default_conventions
564
+ end
565
+
566
+ private
567
+
568
+ # CONSERVATIVE DEFAULTS: Only include patterns that are:
569
+ # 1. Unambiguous (email is always email-formatted string)
570
+ # 2. Safe (no precision-sensitive types like Float for money)
571
+ # 3. Standard (widely accepted conventions)
572
+ def default_conventions
573
+ {
574
+ /\Aemail\z/i => { type: String, constraints: { format: 'email' } },
575
+ /\A(is_|has_|can_|should_|was_|will_)/ => { type: T::Boolean, constraints: {} },
576
+ /\Auuid\z/i => { type: String, constraints: { format: 'uuid' } }
577
+ }
578
+ end
579
+ end
580
+ ```
581
+
582
+ **Modified File**: `lib/easy_talk/schema_definition.rb`
583
+
584
+ **Resolution Order** (CRITICAL for compatibility):
585
+ 1. Resolve symbols to Ruby classes (`:string` → `String`)
586
+ 2. Infer type from name if no explicit type and inference enabled
587
+ 3. Store the resolved Ruby class (never a symbol)
588
+
589
+ ```ruby
590
+ class SchemaDefinition
591
+ def initialize(name, options = {})
592
+ # ... existing code ...
593
+ @infer_types_enabled = false
594
+ @local_conventions = nil # nil = use global, hash = use local
595
+ end
596
+
597
+ def property(name, type = nil, **constraints, &block)
598
+ # CRITICAL: resolved_type is ALWAYS a Ruby class, never a symbol
599
+ resolved_type, inferred_constraints = resolve_property_type(name, type)
600
+ merged_constraints = inferred_constraints.merge(constraints)
601
+
602
+ # Store the resolved class - Property and ActiveModelAdapter expect classes
603
+ # ... rest of existing property logic ...
604
+ end
605
+
606
+ # Per-schema DSL option with flexible arguments
607
+ def infer_types(enabled_or_options = true)
608
+ case enabled_or_options
609
+ when true
610
+ @infer_types_enabled = true
611
+ @local_conventions = nil # Use global
612
+ when false
613
+ @infer_types_enabled = false
614
+ when Hash
615
+ @infer_types_enabled = true
616
+ if enabled_or_options[:only]
617
+ @local_conventions = enabled_or_options[:only]
618
+ elsif enabled_or_options[:conventions]
619
+ @local_conventions = EasyTalk.configuration.type_conventions
620
+ .merge(enabled_or_options[:conventions])
621
+ end
622
+ end
623
+ end
624
+
625
+ private
626
+
627
+ def resolve_property_type(name, explicit_type)
628
+ if explicit_type
629
+ resolved = resolve_type(explicit_type)
630
+ return [resolved, {}]
631
+ end
632
+
633
+ return [String, {}] unless @infer_types_enabled
634
+
635
+ infer_type_from_name(name)
636
+ end
637
+
638
+ def resolve_type(type)
639
+ case type
640
+ when Symbol
641
+ EasyTalk::TypeResolver.resolve(type)
642
+ when Class
643
+ type
644
+ else
645
+ type
646
+ end
647
+ end
648
+
649
+ def infer_type_from_name(name)
650
+ conventions = @local_conventions || EasyTalk.configuration.type_conventions
651
+
652
+ conventions.each do |pattern, config|
653
+ if name.to_s.match?(pattern)
654
+ return [config[:type], config[:constraints] || {}]
655
+ end
656
+ end
657
+
658
+ [String, {}] # Default fallback
659
+ end
660
+ end
661
+ ```
662
+
663
+ ---
664
+
665
+ ## Files Summary
666
+
667
+ ### New Files
668
+
669
+ | File | Purpose |
670
+ |------|---------|
671
+ | `lib/easy_talk/primitive.rb` | Primitive base class with schema generation and standalone validation |
672
+ | `lib/easy_talk/type_resolver.rb` | Symbol → Ruby type resolution |
673
+ | `spec/easy_talk/primitive_spec.rb` | Unit tests for Primitive class (schema + validation) |
674
+ | `spec/easy_talk/type_resolver_spec.rb` | Unit tests for TypeResolver |
675
+ | `spec/easy_talk/type_inference_spec.rb` | Unit tests for type inference |
676
+
677
+ ### Modified Files
678
+
679
+ | File | Changes |
680
+ |------|---------|
681
+ | `lib/easy_talk/configuration.rb` | Add `infer_types`, `type_conventions`, convention methods |
682
+ | `lib/easy_talk/schema_definition.rb` | Add type inference, symbol resolution, `infer_types` DSL |
683
+ | `lib/easy_talk/validation_adapters/active_model_adapter.rb` | **CRITICAL**: Unwrap Primitive types for validation generation |
684
+ | `lib/easy_talk.rb` | Require new files |
685
+ | `spec/integration/json_schema_compliance_spec.rb` | Enable root primitive tests (previously skipped) |
686
+
687
+ **Note**: `lib/easy_talk/property.rb` does NOT need modification. It already handles types with `.schema` via duck typing (line 111-114).
688
+
689
+ ---
690
+
691
+ ## Backward Compatibility
692
+
693
+ This proposal is **fully backward compatible**:
694
+
695
+ | Aspect | Impact |
696
+ |--------|--------|
697
+ | `infer_types` | Defaults to `false` — no behavior change |
698
+ | Existing `property :name, Type` | Works unchanged |
699
+ | Symbol types | New syntax, doesn't affect existing code |
700
+ | Primitive classes | New feature, doesn't affect existing code |
701
+ | Configuration | New options only, existing options unchanged |
702
+
703
+ **No breaking changes.**
704
+
705
+ ---
706
+
707
+ ## Edge Cases
708
+
709
+ | Scenario | Behavior |
710
+ |----------|----------|
711
+ | Empty convention list | Falls back to `String` |
712
+ | Conflicting conventions | First match wins (hash iteration order) |
713
+ | Primitive with no constraints | Valid — creates type-only schema |
714
+ | Inference disabled globally, enabled per-schema | Per-schema takes precedence |
715
+ | Explicit type with inference enabled | Explicit type always wins |
716
+ | Unknown symbol type | Raises `ArgumentError` with helpful message |
717
+
718
+ ---
719
+
720
+ ## Test Plan
721
+
722
+ ### Unit Tests
723
+
724
+ 1. **Primitive class** (`spec/easy_talk/primitive_spec.rb`)
725
+ - Creates valid schema from keyword arguments
726
+ - Delegates constraint validation to underlying builders
727
+ - Raises errors for invalid constraints (via builder's VALID_OPTIONS)
728
+ - Works with both symbol and constant type syntax
729
+ - Generates correct JSON Schema output
730
+ - Exposes `ruby_type` and `constraints` for ActiveModelAdapter
731
+ - Standalone validation via `.valid?` and `.validate` class methods
732
+
733
+ 2. **Type inference** (`spec/easy_talk/type_inference_spec.rb`)
734
+ - Default conventions work correctly
735
+ - Custom conventions can be registered
736
+ - Per-schema `infer_types` DSL works
737
+ - `infer_types conventions: {...}` merges with global
738
+ - `infer_types only: {...}` ignores global
739
+ - Explicit types override inference
740
+ - Unknown names default to String
741
+
742
+ 3. **Symbol types** (`spec/easy_talk/type_resolver_spec.rb`)
743
+ - All symbol types resolve correctly
744
+ - Unknown symbols raise errors with helpful message
745
+ - Classes pass through unchanged
746
+
747
+ 4. **Symbol resolution timing** (CRITICAL)
748
+ - `property :name, :string` resolves to `String` before reaching Property
749
+ - `property :name, :string` resolves to `String` before reaching ActiveModelAdapter
750
+ - Unknown symbols raise `ArgumentError` with helpful message
751
+
752
+ 5. **ActiveModel validation** (Model-wrapped context)
753
+ - Primitive constraints generate correct ActiveModel validations
754
+ - `Email` primitive with `format 'email'` validates email format
755
+ - `PositiveInteger` primitive with `minimum 0` rejects negative numbers
756
+ - Property-level constraints override Primitive constraints
757
+ - Both JSON Schema AND ActiveModel validation work correctly
758
+
759
+ 6. **Standalone Primitive validation** (direct context)
760
+ - `Primitive.valid?(value)` returns boolean
761
+ - `Primitive.validate(value)` returns hash of error messages
762
+ - Uses same `ActiveModelAdapter` as Model-wrapped context
763
+ - Validator class is cached per Primitive subclass
764
+
765
+ 7. **Validation parity** (CRITICAL)
766
+ - Model-wrapped and standalone validation produce identical results
767
+ - Same error messages in both contexts
768
+ - Same constraint behavior (min_length, format, etc.)
769
+
770
+ ### Integration Tests
771
+
772
+ - Primitives work in Model properties (Model-wrapped validation)
773
+ - Primitives work standalone (direct validation)
774
+ - **Both contexts produce identical validation results**
775
+ - Inference works with Model properties
776
+ - Symbol types work everywhere
777
+ - Constraints merge correctly (explicit overrides inferred)
778
+
779
+ ### JSON Schema Compliance Tests
780
+
781
+ - Root-level integer schemas pass compliance tests
782
+ - Root-level string schemas pass compliance tests
783
+ - Root-level boolean schemas pass compliance tests
784
+ - Root-level number schemas pass compliance tests
785
+ - Constraint combinations work correctly
786
+
787
+ ### Edge Cases
788
+
789
+ - Empty convention list
790
+ - Conflicting conventions (first match wins)
791
+ - Primitive with no constraints
792
+ - Inference disabled globally but enabled per-schema
793
+ - Nested models with mixed inference settings
794
+
795
+ ---
796
+
797
+ ## Design Rationale: Conservative Defaults
798
+
799
+ ### Why Minimal Default Conventions?
800
+
801
+ 1. **Financial Data Safety**: Inferring `Float` for `price`, `cost`, `total` is dangerous. Float precision issues can cause real financial bugs. Users should explicitly choose `Integer` (cents), `BigDecimal`, or `Float` based on their needs.
802
+
803
+ 2. **ID Ambiguity**: `*_id` fields could be:
804
+ - Integer auto-increment IDs
805
+ - UUID strings
806
+ - External system IDs (various formats)
807
+
808
+ Assuming `format: 'uuid'` would break many applications.
809
+
810
+ 3. **Count Ambiguity**: `*_count` could be:
811
+ - Integer counts
812
+ - Float averages
813
+ - Decimal for precision
814
+
815
+ 4. **Explicit is Better Than Implicit**: Schema definitions should be predictable. Magic that "just works" until it doesn't is worse than requiring explicit types.
816
+
817
+ ### Per-Schema Override Philosophy
818
+
819
+ - `infer_types true` — Opt-in to global conventions
820
+ - `infer_types conventions: {...}` — Add project-specific patterns
821
+ - `infer_types only: {...}` — Full control, no surprises from global config
822
+
823
+ This ensures models remain isolated and predictable.
824
+
825
+ ---
826
+
827
+ ## Alternatives Considered
828
+
829
+ ### Alternative 1: Schema-Only Mode via Class Method
830
+
831
+ ```ruby
832
+ EasyTalk.schema(:string, min_length: 1, format: 'email')
833
+ # => { "type": "string", "minLength": 1, "format": "email" }
834
+ ```
835
+
836
+ **Rejected**: Produces schemas but not reusable types. Doesn't integrate with Model properties.
837
+
838
+ ### Alternative 2: Anonymous Property Builder
839
+
840
+ ```ruby
841
+ EasyTalk::Property.build(:value, String, format: 'email').schema
842
+ ```
843
+
844
+ **Rejected**: More verbose than Primitive classes. Awkward API for reuse.
845
+
846
+ ### Alternative 3: Type inference only (no Primitives)
847
+
848
+ **Rejected**: Primitives provide explicit, documented types that are easier to understand and test than convention-based inference alone.
849
+
850
+ ### Alternative 4: Aggressive default conventions
851
+
852
+ **Rejected**: Patterns like `price` → `Float` are dangerous for financial applications. Conservative defaults prevent subtle bugs.
853
+
854
+ ### Alternative 5: Block DSL with ConstraintCollector
855
+
856
+ ```ruby
857
+ class Email < EasyTalk::Primitive(:string) { format 'email' }
858
+ ```
859
+
860
+ **Rejected**: Creates duplication with builder `VALID_OPTIONS`. Each builder already defines valid constraints with type checking. A separate `ConstraintCollector` class would need to mirror all constraint methods (`format`, `pattern`, `minimum`, etc.) and keep them in sync with builders. Keyword arguments are simpler and leverage existing validation.
861
+
862
+ ---
863
+
864
+ ## Implementation Order
865
+
866
+ 1. **Phase 1**: TypeResolver + symbol types (smallest scope, foundational)
867
+ 2. **Phase 2**: Primitive class (builds on Phase 1)
868
+ 3. **Phase 3**: ActiveModelAdapter Primitive support (CRITICAL)
869
+ 4. **Phase 4**: Convention registry in Configuration
870
+ 5. **Phase 5**: Type inference in SchemaDefinition
871
+ 6. **Phase 6**: Documentation and comprehensive tests
872
+
873
+ ---
874
+
875
+ ## Open Questions
876
+
877
+ 1. **Convention ordering**: Should we use an ordered data structure instead of Hash to ensure deterministic matching?
878
+
879
+ 2. **Primitive composition**: Should Primitives support composing with other Primitives?
880
+ ```ruby
881
+ class NonEmptyEmail < EasyTalk::Primitive(Email, min_length: 1)
882
+ ```
883
+
884
+ 3. **Array item type inference**: Should `property :emails` infer `T::Array[Email]`?
885
+
886
+ 4. **Symbols in T:: types**: Should `T::Array[:string]` work, or raise an error?
887
+
888
+ ---
889
+
890
+ ## References
891
+
892
+ - [Pydantic Field Types](https://docs.pydantic.dev/latest/concepts/types/) — Inspiration for Primitive concept
893
+ - [JSON Schema Specification](https://json-schema.org/specification.html)
894
+ - [Rails Convention over Configuration](https://rubyonrails.org/doctrine#convention-over-configuration)