sangi 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,184 @@
1
+ module Sangi
2
+ class AdditionEngine
3
+ def initialize(config)
4
+ @config = config
5
+ end
6
+
7
+ def build(abs_a:, abs_b:, result_sign:, display_a:, display_b:, expression:)
8
+ @rod_factory = RodFactory.new
9
+ @result_value = result_sign * (abs_a + abs_b)
10
+ place_count = [
11
+ digit_count(abs_a),
12
+ digit_count(abs_b),
13
+ digit_count(@result_value.abs),
14
+ 1
15
+ ].max
16
+ @board = Board.from_numbers(
17
+ a: display_a,
18
+ b: display_b,
19
+ work: 0,
20
+ place_count: place_count,
21
+ rod_factory: @rod_factory
22
+ )
23
+ @board.row(:work).sign = result_sign
24
+ @builder = StepBuilder.new(
25
+ config: @config,
26
+ expression: expression,
27
+ board: @board,
28
+ result_value: @result_value,
29
+ rod_factory: @rod_factory
30
+ )
31
+
32
+ push_setup_step
33
+ max_operand_places(abs_a, abs_b).times do |place|
34
+ add_digit_to_work(source: :a, place: place, digit: digit_of(abs_a, place))
35
+ add_digit_to_work(source: :b, place: place, digit: digit_of(abs_b, place))
36
+ end
37
+ normalize_all_places
38
+ push_finish_step
39
+ @builder.steps
40
+ end
41
+
42
+ private
43
+
44
+ def push_setup_step
45
+ @builder.push(
46
+ title: "準備",
47
+ brief_message: "A行とB行を表示し、Work行を空にします。",
48
+ learn_message: "A行とB行は参照用に残し、棒をWork行へ1本ずつ置いて合計を作ります。",
49
+ event: Event.new(type: :setup)
50
+ )
51
+ end
52
+
53
+ def push_finish_step
54
+ @board.row(:work).normalize_zero_sign!
55
+ @builder.push(
56
+ title: "完了",
57
+ brief_message: "計算結果は#{@result_value}です。",
58
+ learn_message: "Work行の棒を正規化したので、各位が読みやすい算木風表示になりました。結果は#{@result_value}です。",
59
+ event: Event.new(type: :finish)
60
+ )
61
+ end
62
+
63
+ def add_digit_to_work(source:, place:, digit:)
64
+ return if digit.zero?
65
+
66
+ source_label = source == :a ? "A" : "B"
67
+ (digit / 5).times do
68
+ @builder.place_rod(
69
+ row: :work,
70
+ place: place,
71
+ kind: :five,
72
+ title: "#{source_label}を加える",
73
+ brief_message: "#{source_label}行を参照し、Workの#{@builder.place_name(place)}にfive棒を1本置きます。",
74
+ learn_message: "#{source_label}行の#{@builder.place_name(place)}にある5を表す棒を、Work行へ1本追加します。"
75
+ )
76
+ normalize_from_place(place)
77
+ end
78
+
79
+ (digit % 5).times do
80
+ @builder.place_rod(
81
+ row: :work,
82
+ place: place,
83
+ kind: :unit,
84
+ title: "#{source_label}を加える",
85
+ brief_message: "#{source_label}行を参照し、Workの#{@builder.place_name(place)}にunit棒を1本置きます。",
86
+ learn_message: "#{source_label}行の#{@builder.place_name(place)}にある1を表す棒を、Work行へ1本追加します。"
87
+ )
88
+ normalize_from_place(place)
89
+ end
90
+ end
91
+
92
+ def normalize_all_places
93
+ @board.place_count.times { |place| normalize_from_place(place) }
94
+ end
95
+
96
+ def normalize_from_place(start_place)
97
+ current = start_place
98
+ while current < @board.place_count
99
+ changed = false
100
+ cell = work_cell(current)
101
+
102
+ while cell.unit_rods.size >= 5
103
+ normalize_units_to_five(current)
104
+ changed = true
105
+ end
106
+
107
+ while cell.five_rods.size >= 2
108
+ carry_five_pair(current)
109
+ changed = true
110
+ end
111
+
112
+ break unless changed
113
+
114
+ current += 1
115
+ end
116
+ end
117
+
118
+ def normalize_units_to_five(place)
119
+ 5.times do
120
+ @builder.remove_rod(
121
+ row: :work,
122
+ place: place,
123
+ kind: :unit,
124
+ title: "5本をまとめる",
125
+ brief_message: "Workの#{@builder.place_name(place)}でunit棒5本をfive棒1本にまとめるため、unit棒を1本取り除きます。",
126
+ learn_message: "同じ位の1の棒が5本になったので、5を表す棒へ変換します。",
127
+ event_type: :convert_unit_to_five
128
+ )
129
+ end
130
+ @builder.place_rod(
131
+ row: :work,
132
+ place: place,
133
+ kind: :five,
134
+ title: "5本をまとめる",
135
+ brief_message: "Workの#{@builder.place_name(place)}にfive棒を1本置きます。",
136
+ learn_message: "unit棒5本を取り除いた代わりに、同じ位へ5を表す棒を1本置きます。",
137
+ event_type: :convert_unit_to_five
138
+ )
139
+ end
140
+
141
+ def carry_five_pair(place)
142
+ 2.times do
143
+ @builder.remove_rod(
144
+ row: :work,
145
+ place: place,
146
+ kind: :five,
147
+ title: "繰り上げ",
148
+ brief_message: "Workの#{@builder.place_name(place)}でfive棒2本を次の位へ繰り上げるため、five棒を1本取り除きます。",
149
+ learn_message: "同じ位の5の棒が2本で10になるため、次の位の1本へ置き換えます。",
150
+ event_type: :carry_to_next_place
151
+ )
152
+ end
153
+ @builder.place_rod(
154
+ row: :work,
155
+ place: place + 1,
156
+ kind: :unit,
157
+ title: "繰り上げ",
158
+ brief_message: "Workの#{@builder.place_name(place + 1)}にunit棒を1本置きます。",
159
+ learn_message: "下の位の10は、1つ上の位では1として表します。",
160
+ event_type: :carry_to_next_place
161
+ )
162
+ end
163
+
164
+ def work_cell(place)
165
+ @board.row(:work).cells.fetch(place)
166
+ end
167
+
168
+ def digit_of(value, place)
169
+ digits_of(value).fetch(place, 0)
170
+ end
171
+
172
+ def digits_of(value)
173
+ SignedNumber.from_integer(value).digits
174
+ end
175
+
176
+ def digit_count(value)
177
+ value.to_s.length
178
+ end
179
+
180
+ def max_operand_places(abs_a, abs_b)
181
+ [digit_count(abs_a), digit_count(abs_b)].max
182
+ end
183
+ end
184
+ end
@@ -0,0 +1,85 @@
1
+ module Sangi
2
+ class CopyEngine
3
+ def initialize(config)
4
+ @config = config
5
+ end
6
+
7
+ def build(display_a:, display_b:, result:, expression:)
8
+ rod_factory = RodFactory.new
9
+ place_count = [
10
+ digit_count(display_a.abs),
11
+ digit_count(display_b.abs),
12
+ digit_count(result.abs),
13
+ 1
14
+ ].max
15
+ board = Board.from_numbers(
16
+ a: display_a,
17
+ b: display_b,
18
+ work: 0,
19
+ place_count: place_count,
20
+ rod_factory: rod_factory
21
+ )
22
+ board.row(:work).sign = result <=> 0
23
+ builder = StepBuilder.new(
24
+ config: @config,
25
+ expression: expression,
26
+ board: board,
27
+ result_value: result,
28
+ rod_factory: rod_factory
29
+ )
30
+
31
+ builder.push(
32
+ title: "準備",
33
+ brief_message: "A行とB行を表示し、Work行を空にします。",
34
+ learn_message: "0を含む計算なので、必要な棒だけをWork行へ写して結果を確認します。",
35
+ event: Event.new(type: :setup)
36
+ )
37
+ copy_result_to_work(builder, result.abs)
38
+ board.row(:work).normalize_zero_sign!
39
+ builder.push(
40
+ title: "完了",
41
+ brief_message: "計算結果は#{result}です。",
42
+ learn_message: "Work行の棒が表す値が計算結果です。結果は#{result}です。",
43
+ event: Event.new(type: :finish)
44
+ )
45
+ builder.steps
46
+ end
47
+
48
+ private
49
+
50
+ def copy_result_to_work(builder, value)
51
+ digits_of(value).each_with_index do |digit, place|
52
+ add_digit_rods(builder, place, digit)
53
+ end
54
+ end
55
+
56
+ def add_digit_rods(builder, place, digit)
57
+ (digit / 5).times do
58
+ builder.place_rod(
59
+ row: :work,
60
+ place: place,
61
+ kind: :five,
62
+ title: "結果を写す",
63
+ brief_message: "結果を表すため、Workの#{builder.place_name(place)}にfive棒を1本置きます。"
64
+ )
65
+ end
66
+ (digit % 5).times do
67
+ builder.place_rod(
68
+ row: :work,
69
+ place: place,
70
+ kind: :unit,
71
+ title: "結果を写す",
72
+ brief_message: "結果を表すため、Workの#{builder.place_name(place)}にunit棒を1本置きます。"
73
+ )
74
+ end
75
+ end
76
+
77
+ def digits_of(value)
78
+ SignedNumber.from_integer(value).digits
79
+ end
80
+
81
+ def digit_count(value)
82
+ value.to_s.length
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,124 @@
1
+ module Sangi
2
+ module SubtractionBorrowing
3
+ private
4
+
5
+ def ensure_unit_available(place)
6
+ cell = work_cell(place)
7
+ return if cell.unit_rods.any?
8
+
9
+ if cell.five_rods.any?
10
+ split_five_to_units(place)
11
+ else
12
+ borrow_to_place(place)
13
+ ensure_unit_available(place)
14
+ end
15
+ end
16
+
17
+ def ensure_five_available(place)
18
+ cell = work_cell(place)
19
+ return if cell.five_rods.any?
20
+
21
+ if cell.unit_rods.size >= 5
22
+ normalize_units_to_five(place)
23
+ return
24
+ end
25
+
26
+ borrow_to_place(place)
27
+ normalize_units_to_five(place) if work_cell(place).unit_rods.size >= 5
28
+ return if work_cell(place).five_rods.any?
29
+
30
+ raise InternalError, "#{@builder.place_name(place)}でfive棒を用意できません。"
31
+ end
32
+
33
+ def split_five_to_units(place)
34
+ @builder.remove_rod(
35
+ row: :work,
36
+ place: place,
37
+ kind: :five,
38
+ title: "fiveを分解",
39
+ brief_message: "Workの#{@builder.place_name(place)}でfive棒をunit棒5本へ分解するため、five棒を1本取り除きます。",
40
+ learn_message: "5を表す棒を、1を表す棒5本へ戻します。",
41
+ event_type: :convert_five_to_units
42
+ )
43
+ 5.times do
44
+ @builder.place_rod(
45
+ row: :work,
46
+ place: place,
47
+ kind: :unit,
48
+ title: "fiveを分解",
49
+ brief_message: "Workの#{@builder.place_name(place)}にunit棒を1本置きます。",
50
+ learn_message: "5を表す棒の代わりに、1を表す棒を1本ずつ置きます。",
51
+ event_type: :convert_five_to_units
52
+ )
53
+ end
54
+ end
55
+
56
+ def normalize_units_to_five(place)
57
+ 5.times do
58
+ @builder.remove_rod(
59
+ row: :work,
60
+ place: place,
61
+ kind: :unit,
62
+ title: "5本をまとめる",
63
+ brief_message: "Workの#{@builder.place_name(place)}でunit棒5本をfive棒1本にまとめるため、unit棒を1本取り除きます。",
64
+ event_type: :convert_unit_to_five
65
+ )
66
+ end
67
+ @builder.place_rod(
68
+ row: :work,
69
+ place: place,
70
+ kind: :five,
71
+ title: "5本をまとめる",
72
+ brief_message: "Workの#{@builder.place_name(place)}にfive棒を1本置きます。",
73
+ event_type: :convert_unit_to_five
74
+ )
75
+ end
76
+
77
+ def borrow_to_place(target_place)
78
+ source_place = find_upper_nonzero_place(target_place)
79
+ raise InternalError, "繰り下げ元の位が見つかりません。" if source_place.nil?
80
+
81
+ source_place.downto(target_place + 1) do |place|
82
+ decrement_one_at_place(place)
83
+ add_ten_to_lower_place(place - 1)
84
+ end
85
+ end
86
+
87
+ def find_upper_nonzero_place(target_place)
88
+ (target_place + 1...@board.place_count).find do |place|
89
+ work_cell(place).value.positive?
90
+ end
91
+ end
92
+
93
+ def decrement_one_at_place(place)
94
+ ensure_unit_available(place)
95
+ @builder.remove_rod(
96
+ row: :work,
97
+ place: place,
98
+ kind: :unit,
99
+ title: "繰り下げ",
100
+ brief_message: "Workの#{@builder.place_name(place)}から1を借りるため、unit棒を1本取り除きます。",
101
+ learn_message: "上の位から1を借りると、1つ下の位では10として扱えます。",
102
+ event_type: :borrow_from_upper_place
103
+ )
104
+ end
105
+
106
+ def add_ten_to_lower_place(lower_place)
107
+ 2.times do
108
+ @builder.place_rod(
109
+ row: :work,
110
+ place: lower_place,
111
+ kind: :five,
112
+ title: "繰り下げ",
113
+ brief_message: "Workの#{@builder.place_name(lower_place)}に10を作るため、five棒を1本置きます。",
114
+ learn_message: "上の位から借りた1は、下の位では10です。five棒2本で10を表します。",
115
+ event_type: :borrow_from_upper_place
116
+ )
117
+ end
118
+ end
119
+
120
+ def work_cell(place)
121
+ @board.row(:work).cells.fetch(place)
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,134 @@
1
+ module Sangi
2
+ class SubtractionEngine
3
+ include SubtractionBorrowing
4
+
5
+ def initialize(config)
6
+ @config = config
7
+ end
8
+
9
+ def build(minuend_abs:, subtrahend_abs:, result_sign:, expression:)
10
+ @rod_factory = RodFactory.new
11
+ @result_value = result_sign * (minuend_abs - subtrahend_abs)
12
+ place_count = [
13
+ digit_count(minuend_abs),
14
+ digit_count(subtrahend_abs),
15
+ digit_count(@result_value.abs),
16
+ 1
17
+ ].max
18
+ @board = Board.from_numbers(
19
+ a: minuend_abs,
20
+ b: subtrahend_abs,
21
+ work: 0,
22
+ place_count: place_count,
23
+ rod_factory: @rod_factory
24
+ )
25
+ @board.row(:work).sign = result_sign
26
+ @builder = StepBuilder.new(
27
+ config: @config,
28
+ expression: expression,
29
+ board: @board,
30
+ result_value: @result_value,
31
+ rod_factory: @rod_factory
32
+ )
33
+
34
+ push_setup_step
35
+ copy_minuend_to_work(minuend_abs)
36
+ digit_count(subtrahend_abs).times do |place|
37
+ subtract_digit_from_work(place, digit_of(subtrahend_abs, place))
38
+ end
39
+ push_finish_step
40
+ @builder.steps
41
+ end
42
+
43
+ private
44
+
45
+ def push_setup_step
46
+ @builder.push(
47
+ title: "準備",
48
+ brief_message: "絶対値の大きい数をA行、小さい数をB行に置き、Work行で減算します。",
49
+ learn_message: "異符号の加算や結果が負になる減算は、絶対値の大きい方から小さい方を引き、最後に符号を付けます。",
50
+ event: Event.new(type: :setup)
51
+ )
52
+ end
53
+
54
+ def push_finish_step
55
+ @board.row(:work).normalize_zero_sign!
56
+ @builder.push(
57
+ title: "完了",
58
+ brief_message: "計算結果は#{@result_value}です。",
59
+ learn_message: "Work行に残った棒が差を表します。結果の符号を反映すると#{@result_value}です。",
60
+ event: Event.new(type: :finish)
61
+ )
62
+ end
63
+
64
+ def copy_minuend_to_work(value)
65
+ digits_of(value).each_with_index do |digit, place|
66
+ add_digit_rods_to_work(place, digit)
67
+ end
68
+ end
69
+
70
+ def add_digit_rods_to_work(place, digit)
71
+ (digit / 5).times do
72
+ @builder.place_rod(
73
+ row: :work,
74
+ place: place,
75
+ kind: :five,
76
+ title: "被減数を写す",
77
+ brief_message: "被減数を表すため、Workの#{@builder.place_name(place)}にfive棒を1本置きます。"
78
+ )
79
+ end
80
+ (digit % 5).times do
81
+ @builder.place_rod(
82
+ row: :work,
83
+ place: place,
84
+ kind: :unit,
85
+ title: "被減数を写す",
86
+ brief_message: "被減数を表すため、Workの#{@builder.place_name(place)}にunit棒を1本置きます。"
87
+ )
88
+ end
89
+ end
90
+
91
+ def subtract_digit_from_work(place, digit)
92
+ return if digit.zero?
93
+
94
+ unit_count = digit % 5
95
+ five_count = digit / 5
96
+
97
+ unit_count.times do
98
+ ensure_unit_available(place)
99
+ @builder.remove_rod(
100
+ row: :work,
101
+ place: place,
102
+ kind: :unit,
103
+ title: "減算",
104
+ brief_message: "Workの#{@builder.place_name(place)}からunit棒を1本取り除きます。",
105
+ learn_message: "減数の1に対応する棒を、Work行から1本取り除きます。"
106
+ )
107
+ end
108
+
109
+ five_count.times do
110
+ ensure_five_available(place)
111
+ @builder.remove_rod(
112
+ row: :work,
113
+ place: place,
114
+ kind: :five,
115
+ title: "減算",
116
+ brief_message: "Workの#{@builder.place_name(place)}からfive棒を1本取り除きます。",
117
+ learn_message: "減数の5に対応する棒を、Work行から1本取り除きます。"
118
+ )
119
+ end
120
+ end
121
+
122
+ def digit_of(value, place)
123
+ digits_of(value).fetch(place, 0)
124
+ end
125
+
126
+ def digits_of(value)
127
+ SignedNumber.from_integer(value).digits
128
+ end
129
+
130
+ def digit_count(value)
131
+ value.to_s.length
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,8 @@
1
+ module Sangi
2
+ class Error < StandardError; end
3
+ class ParseError < Error; end
4
+ class ValidationError < Error; end
5
+ class StepLimitError < Error; end
6
+ class ExportError < Error; end
7
+ class InternalError < Error; end
8
+ end
@@ -0,0 +1,14 @@
1
+ module Sangi
2
+ class Event
3
+ attr_reader :type, :row, :place_index, :kind, :from, :to
4
+
5
+ def initialize(type:, row: nil, place_index: nil, kind: nil, from: nil, to: nil)
6
+ @type = type
7
+ @row = row
8
+ @place_index = place_index
9
+ @kind = kind
10
+ @from = from
11
+ @to = to
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,41 @@
1
+ module Sangi
2
+ module Exporter
3
+ class TextExporter
4
+ SEPARATOR = "=" * 60
5
+
6
+ def initialize(config, renderer)
7
+ @config = config
8
+ @renderer = renderer
9
+ end
10
+
11
+ def export(path, steps)
12
+ File.write(path, build_text(steps), mode: "w:UTF-8")
13
+ rescue SystemCallError
14
+ raise ExportError, "ログファイルを書き込めません: #{path}"
15
+ end
16
+
17
+ def build_text(steps)
18
+ first = steps.first
19
+ state = first&.numeric_state || {}
20
+ lines = [
21
+ "sangi calculation log",
22
+ "version: #{VERSION}",
23
+ "expr: #{state[:source]}",
24
+ "normalized: #{state[:normalized]}",
25
+ "mode: #{@config.mode}",
26
+ "zero: #{@config.zero_mode}",
27
+ "sign: #{@config.sign_mode}",
28
+ "explain: #{@config.explain_mode}",
29
+ ""
30
+ ]
31
+ steps.each do |step|
32
+ lines << SEPARATOR
33
+ lines << "step #{step.index}/#{steps.size}"
34
+ lines << @renderer.render(step, total_steps: steps.size).chomp
35
+ end
36
+ lines << SEPARATOR unless steps.empty?
37
+ lines.join("\n") + "\n"
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,20 @@
1
+ module Sangi
2
+ class Expression
3
+ attr_reader :left, :operator, :right, :source
4
+
5
+ def initialize(left:, operator:, right:, source:)
6
+ @left = left
7
+ @operator = operator
8
+ @right = right
9
+ @source = source
10
+ end
11
+
12
+ def effective_right
13
+ operator == :- ? -right : right
14
+ end
15
+
16
+ def normalized_source
17
+ "#{left} + #{effective_right}"
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,49 @@
1
+ module Sangi
2
+ class Parser
3
+ PATTERN = /\A\s*([+-]?\d+)\s*([+-])\s*([+-]?\d+)\s*\z/
4
+
5
+ def initialize(config)
6
+ @config = config
7
+ end
8
+
9
+ def parse(source)
10
+ match = PATTERN.match(source.to_s)
11
+ raise ParseError, parse_error_message unless match
12
+
13
+ operator_text = match[2]
14
+ right_text = match[3]
15
+ raise ParseError, parse_error_message if operator_text == "+" && right_text.start_with?("+")
16
+
17
+ left = normalize_zero(match[1].to_i)
18
+ operator = operator_text.to_sym
19
+ right = normalize_zero(right_text.to_i)
20
+
21
+ validate_digits!(left)
22
+ validate_digits!(right)
23
+
24
+ Expression.new(
25
+ left: left,
26
+ operator: operator,
27
+ right: right,
28
+ source: source.to_s
29
+ )
30
+ end
31
+
32
+ private
33
+
34
+ def normalize_zero(value)
35
+ value.zero? ? 0 : value
36
+ end
37
+
38
+ def validate_digits!(value)
39
+ return if value.abs.to_s.length <= @config.max_digits
40
+
41
+ raise ValidationError,
42
+ "桁数が上限を超えています。現在の上限: #{@config.max_digits}桁。上限を変更するには --max-digits N を指定してください。"
43
+ end
44
+
45
+ def parse_error_message
46
+ "入力式を解釈できません。例: 123+45, 100-7, -12+7, 3--5"
47
+ end
48
+ end
49
+ end