flowengine 0.1.0 → 0.1.2

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.
data/README.md CHANGED
@@ -1,132 +1,858 @@
1
- # Flowengine
1
+ # FlowEngine
2
2
 
3
3
  [![RSpec](https://github.com/kigster/flowengine/actions/workflows/rspec.yml/badge.svg)](https://github.com/kigster/flowengine/actions/workflows/rspec.yml) [![RuboCop](https://github.com/kigster/flowengine/actions/workflows/rubocop.yml/badge.svg)](https://github.com/kigster/flowengine/actions/workflows/rubocop.yml)
4
4
 
5
- > [!IMPORTANT]
6
- >
7
- > Gem's Responsibilities
8
- >
9
- > * DSL
10
- > * Flow Definition
11
- > * AST-based Rule system
12
- > * Evaluator
13
- > * Engine runtime
14
- > * Validation adapter interface
15
- > * Graph exporter (Mermaid)
16
- > * Simulation runner
17
- > * No ActiveRecord.
18
- > * No Rails.
19
- > * No terminal code.
20
-
21
- ### Proposed Gem Structure
22
-
23
- ```text
24
- flowengine/
25
- ├── lib/
26
- │ ├── flowengine.rb
27
- │ ├── flowengine/
28
- │ │ ├── definition.rb
29
- │ │ ├── dsl.rb
30
- │ │ ├── node.rb
31
- │ │ ├── rule_ast.rb
32
- │ │ ├── evaluator.rb
33
- │ │ ├── engine.rb
34
- │ │ ├── validation/
35
- │ │ │ ├── adapter.rb
36
- │ │ │ └── dry_validation_adapter.rb
37
- │ │ ├── graph/
38
- │ │ │ └── mermaid_exporter.rb
39
- │ │ └── simulation.rb
40
- ├── exe/
41
- │ └── flowengine
42
- ```
43
-
44
- #### Core Concepts
45
-
46
- Immutable structure representing flow graph.
5
+ This gem is the foundation of collecting complex multi-branch information from a user using a flow definition written in Ruby DSL. It shouldn't take too long to learn the DSL even for a non-technical person.
6
+
7
+ This gem does not have any UI or an interactive component. It is used as the foundation for additional gems that are built on top of this one, and provide various interactive interfaces for colleting information based on the DSL definition.
8
+
9
+ The simplest way to see this in action is to use the companion gem [`flowengine-cli`](https://rubygems.org/gems/flowengine-cli), which, given the flow DSL will walk the user through the questioniare according to the DSL flow definition, but using terminal UI and ASCII-based flow.
10
+
11
+ **A slightly different explanation is that it offere a declarative flow engine for building rules-driven wizards and intake forms in pure Ruby.**
12
+
13
+ > [!NOTE]
14
+ > FlowEngine lets you define multi-step flows as **directed graphs** with **conditional branching**, evaluate transitions using an **AST-based rule system**, and collect structured answers through a **stateful runtime engine** — all without framework dependencies.
15
+
16
+ > [!CAUTION]
17
+ > **This is not a form builder.** It's a *Form Definition Engine* that separates flow logic, data schema, and UI rendering into independent concerns.
18
+
19
+ ## Installation
20
+
21
+ Add to your Gemfile:
47
22
 
48
23
  ```ruby
49
- FlowEngine.define do
50
- start :earnings
24
+ gem "flowengine"
25
+ ```
51
26
 
52
- step :earnings do
53
- type :multi_select
54
- question "What are your main earnings?"
55
- options %w[W2 1099 BusinessOwnership]
27
+ Or install directly:
28
+
29
+ ```bash
30
+ gem install flowengine
31
+ ```
32
+
33
+ ## Quick Start
34
+
35
+ ```ruby
36
+ require "flowengine"
56
37
 
57
- transition to: :business_details,
58
- if: contains(:earnings, "BusinessOwnership")
38
+ # 1. Define a flow
39
+ definition = FlowEngine.define do
40
+ start :name
41
+
42
+ step :name do
43
+ type :text
44
+ question "What is your name?"
45
+ transition to: :age
46
+ end
47
+
48
+ step :age do
49
+ type :number
50
+ question "How old are you?"
51
+ transition to: :beverage, if_rule: greater_than(:age, 20)
52
+ transition to: :thanks
53
+ end
54
+
55
+ step :beverage do
56
+ type :single_select
57
+ question "Pick a drink."
58
+ options %w[Beer Wine Cocktail]
59
+ transition to: :thanks
60
+ end
61
+
62
+ step :thanks do
63
+ type :text
64
+ question "Thank you for your responses!"
59
65
  end
60
66
  end
67
+
68
+ # 2. Run the engine
69
+ engine = FlowEngine::Engine.new(definition)
70
+
71
+ engine.answer("Alice") # :name -> :age
72
+ engine.answer(25) # :age -> :beverage (25 > 20)
73
+ engine.answer("Wine") # :beverage -> :thanks
74
+ engine.answer("ok") # :thanks -> finished
75
+
76
+ engine.finished? # => true
77
+ engine.answers
78
+ # => { name: "Alice", age: 25, beverage: "Wine", thanks: "ok" }
79
+ engine.history
80
+ # => [:name, :age, :beverage, :thanks]
81
+ ```
82
+
83
+ If Alice were 18 instead, the engine would skip `:beverage` entirely:
84
+
85
+ ```ruby
86
+ engine.answer("Alice") # :name -> :age
87
+ engine.answer(18) # :age -> :thanks (18 is NOT > 20)
88
+ engine.answer("ok") # :thanks -> finished
89
+
90
+ engine.answers
91
+ # => { name: "Alice", age: 18, thanks: "ok" }
92
+ engine.history
93
+ # => [:name, :age, :thanks]
94
+ ```
95
+
96
+ ### Using the `flowengine-cli` gem to Generate the JSON Answers File
97
+
98
+
99
+
100
+ ## Architecture
101
+
102
+ ![architecture](docs/floweingine-architecture.png)
103
+
104
+ The core has **zero UI logic**, **zero DB logic**, and **zero framework dependencies**. Adapters translate input/output, persist state, and render UI.
105
+
106
+ ### Core Components
107
+
108
+ | Component | Responsibility |
109
+ |-----------|---------------|
110
+ | `FlowEngine.define` | DSL entry point; returns a frozen `Definition` |
111
+ | `Definition` | Immutable container of the flow graph (nodes + start step) |
112
+ | `Node` | A single step: type, question, options/fields, transitions, visibility |
113
+ | `Transition` | A directed edge with an optional rule condition |
114
+ | `Rules::*` | AST nodes for conditional logic (`Contains`, `Equals`, `All`, etc.) |
115
+ | `Evaluator` | Evaluates rules against the current answer store |
116
+ | `Engine` | Stateful runtime: tracks current step, answers, and history |
117
+ | `Validation::Adapter` | Interface for pluggable validation (dry-validation, JSON Schema, etc.) |
118
+ | `Graph::MermaidExporter` | Exports the flow definition as a Mermaid diagram |
119
+
120
+ ## The DSL
121
+
122
+ ### Defining a Flow
123
+
124
+ Every flow starts with `FlowEngine.define`, which returns a **frozen, immutable** `Definition`:
125
+
126
+ ```ruby
127
+ definition = FlowEngine.define do
128
+ start :first_step # Required: which node to begin at
129
+
130
+ step :first_step do
131
+ # step configuration...
132
+ end
133
+
134
+ step :second_step do
135
+ # step configuration...
136
+ end
137
+ end
138
+ ```
139
+
140
+ ### Step Configuration
141
+
142
+ Inside a `step` block, you have access to:
143
+
144
+ | Method | Purpose | Example |
145
+ |--------|---------|---------|
146
+ | `type` | The input type (for UI adapters) | `:text`, `:number`, `:single_select`, `:multi_select`, `:number_matrix` |
147
+ | `question` | The prompt shown to the user | `"What is your filing status?"` |
148
+ | `options` | Available choices (for select types) | `%w[W2 1099 Business]` |
149
+ | `fields` | Named fields (for matrix types) | `%w[RealEstate SCorp LLC]` |
150
+ | `transition` | Where to go next (with optional condition) | `transition to: :next_step, if_rule: equals(:field, "value")` |
151
+ | `visible_if` | Visibility rule (for DAG mode) | `visible_if contains(:income, "Rental")` |
152
+
153
+ ### Step Types
154
+
155
+ Step types are semantic labels consumed by UI adapters. The engine itself is type-agnostic — it stores whatever value you pass to `engine.answer(value)`. The types communicate intent to renderers:
156
+
157
+ ```ruby
158
+ step :filing_status do
159
+ type :single_select # One choice from a list
160
+ question "What is your filing status?"
161
+ options %w[single married_filing_jointly married_filing_separately head_of_household]
162
+ transition to: :dependents
163
+ end
164
+
165
+ step :income_types do
166
+ type :multi_select # Multiple choices from a list
167
+ question "Select all income types."
168
+ options %w[W2 1099 Business Investment Rental]
169
+ transition to: :business, if_rule: contains(:income_types, "Business")
170
+ transition to: :summary
171
+ end
172
+
173
+ step :dependents do
174
+ type :number # A numeric value
175
+ question "How many dependents?"
176
+ transition to: :income_types
177
+ end
178
+
179
+ step :business_details do
180
+ type :number_matrix # Multiple named numeric fields
181
+ question "How many of each business type?"
182
+ fields %w[RealEstate SCorp CCorp Trust LLC]
183
+ transition to: :summary
184
+ end
185
+
186
+ step :notes do
187
+ type :text # Free-form text
188
+ question "Any additional notes?"
189
+ transition to: :summary
190
+ end
61
191
  ```
62
192
 
63
- Definition compiles DSL → Node objects → AST transitions.
193
+ ### Transitions
64
194
 
65
- No runtime state.
195
+ Transitions define the edges of the flow graph. They are evaluated **in order** — the first matching transition wins:
66
196
 
67
- #### Engine (Pure Runtime)
197
+ ```ruby
198
+ step :income_types do
199
+ type :multi_select
200
+ question "Select income types."
201
+ options %w[W2 1099 Business Investment Rental]
202
+
203
+ # Conditional transitions (checked in order)
204
+ transition to: :business_count, if_rule: contains(:income_types, "Business")
205
+ transition to: :investment_details, if_rule: contains(:income_types, "Investment")
206
+ transition to: :rental_details, if_rule: contains(:income_types, "Rental")
207
+
208
+ # Unconditional fallback (always matches)
209
+ transition to: :state_filing
210
+ end
211
+ ```
212
+
213
+ **Key behavior:** Only the *first* matching transition fires. If the user selects `["Business", "Investment", "Rental"]`, the engine goes to `:business_count` first. The subsequent steps must themselves include transitions to eventually reach `:investment_details` and `:rental_details`.
214
+
215
+ A transition with no `if_rule:` always matches — use it as a fallback at the end of the list.
216
+
217
+ ### Visibility Rules
218
+
219
+ Nodes can have visibility conditions for DAG-mode rendering, where a UI adapter shows/hides steps dynamically:
68
220
 
69
221
  ```ruby
222
+ step :spouse_income do
223
+ type :number
224
+ question "What is your spouse's annual income?"
225
+ visible_if equals(:filing_status, "married_filing_jointly")
226
+ transition to: :deductions
227
+ end
228
+ ```
229
+
230
+ The engine exposes this via `node.visible?(answers)`, which returns `true` when the rule is satisfied (or when no visibility rule is set).
231
+
232
+ ## Rule System
233
+
234
+ Rules are **AST objects** — not hashes, not strings. They are immutable, composable, and evaluate polymorphically.
235
+
236
+ ### Atomic Rules
237
+
238
+ | Rule | DSL Helper | Evaluates | String Representation |
239
+ |------|------------|-----------|----------------------|
240
+ | `Contains` | `contains(:field, "val")` | `Array(answers[:field]).include?("val")` | `val in field` |
241
+ | `Equals` | `equals(:field, "val")` | `answers[:field] == "val"` | `field == val` |
242
+ | `GreaterThan` | `greater_than(:field, 10)` | `answers[:field].to_i > 10` | `field > 10` |
243
+ | `LessThan` | `less_than(:field, 5)` | `answers[:field].to_i < 5` | `field < 5` |
244
+ | `NotEmpty` | `not_empty(:field)` | `answers[:field]` is not nil and not empty | `field is not empty` |
245
+
246
+ ### Composite Rules
247
+
248
+ Combine atomic rules with boolean logic:
249
+
250
+ ```ruby
251
+ # AND — all conditions must be true
252
+ transition to: :special_review,
253
+ if_rule: all(
254
+ equals(:filing_status, "married_filing_jointly"),
255
+ contains(:income_types, "Business"),
256
+ greater_than(:business_count, 2)
257
+ )
258
+
259
+ # OR — at least one condition must be true
260
+ transition to: :alt_path,
261
+ if_rule: any(
262
+ contains(:income_types, "Investment"),
263
+ contains(:income_types, "Rental")
264
+ )
265
+ ```
266
+
267
+ Composites nest arbitrarily:
268
+
269
+ ```ruby
270
+ transition to: :complex_review,
271
+ if_rule: all(
272
+ equals(:filing_status, "married_filing_jointly"),
273
+ any(
274
+ greater_than(:business_count, 3),
275
+ contains(:income_types, "Rental")
276
+ ),
277
+ not_empty(:dependents)
278
+ )
279
+ ```
280
+
281
+ ### How Rules Evaluate
282
+
283
+ Every rule implements `evaluate(answers)` where `answers` is the engine's hash of `{ step_id => value }`:
284
+
285
+ ```ruby
286
+ rule = FlowEngine::Rules::Contains.new(:income_types, "Business")
287
+ rule.evaluate({ income_types: ["W2", "Business"] }) # => true
288
+ rule.evaluate({ income_types: ["W2"] }) # => false
289
+
290
+ rule = FlowEngine::Rules::All.new(
291
+ FlowEngine::Rules::Equals.new(:status, "married"),
292
+ FlowEngine::Rules::GreaterThan.new(:dependents, 0)
293
+ )
294
+ rule.evaluate({ status: "married", dependents: 2 }) # => true
295
+ rule.evaluate({ status: "single", dependents: 2 }) # => false
296
+ ```
297
+
298
+ ## Engine API
299
+
300
+ ### Creating and Running
301
+
302
+ ```ruby
303
+ definition = FlowEngine.define { ... }
70
304
  engine = FlowEngine::Engine.new(definition)
305
+ ```
71
306
 
72
- engine.current_step
73
- engine.answer(value)
74
- engine.finished?
75
- engine.answers
307
+ ### Methods
308
+
309
+ | Method | Returns | Description |
310
+ |--------|---------|-------------|
311
+ | `engine.current_step_id` | `Symbol` or `nil` | The ID of the current step |
312
+ | `engine.current_step` | `Node` or `nil` | The current Node object |
313
+ | `engine.answer(value)` | `nil` | Records the answer and advances |
314
+ | `engine.finished?` | `Boolean` | `true` when there are no more steps |
315
+ | `engine.answers` | `Hash` | All collected answers `{ step_id => value }` |
316
+ | `engine.history` | `Array<Symbol>` | Ordered list of visited step IDs |
317
+ | `engine.definition` | `Definition` | The immutable flow definition |
318
+
319
+ ### Error Handling
320
+
321
+ ```ruby
322
+ # Answering after the flow is complete
323
+ engine.answer("extra")
324
+ # => raises FlowEngine::AlreadyFinishedError
325
+
326
+ # Referencing an unknown step in a definition
327
+ definition.step(:nonexistent)
328
+ # => raises FlowEngine::UnknownStepError
329
+
330
+ # Invalid definition (start step doesn't exist)
331
+ FlowEngine.define do
332
+ start :missing
333
+ step :other do
334
+ type :text
335
+ question "Hello"
336
+ end
337
+ end
338
+ # => raises FlowEngine::DefinitionError
76
339
  ```
77
340
 
78
- Engine stores:
341
+ ## Validation
79
342
 
80
- * current node id
81
- * answer hash
82
- * evaluator
343
+ The engine accepts a pluggable validator via the adapter pattern. The core gem ships with a `NullAdapter` (always passes) and defines the interface for custom adapters:
83
344
 
84
- No IO.
345
+ ```ruby
346
+ # The adapter interface
347
+ class FlowEngine::Validation::Adapter
348
+ def validate(node, input)
349
+ # Must return a FlowEngine::Validation::Result
350
+ raise NotImplementedError
351
+ end
352
+ end
85
353
 
86
- #### Rule AST (Clean & Extensible)
354
+ # Result object
355
+ FlowEngine::Validation::Result.new(valid: true, errors: [])
356
+ FlowEngine::Validation::Result.new(valid: false, errors: ["must be a number"])
357
+ ```
87
358
 
88
- You want AST objects, not hash blobs.
359
+ ### Custom Validator Example
89
360
 
90
361
  ```ruby
91
- Contains.new(:earnings, "BusinessOwnership")
92
- All.new(rule1, rule2)
93
- Equals.new(:marital_status, "Married")
362
+ class MyValidator < FlowEngine::Validation::Adapter
363
+ def validate(node, input)
364
+ errors = []
365
+
366
+ case node.type
367
+ when :number
368
+ errors << "must be a number" unless input.is_a?(Numeric)
369
+ when :single_select
370
+ errors << "invalid option" unless node.options&.include?(input)
371
+ when :multi_select
372
+ unless input.is_a?(Array) && input.all? { |v| node.options&.include?(v) }
373
+ errors << "invalid options"
374
+ end
375
+ end
376
+
377
+ FlowEngine::Validation::Result.new(valid: errors.empty?, errors: errors)
378
+ end
379
+ end
380
+
381
+ engine = FlowEngine::Engine.new(definition, validator: MyValidator.new)
382
+ engine.answer("not_a_number") # => raises FlowEngine::ValidationError
94
383
  ```
95
384
 
96
- Evaluator does polymorphic dispatch:
385
+ ## Mermaid Diagram Export
386
+
387
+ Export any flow definition as a [Mermaid](https://mermaid.js.org/) flowchart:
97
388
 
98
389
  ```ruby
99
- rule.evaluate(context)
390
+ exporter = FlowEngine::Graph::MermaidExporter.new(definition)
391
+ puts exporter.export
100
392
  ```
101
393
 
102
- Cleaner than giant case statements.
394
+ Output:
103
395
 
104
- #### Validation (Dry Integration)
396
+ ```mermaid
397
+ flowchart TD
398
+ name["What is your name?"]
399
+ name --> age
400
+ age["How old are you?"]
401
+ age -->|"age > 20"| beverage
402
+ age --> thanks
403
+ beverage["Pick a drink."]
404
+ beverage --> thanks
405
+ thanks["Thank you for your responses!"]
406
+ ```
105
407
 
106
- Adapter pattern:
408
+ ## Complete Example: Tax Intake Flow
409
+
410
+ Here's a realistic 17-step tax intake wizard that demonstrates every feature of the DSL.
411
+
412
+ ### Flow Definition
107
413
 
108
414
  ```ruby
109
- class DryValidationAdapter < Adapter
110
- def validate(step, input)
111
- step.schema.call(input)
415
+ tax_intake = FlowEngine.define do
416
+ start :filing_status
417
+
418
+ step :filing_status do
419
+ type :single_select
420
+ question "What is your filing status for 2025?"
421
+ options %w[single married_filing_jointly married_filing_separately head_of_household]
422
+ transition to: :dependents
423
+ end
424
+
425
+ step :dependents do
426
+ type :number
427
+ question "How many dependents do you have?"
428
+ transition to: :income_types
429
+ end
430
+
431
+ step :income_types do
432
+ type :multi_select
433
+ question "Select all income types that apply to you in 2025."
434
+ options %w[W2 1099 Business Investment Rental Retirement]
435
+ transition to: :business_count, if_rule: contains(:income_types, "Business")
436
+ transition to: :investment_details, if_rule: contains(:income_types, "Investment")
437
+ transition to: :rental_details, if_rule: contains(:income_types, "Rental")
438
+ transition to: :state_filing
439
+ end
440
+
441
+ step :business_count do
442
+ type :number
443
+ question "How many total businesses do you own or are a partner in?"
444
+ transition to: :complex_business_info, if_rule: greater_than(:business_count, 2)
445
+ transition to: :business_details
446
+ end
447
+
448
+ step :complex_business_info do
449
+ type :text
450
+ question "With more than 2 businesses, please provide your primary EIN and a brief description of each entity."
451
+ transition to: :business_details
452
+ end
453
+
454
+ step :business_details do
455
+ type :number_matrix
456
+ question "How many of each business type do you own?"
457
+ fields %w[RealEstate SCorp CCorp Trust LLC]
458
+ transition to: :investment_details, if_rule: contains(:income_types, "Investment")
459
+ transition to: :rental_details, if_rule: contains(:income_types, "Rental")
460
+ transition to: :state_filing
461
+ end
462
+
463
+ step :investment_details do
464
+ type :multi_select
465
+ question "What types of investments do you hold?"
466
+ options %w[Stocks Bonds Crypto RealEstate MutualFunds]
467
+ transition to: :crypto_details, if_rule: contains(:investment_details, "Crypto")
468
+ transition to: :rental_details, if_rule: contains(:income_types, "Rental")
469
+ transition to: :state_filing
470
+ end
471
+
472
+ step :crypto_details do
473
+ type :text
474
+ question "Please describe your cryptocurrency transactions (exchanges used, approximate number of transactions)."
475
+ transition to: :rental_details, if_rule: contains(:income_types, "Rental")
476
+ transition to: :state_filing
477
+ end
478
+
479
+ step :rental_details do
480
+ type :number_matrix
481
+ question "Provide details about your rental properties."
482
+ fields %w[Residential Commercial Vacation]
483
+ transition to: :state_filing
484
+ end
485
+
486
+ step :state_filing do
487
+ type :multi_select
488
+ question "Which states do you need to file in?"
489
+ options %w[California NewYork Texas Florida Illinois Other]
490
+ transition to: :foreign_accounts
491
+ end
492
+
493
+ step :foreign_accounts do
494
+ type :single_select
495
+ question "Do you have any foreign financial accounts (bank accounts, securities, or financial assets)?"
496
+ options %w[yes no]
497
+ transition to: :foreign_account_details, if_rule: equals(:foreign_accounts, "yes")
498
+ transition to: :deduction_types
499
+ end
500
+
501
+ step :foreign_account_details do
502
+ type :number
503
+ question "How many foreign accounts do you have?"
504
+ transition to: :deduction_types
505
+ end
506
+
507
+ step :deduction_types do
508
+ type :multi_select
509
+ question "Which additional deductions apply to you?"
510
+ options %w[Medical Charitable Education Mortgage None]
511
+ transition to: :charitable_amount, if_rule: contains(:deduction_types, "Charitable")
512
+ transition to: :contact_info
513
+ end
514
+
515
+ step :charitable_amount do
516
+ type :number
517
+ question "What is your total estimated charitable contribution amount for 2025?"
518
+ transition to: :charitable_documentation, if_rule: greater_than(:charitable_amount, 5000)
519
+ transition to: :contact_info
520
+ end
521
+
522
+ step :charitable_documentation do
523
+ type :text
524
+ question "For charitable contributions over $5,000, please list the organizations and amounts."
525
+ transition to: :contact_info
526
+ end
527
+
528
+ step :contact_info do
529
+ type :text
530
+ question "Please provide your contact information (name, email, phone)."
531
+ transition to: :review
532
+ end
533
+
534
+ step :review do
535
+ type :text
536
+ question "Thank you! Please review your information. Type 'confirm' to submit."
112
537
  end
113
538
  end
114
539
  ```
115
540
 
116
- Core does:
541
+ ### Scenario 1: Maximum Path (17 steps visited)
542
+
543
+ A married filer with all income types, 4 businesses, crypto, rentals, foreign accounts, and high charitable giving:
117
544
 
118
545
  ```ruby
119
- validator.validate(step, input)
546
+ engine = FlowEngine::Engine.new(tax_intake)
547
+
548
+ engine.answer("married_filing_jointly")
549
+ engine.answer(3)
550
+ engine.answer(%w[W2 1099 Business Investment Rental Retirement])
551
+ engine.answer(4)
552
+ engine.answer("EIN: 12-3456789. Entities: Alpha LLC, Beta SCorp, Gamma LLC, Delta CCorp")
553
+ engine.answer({ "RealEstate" => 1, "SCorp" => 1, "CCorp" => 1, "Trust" => 0, "LLC" => 2 })
554
+ engine.answer(%w[Stocks Bonds Crypto RealEstate])
555
+ engine.answer("Coinbase and Kraken, approximately 150 transactions in 2025")
556
+ engine.answer({ "Residential" => 2, "Commercial" => 1, "Vacation" => 0 })
557
+ engine.answer(%w[California NewYork])
558
+ engine.answer("yes")
559
+ engine.answer(3)
560
+ engine.answer(%w[Medical Charitable Education Mortgage])
561
+ engine.answer(12_000)
562
+ engine.answer("Red Cross: $5,000; Habitat for Humanity: $4,000; Local Food Bank: $3,000")
563
+ engine.answer("Jane Smith, jane.smith@example.com, 555-123-4567")
564
+ engine.answer("confirm")
565
+ ```
566
+
567
+ **Collected data:**
568
+
569
+ ```json
570
+ {
571
+ "filing_status": "married_filing_jointly",
572
+ "dependents": 3,
573
+ "income_types": ["W2", "1099", "Business", "Investment", "Rental", "Retirement"],
574
+ "business_count": 4,
575
+ "complex_business_info": "EIN: 12-3456789. Entities: Alpha LLC, Beta SCorp, Gamma LLC, Delta CCorp",
576
+ "business_details": {
577
+ "RealEstate": 1,
578
+ "SCorp": 1,
579
+ "CCorp": 1,
580
+ "Trust": 0,
581
+ "LLC": 2
582
+ },
583
+ "investment_details": ["Stocks", "Bonds", "Crypto", "RealEstate"],
584
+ "crypto_details": "Coinbase and Kraken, approximately 150 transactions in 2025",
585
+ "rental_details": {
586
+ "Residential": 2,
587
+ "Commercial": 1,
588
+ "Vacation": 0
589
+ },
590
+ "state_filing": ["California", "NewYork"],
591
+ "foreign_accounts": "yes",
592
+ "foreign_account_details": 3,
593
+ "deduction_types": ["Medical", "Charitable", "Education", "Mortgage"],
594
+ "charitable_amount": 12000,
595
+ "charitable_documentation": "Red Cross: $5,000; Habitat for Humanity: $4,000; Local Food Bank: $3,000",
596
+ "contact_info": "Jane Smith, jane.smith@example.com, 555-123-4567",
597
+ "review": "confirm"
598
+ }
599
+ ```
600
+
601
+ **Path taken** (all 17 steps):
602
+
603
+ ```
604
+ filing_status -> dependents -> income_types -> business_count ->
605
+ complex_business_info -> business_details -> investment_details ->
606
+ crypto_details -> rental_details -> state_filing -> foreign_accounts ->
607
+ foreign_account_details -> deduction_types -> charitable_amount ->
608
+ charitable_documentation -> contact_info -> review
120
609
  ```
121
610
 
122
- IMPORTANT: Core does not depend directly on dry-validation.
611
+ ### Scenario 2: Minimum Path (8 steps visited)
123
612
 
124
- ### Examples of Mermaid Charts
613
+ A single filer, W2 income only, no special deductions:
614
+
615
+ ```ruby
616
+ engine = FlowEngine::Engine.new(tax_intake)
617
+
618
+ engine.answer("single")
619
+ engine.answer(0)
620
+ engine.answer(["W2"])
621
+ engine.answer(["Texas"])
622
+ engine.answer("no")
623
+ engine.answer(["None"])
624
+ engine.answer("John Doe, john.doe@example.com, 555-987-6543")
625
+ engine.answer("confirm")
626
+ ```
627
+
628
+ **Collected data:**
629
+
630
+ ```json
631
+ {
632
+ "filing_status": "single",
633
+ "dependents": 0,
634
+ "income_types": ["W2"],
635
+ "state_filing": ["Texas"],
636
+ "foreign_accounts": "no",
637
+ "deduction_types": ["None"],
638
+ "contact_info": "John Doe, john.doe@example.com, 555-987-6543",
639
+ "review": "confirm"
640
+ }
641
+ ```
642
+
643
+ **Path taken** (8 steps — skipped 9 steps):
644
+
645
+ ```
646
+ filing_status -> dependents -> income_types -> state_filing ->
647
+ foreign_accounts -> deduction_types -> contact_info -> review
648
+ ```
649
+
650
+ **Skipped:** `business_count`, `complex_business_info`, `business_details`, `investment_details`, `crypto_details`, `rental_details`, `foreign_account_details`, `charitable_amount`, `charitable_documentation`.
651
+
652
+ ### Scenario 3: Medium Path (12 steps visited)
653
+
654
+ Married, with business + investment income, low charitable giving:
655
+
656
+ ```ruby
657
+ engine = FlowEngine::Engine.new(tax_intake)
658
+
659
+ engine.answer("married_filing_separately")
660
+ engine.answer(1)
661
+ engine.answer(%w[W2 Business Investment])
662
+ engine.answer(2) # <= 2 businesses, no complex_business_info
663
+ engine.answer({ "RealEstate" => 0, "SCorp" => 1, "CCorp" => 0, "Trust" => 0, "LLC" => 1 })
664
+ engine.answer(%w[Stocks Bonds MutualFunds]) # no Crypto, no crypto_details
665
+ engine.answer(%w[California Illinois])
666
+ engine.answer("no") # no foreign accounts
667
+ engine.answer(%w[Charitable Mortgage])
668
+ engine.answer(3000) # <= 5000, no documentation needed
669
+ engine.answer("Alice Johnson, alice.j@example.com, 555-555-0100")
670
+ engine.answer("confirm")
671
+ ```
672
+
673
+ **Collected data:**
674
+
675
+ ```json
676
+ {
677
+ "filing_status": "married_filing_separately",
678
+ "dependents": 1,
679
+ "income_types": ["W2", "Business", "Investment"],
680
+ "business_count": 2,
681
+ "business_details": {
682
+ "RealEstate": 0,
683
+ "SCorp": 1,
684
+ "CCorp": 0,
685
+ "Trust": 0,
686
+ "LLC": 1
687
+ },
688
+ "investment_details": ["Stocks", "Bonds", "MutualFunds"],
689
+ "state_filing": ["California", "Illinois"],
690
+ "foreign_accounts": "no",
691
+ "deduction_types": ["Charitable", "Mortgage"],
692
+ "charitable_amount": 3000,
693
+ "contact_info": "Alice Johnson, alice.j@example.com, 555-555-0100",
694
+ "review": "confirm"
695
+ }
696
+ ```
697
+
698
+ **Path taken** (12 steps):
699
+
700
+ ```
701
+ filing_status -> dependents -> income_types -> business_count ->
702
+ business_details -> investment_details -> state_filing ->
703
+ foreign_accounts -> deduction_types -> charitable_amount ->
704
+ contact_info -> review
705
+ ```
706
+
707
+ ### Scenario 4: Rental + Foreign Accounts (10 steps visited)
708
+
709
+ Head of household, 1099 + rental income, foreign accounts, no charitable:
710
+
711
+ ```ruby
712
+ engine = FlowEngine::Engine.new(tax_intake)
713
+
714
+ engine.answer("head_of_household")
715
+ engine.answer(2)
716
+ engine.answer(%w[1099 Rental])
717
+ engine.answer({ "Residential" => 1, "Commercial" => 0, "Vacation" => 1 })
718
+ engine.answer(["Florida"])
719
+ engine.answer("yes")
720
+ engine.answer(1)
721
+ engine.answer(%w[Medical Education])
722
+ engine.answer("Bob Lee, bob@example.com, 555-000-1111")
723
+ engine.answer("confirm")
724
+ ```
725
+
726
+ **Collected data:**
727
+
728
+ ```json
729
+ {
730
+ "filing_status": "head_of_household",
731
+ "dependents": 2,
732
+ "income_types": ["1099", "Rental"],
733
+ "rental_details": {
734
+ "Residential": 1,
735
+ "Commercial": 0,
736
+ "Vacation": 1
737
+ },
738
+ "state_filing": ["Florida"],
739
+ "foreign_accounts": "yes",
740
+ "foreign_account_details": 1,
741
+ "deduction_types": ["Medical", "Education"],
742
+ "contact_info": "Bob Lee, bob@example.com, 555-000-1111",
743
+ "review": "confirm"
744
+ }
745
+ ```
746
+
747
+ **Path taken** (10 steps):
748
+
749
+ ```
750
+ filing_status -> dependents -> income_types -> rental_details ->
751
+ state_filing -> foreign_accounts -> foreign_account_details ->
752
+ deduction_types -> contact_info -> review
753
+ ```
754
+
755
+ ### Comparing Collected Data Across Paths
756
+
757
+ The shape of the collected data depends entirely on which path the user takes through the graph. Here's a side-by-side of which keys appear in each scenario:
758
+
759
+ | Answer Key | Max (17) | Min (8) | Medium (12) | Rental (10) |
760
+ |---|:---:|:---:|:---:|:---:|
761
+ | `filing_status` | x | x | x | x |
762
+ | `dependents` | x | x | x | x |
763
+ | `income_types` | x | x | x | x |
764
+ | `business_count` | x | | x | |
765
+ | `complex_business_info` | x | | | |
766
+ | `business_details` | x | | x | |
767
+ | `investment_details` | x | | x | |
768
+ | `crypto_details` | x | | | |
769
+ | `rental_details` | x | | | x |
770
+ | `state_filing` | x | x | x | x |
771
+ | `foreign_accounts` | x | x | x | x |
772
+ | `foreign_account_details` | x | | | x |
773
+ | `deduction_types` | x | x | x | x |
774
+ | `charitable_amount` | x | | x | |
775
+ | `charitable_documentation` | x | | | |
776
+ | `contact_info` | x | x | x | x |
777
+ | `review` | x | x | x | x |
778
+
779
+ ## Composing Complex Rules
780
+
781
+ ### Example: Multi-Condition Branching
782
+
783
+ ```ruby
784
+ definition = FlowEngine.define do
785
+ start :status
786
+
787
+ step :status do
788
+ type :single_select
789
+ question "Filing status?"
790
+ options %w[single married]
791
+ transition to: :dependents
792
+ end
793
+
794
+ step :dependents do
795
+ type :number
796
+ question "How many dependents?"
797
+ transition to: :income
798
+ end
799
+
800
+ step :income do
801
+ type :multi_select
802
+ question "Income types?"
803
+ options %w[W2 Business]
804
+
805
+ # All three conditions must be true
806
+ transition to: :special_review,
807
+ if_rule: all(
808
+ equals(:status, "married"),
809
+ contains(:income, "Business"),
810
+ not_empty(:income)
811
+ )
812
+
813
+ # At least one condition must be true
814
+ transition to: :alt_review,
815
+ if_rule: any(
816
+ less_than(:dependents, 2),
817
+ contains(:income, "W2")
818
+ )
819
+
820
+ # Unconditional fallback
821
+ transition to: :default_review
822
+ end
823
+
824
+ step :special_review do
825
+ type :text
826
+ question "Married with business income - special review required."
827
+ end
828
+
829
+ step :alt_review do
830
+ type :text
831
+ question "Alternative review path."
832
+ end
833
+
834
+ step :default_review do
835
+ type :text
836
+ question "Default review."
837
+ end
838
+ end
839
+ ```
840
+
841
+ The four possible outcomes:
842
+
843
+ | Inputs | Path | Why |
844
+ |--------|------|-----|
845
+ | married, 0 deps, `[W2, Business]` | `:special_review` | `all()` satisfied: married + Business + not empty |
846
+ | single, 0 deps, `[W2]` | `:alt_review` | `all()` fails (not married); `any()` passes (has W2) |
847
+ | single, 1 dep, `[Business]` | `:alt_review` | `all()` fails; `any()` passes (deps < 2) |
848
+ | single, 3 deps, `[Business]` | `:default_review` | `all()` fails; `any()` fails (deps not < 2, no W2) |
849
+
850
+ ## Mermaid Diagram of the Tax Intake Flow
125
851
 
126
852
  ![Example](docs/flowengine-example.png)
127
853
 
128
854
  <details>
129
- <summary>Expand to See Mermaid Sources</summary>
855
+ <summary>Expand to see Mermaid source</summary>
130
856
 
131
857
  ```mermaid
132
858
  flowchart BT
@@ -163,3 +889,24 @@ flowchart BT
163
889
  ```
164
890
 
165
891
  </details>
892
+
893
+ ## Ecosystem
894
+
895
+ FlowEngine is the core of a three-gem architecture:
896
+
897
+ | Gem | Purpose |
898
+ |-----|---------|
899
+ | **`flowengine`** (this gem) | Core engine — pure Ruby, no Rails, no DB, no UI |
900
+ | **`flowengine-cli`** | Terminal wizard adapter using [TTY Toolkit](https://ttytoolkit.org/) + Dry::CLI |
901
+ | **`flowengine-rails`** | Rails Engine with ActiveRecord persistence and web views |
902
+
903
+ ## Development
904
+
905
+ ```bash
906
+ bundle install
907
+ bundle exec rspec
908
+ ```
909
+
910
+ ## License
911
+
912
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).