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,142 @@
1
+ require_relative "../engine"
2
+ require_relative "../renderer"
3
+ require_relative "../input"
4
+ require_relative "../terminal_viewport"
5
+
6
+ module Keisanjaku
7
+ module Modes
8
+ class Demo
9
+ REDRAW_INTERVAL = 0.1
10
+
11
+ def initialize(expression, width: nil, color: true, animate: true, output: $stdout)
12
+ @expression = expression
13
+ @width = width
14
+ @viewport = TerminalViewport.new(width: width)
15
+ @renderer = Renderer.new(color: color)
16
+ @animate = animate
17
+ @output = output
18
+ end
19
+
20
+ def plan
21
+ @plan ||= Planner.compile(Parser.parse(@expression), expression: @expression)
22
+ end
23
+
24
+ def run_noninteractive
25
+ engine = Engine.new(plan, animate: false)
26
+ @output.puts "Expression: #{@expression}"
27
+ plan.steps.each_with_index do |step, index|
28
+ @output.puts "#{index + 1}. #{step.description}"
29
+ engine.step_forward
30
+ end
31
+ @renderer.render(engine.state, width: @viewport.current_width, highlight_scale: plan.read_scale).each { |line| @output.puts line }
32
+ print_summary(engine.summary)
33
+ end
34
+
35
+ def run_interactive(input: $stdin)
36
+ engine = Engine.new(plan, animate: @animate)
37
+ reader = Input.new(input)
38
+ auto = false
39
+ speed = 0.35
40
+ next_auto_at = Time.now + speed
41
+ needs_draw = true
42
+ @viewport = TerminalViewport.new(width: @width, right_margin: 1, clamp_fixed_width: true)
43
+ @viewport.install
44
+ Input.with_raw(input: input, output: @output) do
45
+ loop do
46
+ if needs_draw || @viewport.refresh_needed?
47
+ draw(engine)
48
+ needs_draw = false
49
+ end
50
+
51
+ key = reader.read_key(timeout: auto ? [speed, REDRAW_INTERVAL].min : REDRAW_INTERVAL)
52
+ if key.nil?
53
+ if auto && Time.now >= next_auto_at
54
+ auto = false unless advance(engine)
55
+ next_auto_at = Time.now + speed
56
+ needs_draw = true
57
+ end
58
+ next
59
+ end
60
+
61
+ case key
62
+ when :enter, :space
63
+ advance(engine)
64
+ needs_draw = true
65
+ when "b"
66
+ engine.step_back
67
+ needs_draw = true
68
+ when "a"
69
+ auto = !auto
70
+ next_auto_at = Time.now + speed
71
+ needs_draw = true
72
+ when "1"
73
+ speed = 0.7
74
+ next_auto_at = Time.now + speed
75
+ when "2"
76
+ speed = 0.35
77
+ next_auto_at = Time.now + speed
78
+ when "3"
79
+ speed = 0.15
80
+ next_auto_at = Time.now + speed
81
+ when "q", :ctrl_c
82
+ break
83
+ end
84
+ end
85
+ end
86
+ ensure
87
+ @viewport.restore if @viewport
88
+ end
89
+
90
+ private
91
+
92
+ def advance(engine)
93
+ from = engine.state.dup
94
+ step = engine.step_forward
95
+ return nil unless step
96
+
97
+ animate_transition(engine, step, from, engine.state)
98
+ step
99
+ end
100
+
101
+ def animate_transition(engine, step, from, to)
102
+ return unless @animate
103
+
104
+ engine.frames_for(step, from: from, to: to).each do |frame|
105
+ draw_frame(frame, step.description, highlight_scale_for(step))
106
+ sleep 0.04
107
+ end
108
+ end
109
+
110
+ def draw(engine)
111
+ step = plan.steps[engine.index]
112
+ message = step ? step.description : summary_text(engine.summary)
113
+ highlight_scale = step ? highlight_scale_for(step) : plan.read_scale
114
+ draw_frame(engine.state, message, highlight_scale)
115
+ Input.raw_puts(@output, "Enter/Space next b back a auto 1/2/3 speed q quit")
116
+ end
117
+
118
+ def draw_frame(state, message, highlight_scale)
119
+ @output.print ANSI.clear_screen
120
+ Input.raw_print_lines(@output, @renderer.render(
121
+ state,
122
+ width: @viewport.current_width,
123
+ message: message,
124
+ highlight_scale: highlight_scale
125
+ ))
126
+ end
127
+
128
+ def highlight_scale_for(step)
129
+ step&.primitive == :read ? step.scale : nil
130
+ end
131
+
132
+ def print_summary(summary)
133
+ @output.puts summary_text(summary)
134
+ @output.puts summary[:place_explanation]
135
+ end
136
+
137
+ def summary_text(summary)
138
+ "Read=#{format('%.6g', summary[:read_value])} True=#{format('%.6g', summary[:true_value])} Error=#{format('%.4f', summary[:error_percent])}%"
139
+ end
140
+ end
141
+ end
142
+ end
@@ -0,0 +1,127 @@
1
+ require_relative "../drill"
2
+ require_relative "../terminal_viewport"
3
+ require_relative "demo"
4
+ require_relative "sandbox"
5
+
6
+ module Keisanjaku
7
+ module Modes
8
+ class Drill
9
+ def initialize(width: nil, color: true, tolerance_percent: 1.0, output: $stdout)
10
+ @width = width
11
+ @color = color
12
+ @generator = QuestionGenerator.new
13
+ @judge = Judge.new(tolerance_percent: tolerance_percent)
14
+ @procedure_checker = ProcedureChecker.new
15
+ @output = output
16
+ @score = 0
17
+ @streak = 0
18
+ @started_at = Time.now
19
+ end
20
+
21
+ def run(input: $stdin)
22
+ operations = choose_operations(input)
23
+ loop do
24
+ question = @generator.next_question(operation: operations.sample)
25
+ puts_fitted(question.prompt)
26
+ attempt = operate_before_answer(input, question)
27
+ break if attempt == :quit
28
+ next if attempt == :next
29
+
30
+ answer = attempt.answer || read_text_answer(input)
31
+ break if answer.nil? || answer == "q"
32
+
33
+ if answer == "g"
34
+ show_demo(question)
35
+ next
36
+ end
37
+
38
+ judge_answer(answer, question, attempt)
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ def choose_operations(input)
45
+ puts_fitted("出題範囲を選択してください。空欄/all で全範囲。")
46
+ OperationSelector.menu_lines(display_width).each { |line| puts_fitted(line) }
47
+ @output.print "range> "
48
+ selection = input.gets
49
+ @output.puts unless input.tty?
50
+ OperationSelector.parse(selection)
51
+ end
52
+
53
+ def operate_before_answer(input, question)
54
+ return ProcedureAttempt.new(actions: [], used_model: false) unless input.tty?
55
+
56
+ loop do
57
+ puts_fitted("operate: Enter/o 操作+回答 s text answer g demo q quit")
58
+ @output.print "> "
59
+ choice = input.gets&.strip
60
+ return :quit if choice.nil? || choice == "q"
61
+ return ProcedureAttempt.new(actions: [], used_model: false) if choice == "s"
62
+ if choice == "g"
63
+ show_demo(question)
64
+ return ProcedureAttempt.new(actions: [], used_model: true)
65
+ end
66
+ return run_operation_workspace(input, question) if choice.empty? || choice == "o"
67
+ end
68
+ end
69
+
70
+ def run_operation_workspace(input, question)
71
+ plan = Planner.compile(Parser.parse(question.expression), expression: question.expression)
72
+ sandbox = Modes::Sandbox.new(
73
+ width: @width,
74
+ color: @color,
75
+ output: @output,
76
+ prompt: question.prompt,
77
+ answer_entry: true,
78
+ procedure_plan: plan
79
+ )
80
+ result = sandbox.run(input: input)
81
+ return :quit if result.quit
82
+
83
+ ProcedureAttempt.new(actions: result.actions, used_model: false, answer: result.answer)
84
+ end
85
+
86
+ def show_demo(question)
87
+ Modes::Demo.new(question.expression, width: @width, color: @color, animate: false, output: @output).run_noninteractive
88
+ end
89
+
90
+ def judge_answer(answer, question, attempt)
91
+ if @judge.correct?(answer, question.true_value)
92
+ @score += 1
93
+ @streak += 1
94
+ @output.puts "OK score=#{@score} streak=#{@streak} procedure=#{procedure_result(question, attempt)} elapsed=#{elapsed}s"
95
+ else
96
+ @streak = 0
97
+ error = @judge.relative_error_percent(answer, question.true_value)
98
+ @output.puts "NG true=#{format('%.6g', question.true_value)} error=#{format('%.3f', error)}%"
99
+ end
100
+ rescue ArgumentError
101
+ @output.puts "数値を入力してください"
102
+ end
103
+
104
+ def read_text_answer(input)
105
+ @output.print "answer (g demo, q quit)> "
106
+ input.gets&.strip
107
+ end
108
+
109
+ def puts_fitted(text)
110
+ @output.puts ANSI.fit(text, display_width)
111
+ end
112
+
113
+ def display_width
114
+ @display_width ||= TerminalViewport.new(width: @width, right_margin: 1, clamp_fixed_width: true).current_width
115
+ end
116
+
117
+ def elapsed
118
+ (Time.now - @started_at).round(1)
119
+ end
120
+
121
+ def procedure_result(question, attempt)
122
+ plan = Planner.compile(Parser.parse(question.expression), expression: question.expression)
123
+ @procedure_checker.matches?(plan, attempt) ? "matched" : "unchecked"
124
+ end
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,159 @@
1
+ require_relative "../input"
2
+ require_relative "../procedure"
3
+ require_relative "../renderer"
4
+ require_relative "../terminal_viewport"
5
+
6
+ module Keisanjaku
7
+ module Modes
8
+ class Sandbox
9
+ COARSE = 0.01
10
+ FINE = 0.001
11
+ REDRAW_INTERVAL = 0.1
12
+
13
+ SandboxResult = Struct.new(:actions, :answer, :quit, keyword_init: true)
14
+
15
+ attr_reader :state, :target, :actions
16
+
17
+ def initialize(width: nil, color: true, output: $stdout, prompt: nil, answer_entry: false, viewport: nil, procedure_plan: nil)
18
+ @state = RuleState.new(cursor: Scale.fetch("D").position(1.0))
19
+ @target = :slide
20
+ @show_values = true
21
+ @viewport = viewport || TerminalViewport.new(width: width, right_margin: 1, clamp_fixed_width: true)
22
+ @renderer = Renderer.new(color: color)
23
+ @output = output
24
+ @prompt = prompt
25
+ @answer_entry = answer_entry
26
+ @answer = +""
27
+ @actions = []
28
+ @procedure_capture = procedure_plan ? ProcedureCapture.new(procedure_plan) : nil
29
+ end
30
+
31
+ def handle_key(key)
32
+ return handle_answer_key(key) if @answer_entry && answer_key?(key)
33
+
34
+ result = case key
35
+ when "q", :ctrl_c
36
+ :quit
37
+ when "h"
38
+ record(:slide_move)
39
+ move_slide(-COARSE)
40
+ when "l"
41
+ record(:slide_move)
42
+ move_slide(COARSE)
43
+ when "H", ","
44
+ record(:slide_move)
45
+ move_slide(-FINE)
46
+ when "L", "."
47
+ record(:slide_move)
48
+ move_slide(FINE)
49
+ when "c", "j", "k"
50
+ record(:select_target)
51
+ @target = target == :slide ? :cursor : :slide
52
+ when :left
53
+ record_selected_move
54
+ move_selected(-COARSE)
55
+ when :right
56
+ record_selected_move
57
+ move_selected(COARSE)
58
+ when :tab
59
+ record(:flip)
60
+ state.flip!
61
+ when "?"
62
+ @show_values = !@show_values
63
+ when "r"
64
+ record(:reset)
65
+ @state = RuleState.new
66
+ end
67
+ capture_procedure unless result == :quit
68
+ result
69
+ end
70
+
71
+ def run(input: $stdin)
72
+ reader = Input.new(input)
73
+ @viewport.install
74
+ needs_draw = true
75
+ quit = false
76
+ Input.with_raw(input: input, output: @output) do
77
+ loop do
78
+ if needs_draw || @viewport.refresh_needed?
79
+ draw
80
+ needs_draw = false
81
+ end
82
+ key = reader.read_key(timeout: REDRAW_INTERVAL)
83
+ next unless key
84
+
85
+ result = handle_key(key)
86
+ quit = result == :quit
87
+ break if quit || result == :done
88
+
89
+ needs_draw = true
90
+ end
91
+ end
92
+ SandboxResult.new(actions: actions, answer: @answer.empty? ? nil : @answer, quit: quit)
93
+ ensure
94
+ @viewport.restore if @viewport
95
+ end
96
+
97
+ private
98
+
99
+ def answer_key?(key)
100
+ key == :enter ||
101
+ key == :backspace ||
102
+ key.is_a?(String) && key.match?(/\A[0-9.eE+-]\z/)
103
+ end
104
+
105
+ def handle_answer_key(key)
106
+ case key
107
+ when :enter
108
+ record(:read)
109
+ capture_procedure(trigger: :read)
110
+ :done
111
+ when :backspace
112
+ @answer.chop!
113
+ nil
114
+ else
115
+ @answer << key
116
+ nil
117
+ end
118
+ end
119
+
120
+ def record(action)
121
+ @actions << action
122
+ end
123
+
124
+ def capture_procedure(trigger: nil)
125
+ return unless @procedure_capture
126
+
127
+ before = @procedure_capture.events.length
128
+ @procedure_capture.record_satisfied!(state, trigger: trigger)
129
+ @actions.concat(@procedure_capture.events.drop(before))
130
+ end
131
+
132
+ def record_selected_move
133
+ record(target == :slide ? :slide_move : :cursor_move)
134
+ end
135
+
136
+ def move_slide(delta)
137
+ state.slide = state.slide + delta
138
+ end
139
+
140
+ def move_selected(delta)
141
+ if target == :slide
142
+ move_slide(delta)
143
+ else
144
+ state.cursor = state.cursor + delta
145
+ end
146
+ end
147
+
148
+ def draw
149
+ guide = "Sandbox target=#{target} keys: h/l slide H/L fine arrows selected Tab flip ? values r reset q quit"
150
+ answer = @answer_entry ? "answer=#{@answer.empty? ? '_' : @answer} Enter=submit Backspace=edit" : nil
151
+ message = [@prompt, answer, guide].compact.join(" ")
152
+ @output.print ANSI.clear_screen
153
+ @renderer.render(state, width: @viewport.current_width, message: @show_values ? message : "Sandbox values hidden").each do |line|
154
+ Input.raw_puts(@output, line)
155
+ end
156
+ end
157
+ end
158
+ end
159
+ end
@@ -0,0 +1,185 @@
1
+ module Keisanjaku
2
+ class ParseError < StandardError
3
+ attr_reader :position
4
+
5
+ def initialize(message, position)
6
+ @position = position
7
+ super("#{message} at #{position}")
8
+ end
9
+ end
10
+
11
+ NumberNode = Struct.new(:value, keyword_init: true)
12
+ PowerNode = Struct.new(:base, :power, keyword_init: true)
13
+ FunctionNode = Struct.new(:name, :argument, :source_name, :source_argument, keyword_init: true)
14
+ BinaryNode = Struct.new(:left, :operator, :right, keyword_init: true)
15
+
16
+ class Parser
17
+ FUNCTIONS = %w[sqrt cbrt sin cos tan log].freeze
18
+
19
+ def initialize(input)
20
+ @chars = input.to_s.each_char.to_a
21
+ @index = 0
22
+ end
23
+
24
+ def parse
25
+ skip_space
26
+ node = parse_expr
27
+ skip_space
28
+ raise_error("unexpected token '#{peek}'") unless eof?
29
+
30
+ node
31
+ end
32
+
33
+ def self.parse(input)
34
+ new(input).parse
35
+ end
36
+
37
+ private
38
+
39
+ def parse_expr
40
+ node = parse_term
41
+ loop do
42
+ skip_space
43
+ operator = parse_operator
44
+ break unless operator
45
+
46
+ node = BinaryNode.new(left: node, operator: operator, right: parse_term)
47
+ end
48
+ node
49
+ end
50
+
51
+ def parse_term
52
+ skip_space
53
+ if letter?(peek)
54
+ return parse_function
55
+ end
56
+
57
+ number = parse_number
58
+ skip_space
59
+ return number unless consume("^")
60
+
61
+ power_char = consume_any(%w[2 3])
62
+ raise_error("expected power 2 or 3") unless power_char
63
+
64
+ PowerNode.new(base: number.value, power: power_char.to_i)
65
+ end
66
+
67
+ def parse_function
68
+ start = @index
69
+ name = parse_identifier
70
+ raise_error("unknown function '#{name}'", start) unless FUNCTIONS.include?(name)
71
+ skip_space
72
+ raise_error("expected '('") unless consume("(")
73
+
74
+ argument = parse_number.value
75
+ skip_space
76
+ raise_error("expected ')'") unless consume(")")
77
+
78
+ build_function(name, argument, start)
79
+ end
80
+
81
+ def build_function(name, argument, position)
82
+ case name
83
+ when "sqrt", "cbrt", "log"
84
+ raise ParseError.new("#{name} expects a positive number", position) unless argument.positive?
85
+ FunctionNode.new(name: name.to_sym, argument: argument, source_name: name.to_sym, source_argument: argument)
86
+ when "sin"
87
+ require_angle(argument, Scale::S_MIN_DEGREES, 90.0, "sin", position)
88
+ FunctionNode.new(name: :sin, argument: argument, source_name: :sin, source_argument: argument)
89
+ when "tan"
90
+ require_angle(argument, Scale::T_MIN_DEGREES, 45.0, "tan", position)
91
+ FunctionNode.new(name: :tan, argument: argument, source_name: :tan, source_argument: argument)
92
+ when "cos"
93
+ converted = 90.0 - argument
94
+ require_angle(converted, Scale::S_MIN_DEGREES, 90.0, "cos", position)
95
+ FunctionNode.new(name: :sin, argument: converted, source_name: :cos, source_argument: argument)
96
+ else
97
+ raise_error("unknown function '#{name}'", position)
98
+ end
99
+ end
100
+
101
+ def require_angle(value, min, max, name, position)
102
+ return if value >= min - 1e-12 && value <= max + 1e-12
103
+
104
+ raise ParseError.new("#{name} angle is outside #{format('%.3f', min)}..#{format('%.3f', max)}", position)
105
+ end
106
+
107
+ def parse_operator
108
+ char = peek
109
+ case char
110
+ when "*", "×"
111
+ advance
112
+ :multiply
113
+ when "/", "÷"
114
+ advance
115
+ :divide
116
+ end
117
+ end
118
+
119
+ def parse_number
120
+ skip_space
121
+ start = @index
122
+ digits = +""
123
+ digits << advance while digit?(peek)
124
+ if peek == "."
125
+ digits << advance
126
+ digits << advance while digit?(peek)
127
+ end
128
+ raise_error("expected number", start) if digits.empty? || digits == "."
129
+
130
+ value = Float(digits)
131
+ raise_error("number must be positive", start) unless value.positive?
132
+
133
+ NumberNode.new(value: value)
134
+ end
135
+
136
+ def parse_identifier
137
+ ident = +""
138
+ ident << advance while letter?(peek)
139
+ ident
140
+ end
141
+
142
+ def consume(expected)
143
+ return false unless peek == expected
144
+
145
+ advance
146
+ true
147
+ end
148
+
149
+ def consume_any(expected)
150
+ return nil unless expected.include?(peek)
151
+
152
+ advance
153
+ end
154
+
155
+ def skip_space
156
+ advance while peek&.match?(/\s/)
157
+ end
158
+
159
+ def eof?
160
+ @index >= @chars.length
161
+ end
162
+
163
+ def peek
164
+ @chars[@index]
165
+ end
166
+
167
+ def advance
168
+ char = @chars[@index]
169
+ @index += 1
170
+ char
171
+ end
172
+
173
+ def digit?(char)
174
+ char&.match?(/[0-9]/)
175
+ end
176
+
177
+ def letter?(char)
178
+ char&.match?(/[A-Za-z]/)
179
+ end
180
+
181
+ def raise_error(message, position = @index)
182
+ raise ParseError.new(message, position)
183
+ end
184
+ end
185
+ end