keisanjaku 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,306 @@
1
+ require_relative "parser"
2
+ require_relative "rule_state"
3
+
4
+ module Keisanjaku
5
+ Step = Struct.new(:primitive, :scale, :value, :side, :description, keyword_init: true) do
6
+ def label
7
+ parts = [primitive]
8
+ parts << scale if scale
9
+ parts << value if value
10
+ parts << side if side
11
+ parts.join(" ")
12
+ end
13
+ end
14
+
15
+ Plan = Struct.new(
16
+ :expression,
17
+ :steps,
18
+ :true_value,
19
+ :read_scale,
20
+ :read_value,
21
+ :place_explanation,
22
+ keyword_init: true
23
+ )
24
+
25
+ class Planner
26
+ def initialize(ast, expression: nil)
27
+ @ast = ast
28
+ @expression = expression
29
+ @state = RuleState.new
30
+ @steps = []
31
+ end
32
+
33
+ def compile
34
+ terms, operations = flatten(@ast)
35
+ current_value = term_value(terms.first)
36
+ emit_initial_term(terms.first, chained: !operations.empty?)
37
+ if operations.any?
38
+ ensure_front if @state.face == :back
39
+ ensure_cursor_on_d(current_value) unless cursor_matches_d?(current_value)
40
+ end
41
+
42
+ operations.each do |operator, term|
43
+ factor = term_value(term)
44
+ ensure_front
45
+ if operator == :multiply
46
+ emit_multiply(current_value, factor, term)
47
+ current_value *= factor
48
+ else
49
+ emit_divide(current_value, factor, term)
50
+ current_value /= factor
51
+ end
52
+ end
53
+
54
+ emit_final_read(current_value, operations.empty? ? terms.first : nil)
55
+ Plan.new(
56
+ expression: @expression,
57
+ steps: @steps,
58
+ true_value: current_value,
59
+ read_scale: final_read_scale(operations.empty? ? terms.first : nil),
60
+ read_value: current_value,
61
+ place_explanation: place_explanation(current_value)
62
+ )
63
+ end
64
+
65
+ def self.compile(ast, expression: nil)
66
+ new(ast, expression: expression).compile
67
+ end
68
+
69
+ private
70
+
71
+ def flatten(node)
72
+ return [[node], []] unless node.is_a?(BinaryNode)
73
+
74
+ terms, operations = flatten(node.left)
75
+ terms << node.right
76
+ operations << [node.operator, node.right]
77
+ [terms, operations]
78
+ end
79
+
80
+ def term_value(node)
81
+ case node
82
+ when NumberNode
83
+ node.value
84
+ when PowerNode
85
+ node.base**node.power
86
+ when FunctionNode
87
+ function_value(node)
88
+ else
89
+ raise ArgumentError, "unknown AST node: #{node.inspect}"
90
+ end
91
+ end
92
+
93
+ def function_value(node)
94
+ case node.name
95
+ when :sqrt then Math.sqrt(node.argument)
96
+ when :cbrt then node.argument**(1.0 / 3.0)
97
+ when :sin then Math.sin(node.argument * Scale::DEGREE)
98
+ when :tan then Math.tan(node.argument * Scale::DEGREE)
99
+ when :log then Math.log10(node.argument)
100
+ else
101
+ raise ArgumentError, "unknown function: #{node.name}"
102
+ end
103
+ end
104
+
105
+ def emit_initial_term(node, chained:)
106
+ case node
107
+ when NumberNode
108
+ add_move_cursor("D", mantissa(node.value), "カーソルを D 尺の #{display_number(node.value)} に合わせます")
109
+ when PowerNode
110
+ add_move_cursor("D", mantissa(node.base), "カーソルを D 尺の #{display_number(node.base)} に合わせます")
111
+ add_read("A", "#{display_number(node.base)}^2 を A 尺で読み取ります") if node.power == 2
112
+ add_read("K", "#{display_number(node.base)}^3 を K 尺で読み取ります") if node.power == 3
113
+ when FunctionNode
114
+ emit_function_term(node)
115
+ end
116
+ ensure_cursor_on_d(term_value(node)) if chained
117
+ end
118
+
119
+ def emit_function_term(node)
120
+ case node.name
121
+ when :sqrt
122
+ add_move_cursor("A", normalized_power_value(node.argument, 2), "カーソルを A 尺の #{display_number(node.argument)} に合わせます")
123
+ add_read("D", "D 尺で平方根を読み取ります")
124
+ when :cbrt
125
+ add_move_cursor("K", normalized_power_value(node.argument, 3), "カーソルを K 尺の #{display_number(node.argument)} に合わせます")
126
+ add_read("D", "D 尺で立方根を読み取ります")
127
+ when :sin
128
+ ensure_back
129
+ add_move_cursor("S", node.argument, "#{source_label(node)} を S 尺に合わせます")
130
+ add_read("D", "D 尺で正弦の仮数を読み取ります")
131
+ when :tan
132
+ ensure_back
133
+ add_move_cursor("T", node.argument, "#{source_label(node)} を T 尺に合わせます")
134
+ add_read("D", "D 尺で正接の仮数を読み取ります")
135
+ when :log
136
+ add_move_cursor("D", mantissa(node.argument), "カーソルを D 尺の #{display_number(node.argument)} に合わせます")
137
+ add_read("L", "L 尺で常用対数を読み取ります")
138
+ end
139
+ end
140
+
141
+ def emit_multiply(current_value, factor, term)
142
+ current_pos = d_position(current_value)
143
+ factor_mantissa = mantissa(factor)
144
+ factor_pos = Scale.fetch("C").position(factor_mantissa)
145
+ side, target = choose_multiply_side(current_pos, factor_pos)
146
+ add_move_slide_index(side, multiply_index_description(side, current_value))
147
+ add_move_cursor("C", factor_mantissa, "#{term_label(term)} を C 尺でカーソルに合わせます")
148
+ @state.cursor = target
149
+ add_read("D", "D 尺で積の仮数を読み取ります")
150
+ end
151
+
152
+ def emit_divide(current_value, factor, term)
153
+ factor_mantissa = mantissa(factor)
154
+ add_move_slide_to("C", factor_mantissa, "#{term_label(term)} を C 尺で現在のカーソルに合わせます")
155
+ side, target = choose_division_side(@state.slide)
156
+ add_move_cursor("C", side == :left ? 1.0 : 10.0, "C 尺の#{side == :left ? '左' : '右'}基線へカーソルを動かします")
157
+ @state.cursor = target
158
+ add_read("D", "D 尺で商の仮数を読み取ります")
159
+ end
160
+
161
+ def choose_multiply_side(current_pos, factor_pos)
162
+ left_target = current_pos + factor_pos
163
+ return [:left, left_target] if unit?(left_target)
164
+
165
+ right_target = current_pos - 1.0 + factor_pos
166
+ return [:right, right_target] if unit?(right_target)
167
+
168
+ raise ScaleError, "no visible slide index can multiply at #{current_pos}"
169
+ end
170
+
171
+ def choose_division_side(slide)
172
+ left_target = slide
173
+ return [:left, left_target] if unit?(left_target)
174
+
175
+ right_target = slide + 1.0
176
+ return [:right, right_target] if unit?(right_target)
177
+
178
+ raise ScaleError, "no visible slide index can divide at slide #{slide}"
179
+ end
180
+
181
+ def multiply_index_description(side, current_value)
182
+ if side == :left
183
+ "滑尺の左基線(1)を現在値 #{display_number(current_value)} に合わせます"
184
+ else
185
+ "左基線では枠外に出るため、滑尺の右基線(10)を使って折り返します"
186
+ end
187
+ end
188
+
189
+ def emit_final_read(value, single_term)
190
+ scale = final_read_scale(single_term)
191
+ return if @steps.last&.primitive == :read && @steps.last.scale.to_s == scale.to_s
192
+
193
+ ensure_cursor_on_d(value) if scale == "D" && !cursor_matches_d?(value)
194
+ add_read(scale, "最終結果を #{scale} 尺で読み取ります")
195
+ end
196
+
197
+ def final_read_scale(single_term)
198
+ case single_term
199
+ when PowerNode
200
+ single_term.power == 2 ? "A" : "K"
201
+ when FunctionNode
202
+ single_term.name == :log ? "L" : "D"
203
+ else
204
+ "D"
205
+ end
206
+ end
207
+
208
+ def ensure_cursor_on_d(value)
209
+ return if cursor_matches_d?(value)
210
+
211
+ add_move_cursor("D", mantissa(value), "中間結果の仮数 #{display_number(mantissa(value))} を D 尺に移します")
212
+ end
213
+
214
+ def cursor_matches_d?(value)
215
+ (@state.cursor - d_position(value)).abs < 1e-9
216
+ end
217
+
218
+ def ensure_front
219
+ add_flip("滑尺を表面(C/CI/B)に戻します") if @state.face == :back
220
+ end
221
+
222
+ def ensure_back
223
+ add_flip("滑尺を裏面(S/T)に切り替えます") if @state.face == :front
224
+ end
225
+
226
+ def add_move_cursor(scale, value, description)
227
+ add_step(Step.new(primitive: :move_cursor, scale: scale, value: value, description: description))
228
+ @state.move_cursor_to(scale, value)
229
+ end
230
+
231
+ def add_move_slide_index(side, description)
232
+ add_step(Step.new(primitive: :move_slide_index, side: side, description: description))
233
+ @state.align_slide_index(side)
234
+ end
235
+
236
+ def add_move_slide_to(scale, value, description)
237
+ add_step(Step.new(primitive: :move_slide_to, scale: scale, value: value, description: description))
238
+ @state.align_slide_value(scale, value)
239
+ end
240
+
241
+ def add_flip(description)
242
+ add_step(Step.new(primitive: :flip_slide, description: description))
243
+ @state.flip!
244
+ end
245
+
246
+ def add_read(scale, description)
247
+ add_step(Step.new(primitive: :read, scale: scale, description: description))
248
+ end
249
+
250
+ def add_step(step)
251
+ @steps << step
252
+ end
253
+
254
+ def mantissa(value)
255
+ numeric = Float(value)
256
+ exponent = Math.log10(numeric).floor
257
+ mantissa = numeric / (10.0**exponent)
258
+ return 1.0 if (mantissa - 10.0).abs < 1e-12
259
+
260
+ mantissa
261
+ end
262
+
263
+ def normalized_power_value(value, power)
264
+ max = 10.0**power
265
+ numeric = Float(value)
266
+ numeric /= max while numeric > max
267
+ numeric *= max while numeric < 1.0
268
+ numeric
269
+ end
270
+
271
+ def d_position(value)
272
+ Scale.fetch("D").position(mantissa(value))
273
+ end
274
+
275
+ def unit?(value)
276
+ value >= -1e-12 && value <= 1.0 + 1e-12
277
+ end
278
+
279
+ def source_label(node)
280
+ if node.source_name == :cos
281
+ "cos(#{display_number(node.source_argument)})=sin(#{display_number(node.argument)})"
282
+ else
283
+ "#{node.source_name}(#{display_number(node.source_argument)})"
284
+ end
285
+ end
286
+
287
+ def term_label(node)
288
+ case node
289
+ when NumberNode then display_number(node.value)
290
+ when PowerNode then "#{display_number(node.base)}^#{node.power}"
291
+ when FunctionNode then source_label(node)
292
+ else node.to_s
293
+ end
294
+ end
295
+
296
+ def display_number(value)
297
+ format("%.6g", value)
298
+ end
299
+
300
+ def place_explanation(value)
301
+ exponent = Math.log10(value).floor
302
+ mant = value / (10.0**exponent)
303
+ "読み取り仮数 #{format('%.4g', mant)} に 10^#{exponent} を掛けて #{format('%.6g', value)} とします"
304
+ end
305
+ end
306
+ end
@@ -0,0 +1,108 @@
1
+ require_relative "planner"
2
+
3
+ module Keisanjaku
4
+ ProcedureAttempt = Struct.new(:actions, :used_model, :answer, keyword_init: true)
5
+
6
+ ProcedureEvent = Struct.new(:primitive, :scale, :value, :side, keyword_init: true) do
7
+ def self.from_step(step)
8
+ new(
9
+ primitive: step.primitive,
10
+ scale: step.scale&.to_s,
11
+ value: step.value,
12
+ side: step.side
13
+ )
14
+ end
15
+ end
16
+
17
+ class ProcedureCapture
18
+ POSITION_TOLERANCE = 0.002
19
+
20
+ attr_reader :events
21
+
22
+ def initialize(plan)
23
+ @steps = plan&.steps || []
24
+ @index = 0
25
+ @events = []
26
+ end
27
+
28
+ def record_satisfied!(state, trigger: nil)
29
+ loop do
30
+ step = @steps[@index]
31
+ break unless step_satisfied?(step, state, trigger)
32
+
33
+ @events << ProcedureEvent.from_step(step)
34
+ @index += 1
35
+ trigger = nil
36
+ end
37
+ events
38
+ end
39
+
40
+ private
41
+
42
+ def step_satisfied?(step, state, trigger)
43
+ return false unless step
44
+
45
+ case step.primitive
46
+ when :move_cursor
47
+ close?(state.cursor, state.board_position(step.scale, step.value))
48
+ when :move_slide_index
49
+ close?(state.slide, state.cursor - index_position(step.side))
50
+ when :move_slide_to
51
+ close?(state.slide, state.cursor - Scale.fetch(step.scale).position(step.value))
52
+ when :flip_slide
53
+ trigger == :flip
54
+ when :read
55
+ trigger == :read
56
+ else
57
+ false
58
+ end
59
+ end
60
+
61
+ def index_position(side)
62
+ side == :right ? 1.0 : 0.0
63
+ end
64
+
65
+ def close?(actual, expected)
66
+ (actual - expected).abs <= POSITION_TOLERANCE
67
+ rescue ScaleError
68
+ false
69
+ end
70
+ end
71
+
72
+ class ProcedureChecker
73
+ VALUE_TOLERANCE = 1e-9
74
+
75
+ def matches?(plan, attempt)
76
+ return false if attempt.used_model
77
+
78
+ expected = expected_events(plan)
79
+ observed = observed_events(attempt)
80
+ expected.length == observed.length &&
81
+ expected.zip(observed).all? { |expected_event, observed_event| event_matches?(expected_event, observed_event) }
82
+ end
83
+
84
+ def expected_events(plan)
85
+ plan.steps.map { |step| ProcedureEvent.from_step(step) }
86
+ end
87
+
88
+ def observed_events(attempt)
89
+ attempt.actions.filter { |action| action.is_a?(ProcedureEvent) }
90
+ end
91
+
92
+ private
93
+
94
+ def event_matches?(expected, observed)
95
+ expected.primitive == observed.primitive &&
96
+ expected.scale == observed.scale &&
97
+ expected.side == observed.side &&
98
+ values_match?(expected.value, observed.value)
99
+ end
100
+
101
+ def values_match?(expected, observed)
102
+ return true if expected.nil? && observed.nil?
103
+ return false if expected.nil? || observed.nil?
104
+
105
+ (expected.to_f - observed.to_f).abs <= VALUE_TOLERANCE
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,142 @@
1
+ require_relative "ansi"
2
+ require_relative "rule_state"
3
+
4
+ module Keisanjaku
5
+ class Renderer
6
+ MIN_WIDTH = 60
7
+ DEFAULT_WIDTH = 100
8
+ LABEL_WIDTH = 6
9
+
10
+ def initialize(color: true)
11
+ @color = color
12
+ end
13
+
14
+ def render(state, width: DEFAULT_WIDTH, message: nil, highlight_scale: nil)
15
+ width = [width.to_i, MIN_WIDTH].max
16
+ rail_width = width - LABEL_WIDTH - 2
17
+ cursor_col = column_for(state.cursor, rail_width)
18
+ highlight_name = highlight_scale&.to_s&.upcase
19
+ lines = []
20
+ lines << border_line(rail_width, :top)
21
+ lines.concat(render_scale("K", state, rail_width, cursor_col, highlight_name))
22
+ lines.concat(render_scale("A", state, rail_width, cursor_col, highlight_name))
23
+ lines << border_line(rail_width, :divider)
24
+ slide_names = state.face == :front ? %w[B CI C] : %w[S T]
25
+ slide_names.each { |name| lines.concat(render_scale(name, state, rail_width, cursor_col, highlight_name)) }
26
+ lines << border_line(rail_width, :divider)
27
+ lines.concat(render_scale("D", state, rail_width, cursor_col, highlight_name))
28
+ lines.concat(render_scale("L", state, rail_width, cursor_col, highlight_name))
29
+ lines << border_line(rail_width, :bottom)
30
+ lines << status_line(state, width, highlight_name)
31
+ lines << ANSI.fit(message.to_s, width) if message
32
+ lines.map { |line| ANSI.fit(line, width) }
33
+ end
34
+
35
+ def self.render(state, width: DEFAULT_WIDTH, color: true, message: nil, highlight_scale: nil)
36
+ new(color: color).render(state, width: width, message: message, highlight_scale: highlight_scale)
37
+ end
38
+
39
+ private
40
+
41
+ def border_line(rail_width, kind)
42
+ left = kind == :top || kind == :bottom ? "+" : "+"
43
+ right = "+"
44
+ label = " " * LABEL_WIDTH
45
+ "#{label}#{left}#{'-' * rail_width}#{right}"
46
+ end
47
+
48
+ def render_scale(name, state, rail_width, cursor_col, highlight_name)
49
+ scale = Scale.fetch(name)
50
+ slide_scale = state.slide_scale?(name)
51
+ tick_row = base_row(rail_width, slide_scale, state.slide)
52
+ label_row = base_row(rail_width, slide_scale, state.slide)
53
+ highlight_col = highlight_name == name ? cursor_col : nil
54
+
55
+ scale.ticks(rail_width).each do |tick|
56
+ board_pos = slide_scale ? tick[:col].to_f / (rail_width - 1) + state.slide : tick[:col].to_f / (rail_width - 1)
57
+ next unless board_pos >= 0.0 && board_pos <= 1.0
58
+
59
+ col = column_for(board_pos, rail_width)
60
+ tick_row[col] = tick_mark(tick[:level])
61
+ write_label(label_row, col, tick[:label]) if tick[:label]
62
+ end
63
+
64
+ draw_cursor(tick_row, cursor_col)
65
+ draw_cursor(label_row, cursor_col)
66
+ label = name.rjust(LABEL_WIDTH)
67
+ [
68
+ "#{label}|#{join_row(tick_row, slide_scale, state.slide, rail_width, highlight_col)}|",
69
+ "#{' ' * LABEL_WIDTH}|#{join_row(label_row, slide_scale, state.slide, rail_width, highlight_col)}|"
70
+ ]
71
+ end
72
+
73
+ def base_row(rail_width, slide_scale, slide)
74
+ return Array.new(rail_width, " ") unless slide_scale
75
+
76
+ Array.new(rail_width) do |index|
77
+ board_pos = index.to_f / (rail_width - 1)
78
+ board_pos >= slide && board_pos <= slide + 1.0 ? "." : " "
79
+ end
80
+ end
81
+
82
+ def tick_mark(level)
83
+ case level
84
+ when 0 then "|"
85
+ when 1 then ":"
86
+ else "'"
87
+ end
88
+ end
89
+
90
+ def write_label(row, col, label)
91
+ start = [[col - (label.length / 2), 0].max, row.length - label.length].min
92
+ label.each_char.with_index { |char, index| row[start + index] = char }
93
+ end
94
+
95
+ def draw_cursor(row, cursor_col)
96
+ row[cursor_col] = ANSI.paint("|", :red, color: @color)
97
+ end
98
+
99
+ def join_row(row, slide_scale, slide, rail_width, highlight_col)
100
+ row.each_with_index.map do |cell, index|
101
+ highlighted = highlight_col == index
102
+ inside_slide = slide_scale && inside_slide?(index, slide, rail_width)
103
+ decorate_cell(cell, inside_slide, highlighted)
104
+ end.join
105
+ end
106
+
107
+ def decorate_cell(cell, inside_slide, highlighted)
108
+ return ANSI.paint(cell, :yellow, :bold, color: @color) if highlighted
109
+ return cell unless inside_slide
110
+ return cell unless @color
111
+
112
+ display = cell == "." ? " " : cell
113
+ ANSI.paint(display, :cyan_bg, color: @color)
114
+ end
115
+
116
+ def inside_slide?(index, slide, rail_width)
117
+ board_pos = index.to_f / (rail_width - 1)
118
+ board_pos >= slide && board_pos <= slide + 1.0
119
+ end
120
+
121
+ def column_for(pos, rail_width)
122
+ (pos.clamp(0.0, 1.0) * (rail_width - 1)).round
123
+ end
124
+
125
+ def status_line(state, width, highlight_name)
126
+ values = state.visible_scale_names.filter_map do |name|
127
+ value = state.read(name)
128
+ next unless value
129
+
130
+ text = "#{name}=#{format_value(value)}"
131
+ name == highlight_name ? ANSI.paint(text, :yellow, :bold, color: @color) : text
132
+ end
133
+ ANSI.fit("cursor=#{format('%.4f', state.cursor)} slide=#{format('%+.4f', state.slide)} face=#{state.face} #{values.join(' ')}", width)
134
+ end
135
+
136
+ def format_value(value)
137
+ return "--" unless value
138
+
139
+ format("%.5g", value)
140
+ end
141
+ end
142
+ end
@@ -0,0 +1,98 @@
1
+ require_relative "scale"
2
+
3
+ module Keisanjaku
4
+ class RuleState
5
+ FRONT_SLIDE_SCALES = %w[B CI C].freeze
6
+ BACK_SLIDE_SCALES = %w[S T].freeze
7
+ FIXED_SCALES = %w[K A D L].freeze
8
+ SLIDE_SCALES = (FRONT_SLIDE_SCALES + BACK_SLIDE_SCALES).freeze
9
+
10
+ attr_reader :slide, :cursor, :face
11
+
12
+ def initialize(slide: 0.0, cursor: 0.0, face: :front)
13
+ self.slide = slide
14
+ self.cursor = cursor
15
+ self.face = face
16
+ end
17
+
18
+ def slide=(value)
19
+ @slide = Float(value).clamp(-1.0, 1.0)
20
+ end
21
+
22
+ def cursor=(value)
23
+ @cursor = Float(value).clamp(0.0, 1.0)
24
+ end
25
+
26
+ def face=(value)
27
+ symbol = value.to_sym
28
+ raise ArgumentError, "face must be :front or :back" unless %i[front back].include?(symbol)
29
+
30
+ @face = symbol
31
+ end
32
+
33
+ def dup
34
+ self.class.new(slide: slide, cursor: cursor, face: face)
35
+ end
36
+
37
+ def read(scale_name)
38
+ scale = Scale.fetch(scale_name)
39
+ pos = local_position_for(scale.name)
40
+ return nil unless pos && pos >= -1e-12 && pos <= 1.0 + 1e-12
41
+
42
+ scale.value_at(pos.clamp(0.0, 1.0))
43
+ rescue ScaleError
44
+ nil
45
+ end
46
+
47
+ def visible_scale_names
48
+ face == :front ? %w[K A B CI C D L] : %w[K A S T D L]
49
+ end
50
+
51
+ def board_position(scale_name, value)
52
+ scale = Scale.fetch(scale_name)
53
+ local = scale.position(value)
54
+ slide_scale?(scale.name) ? local + slide : local
55
+ end
56
+
57
+ def move_cursor_to(scale_name, value)
58
+ self.cursor = board_position(scale_name, value)
59
+ end
60
+
61
+ def align_slide_index(side)
62
+ self.slide = cursor - index_position(side)
63
+ end
64
+
65
+ def align_slide_value(scale_name, value)
66
+ scale = Scale.fetch(scale_name)
67
+ raise ScaleError, "#{scale.name} is not on the slide" unless slide_scale?(scale.name)
68
+
69
+ self.slide = cursor - scale.position(value)
70
+ end
71
+
72
+ def flip!
73
+ self.face = face == :front ? :back : :front
74
+ end
75
+
76
+ def slide_scale?(scale_name)
77
+ SLIDE_SCALES.include?(scale_name.to_s.upcase)
78
+ end
79
+
80
+ def local_position_for(scale_name)
81
+ name = scale_name.to_s.upcase
82
+ slide_scale?(name) ? cursor - slide : cursor
83
+ end
84
+
85
+ private
86
+
87
+ def index_position(side)
88
+ case side
89
+ when :left, "left", 1, "1"
90
+ 0.0
91
+ when :right, "right", 10, "10"
92
+ 1.0
93
+ else
94
+ raise ArgumentError, "unknown slide index side: #{side}"
95
+ end
96
+ end
97
+ end
98
+ end