token_reel 0.2.0 → 0.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d8ea0cf1ffd5db7548162763c7944b3005683791b0df22a9c56cc03200a2427a
4
- data.tar.gz: ee2f2160151b7658ab65a13747b4dc84463ecc83b62bff1d659465a95ce99bce
3
+ metadata.gz: b8a6442883c40d8cc57ff6c4763fde5fbfa7b1c5b018a77023bc8a38f41de047
4
+ data.tar.gz: e98f5f9e2cfce2c6cfe9cb32888ef106a492ed4b6b671e9d80d0412dab1d8734
5
5
  SHA512:
6
- metadata.gz: f2835962949cc430260f3e37b620c2f0c4a9286c661ba64d895d4ea290ed2fc910d8adc7c1b20d3df17a28fbf06e9f3c3c917fbe32edd142d43388c9d53f7992
7
- data.tar.gz: '087df605401c0cb00fdc2025cfd1d685e0c657ab167c2d31092b508b0136d8956ac650f9864a9209a1d405072e643cfcae8138000b47672289de7e8d9120e051'
6
+ metadata.gz: 3d0f2bf7add9fd157f157e2c34f462b37bae1d0a8689adb30390b8bd4bfa3e944cdcda8852fb0437298bd3ee14926b52775d4abf8d98a3382230296ca4bf05bc
7
+ data.tar.gz: 5156c0c8b53610b545be54f43f2c66ee86b2eaa127347a8a9d84c2c8a93ab99ffa0132a5f194e5e6606f634d7a891de032ca09cb4959f77c4c2c318fbeca8085
@@ -0,0 +1,57 @@
1
+ name: Release
2
+
3
+ # Publishes to RubyGems whenever a "v*" tag is pushed (see
4
+ # .github/workflows/version-bump.yml, which creates that tag). Uses
5
+ # RubyGems' Trusted Publishing (OIDC) -- no API key stored in this repo.
6
+ # One-time setup required on rubygems.org: Settings > Trusted Publishers
7
+ # on the token_reel gem, with owner "stringsn88keys", repository
8
+ # "token_reel", workflow filename "release.yml", environment "release".
9
+
10
+ on:
11
+ push:
12
+ tags:
13
+ - "v*"
14
+
15
+ permissions:
16
+ contents: read
17
+
18
+ jobs:
19
+ test:
20
+ name: Verify build
21
+ runs-on: ubuntu-latest
22
+ steps:
23
+ - uses: actions/checkout@v4
24
+
25
+ - name: Install ImageMagick
26
+ run: sudo apt-get update && sudo apt-get install -y imagemagick
27
+
28
+ - uses: ruby/setup-ruby@v1
29
+ with:
30
+ ruby-version: "3.3"
31
+ bundler-cache: true
32
+
33
+ - name: Run specs
34
+ run: bundle exec rake spec
35
+
36
+ - name: Build gem
37
+ run: gem build token_reel.gemspec
38
+
39
+ release:
40
+ name: Publish to RubyGems
41
+ needs: test
42
+ runs-on: ubuntu-latest
43
+ environment: release
44
+ permissions:
45
+ contents: write
46
+ id-token: write
47
+ steps:
48
+ - uses: actions/checkout@v5
49
+ with:
50
+ persist-credentials: false
51
+
52
+ - uses: ruby/setup-ruby@v1
53
+ with:
54
+ bundler-cache: true
55
+ ruby-version: ruby
56
+
57
+ - uses: rubygems/release-gem@v1
@@ -0,0 +1,62 @@
1
+ name: Version Bump
2
+
3
+ # Bumps the gem version when a PR is merged to main with a "major",
4
+ # "minor", or "patch" label, then tags the resulting commit. Pushing
5
+ # that tag triggers .github/workflows/release.yml, which publishes the
6
+ # gem to RubyGems.
7
+
8
+ on:
9
+ pull_request:
10
+ types: [closed]
11
+ branches: [main]
12
+
13
+ permissions:
14
+ contents: write
15
+
16
+ jobs:
17
+ bump:
18
+ if: github.event.pull_request.merged == true
19
+ runs-on: ubuntu-latest
20
+ steps:
21
+ - name: Determine version bump from labels
22
+ id: bump
23
+ env:
24
+ LABELS_JSON: ${{ toJSON(github.event.pull_request.labels.*.name) }}
25
+ run: |
26
+ has_label() { echo "$LABELS_JSON" | jq -e --arg l "$1" 'index($l) != null' > /dev/null; }
27
+ if has_label major; then
28
+ part=major
29
+ elif has_label minor; then
30
+ part=minor
31
+ elif has_label patch; then
32
+ part=patch
33
+ else
34
+ part=""
35
+ fi
36
+ echo "part=$part" >> "$GITHUB_OUTPUT"
37
+
38
+ - uses: actions/checkout@v4
39
+ if: steps.bump.outputs.part != ''
40
+ with:
41
+ ref: main
42
+ fetch-depth: 0
43
+
44
+ - uses: ruby/setup-ruby@v1
45
+ if: steps.bump.outputs.part != ''
46
+ with:
47
+ ruby-version: "3.3"
48
+ bundler-cache: true
49
+
50
+ - name: Bump version
51
+ if: steps.bump.outputs.part != ''
52
+ run: bundle exec rake "version:${{ steps.bump.outputs.part }}"
53
+
54
+ - name: Commit and tag
55
+ if: steps.bump.outputs.part != ''
56
+ run: |
57
+ new_version=$(ruby -e 'puts File.read("lib/token_reel/version.rb")[/VERSION = "(.*)"/, 1]')
58
+ git config user.name "github-actions[bot]"
59
+ git config user.email "github-actions[bot]@users.noreply.github.com"
60
+ git commit -am "Bump version to ${new_version}"
61
+ git tag "v${new_version}"
62
+ git push origin main "v${new_version}"
data/README.md CHANGED
@@ -114,6 +114,8 @@ Run `token_reel --help` for the full flag list. The important ones:
114
114
  | `--font NAME` | exact ImageMagick font name | auto-detected monospace |
115
115
  | `--fps N` | GIF frame rate | `12` |
116
116
  | `--hold N` | seconds to hold on the finished frame | `1.5` |
117
+ | `--variability` | jitter each token's delay +/-10/20/30% (normal distribution) for a more natural, less metronomic stream | off |
118
+ | `--seed N` | RNG seed for `--variability`, for reproducible output | random |
117
119
  | `-o, --out PATH` | output GIF path | `token_reel.gif` |
118
120
 
119
121
  ## Ruby API
@@ -165,6 +167,45 @@ Frames are sampled at `--fps` and identical consecutive frames are
165
167
  collapsed into a single frame with a longer delay, so a long `--ttft`
166
168
  or a slow `--tps` doesn't blow up the frame count or file size.
167
169
 
170
+ By default every token in a stream lands at an exact, metronomic
171
+ interval. Pass `--variability` to jitter each token's delay instead:
172
+ per token, one of +/-10%, +/-20%, or +/-30% is picked at random and
173
+ applied as a normally-distributed offset around the base interval, so
174
+ some tokens land a little early and others a little late -- closer to
175
+ how a real model streams. Pair it with `--seed N` to reproduce the
176
+ exact same jittered timeline across runs.
177
+
178
+ ## Releasing
179
+
180
+ Version bumps and RubyGems publishing are both automated, driven off
181
+ PR labels:
182
+
183
+ 1. Label a PR `major`, `minor`, or `patch` (semver meaning, same as
184
+ the `rake version:*` tasks below) before merging it to `main`.
185
+ 2. On merge, [`version-bump.yml`](.github/workflows/version-bump.yml)
186
+ bumps `lib/token_reel/version.rb` accordingly, commits it to
187
+ `main`, and pushes a matching `vX.Y.Z` tag.
188
+ 3. That tag push triggers [`release.yml`](.github/workflows/release.yml),
189
+ which runs the test suite and then publishes the gem to RubyGems
190
+ using [Trusted Publishing](https://guides.rubygems.org/trusted-publishing/)
191
+ (OIDC -- no API key stored in this repo).
192
+
193
+ A PR merged without one of those labels doesn't bump the version or
194
+ release anything.
195
+
196
+ Trusted Publishing needs a one-time setup on rubygems.org, under the
197
+ gem's *Trusted Publishers* settings: owner `stringsn88keys`, repository
198
+ `token_reel`, workflow filename `release.yml`, environment `release`.
199
+
200
+ The version can also be bumped locally without releasing:
201
+
202
+ ```bash
203
+ rake version # print the current version
204
+ rake version:patch # x.x.X -- backwards-compatible fixes
205
+ rake version:minor # x.X.0 -- backwards-compatible features
206
+ rake version:major # X.0.0 -- breaking changes
207
+ ```
208
+
168
209
  ## License
169
210
 
170
211
  MIT
data/Rakefile CHANGED
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "bundler/gem_tasks"
3
4
  require "rspec/core/rake_task"
4
5
 
5
6
  RSpec::Core::RakeTask.new(:spec)
@@ -50,6 +50,8 @@ module TokenReel
50
50
  o.on("--hold N", Float, "Seconds to hold the final frame (default: #{config.hold})") { |v| config.hold = v }
51
51
  o.on("--fps N", Integer, "GIF frame rate (default: #{config.fps})") { |v| config.fps = v }
52
52
  o.on("--loop N", Integer, "GIF loop count, 0 = forever (default: #{config.loop_count})") { |v| config.loop_count = v }
53
+ o.on("--variability", "Jitter each token's delay by a random +/-10/20/30% (normal distribution)") { config.variability = true }
54
+ o.on("--seed N", Integer, "RNG seed for --variability, for reproducible output (default: random)") { |v| config.seed = v }
53
55
 
54
56
  o.separator ""
55
57
  o.separator "Look:"
@@ -9,7 +9,8 @@ module TokenReel
9
9
  :cols, :rows, :font, :font_size, :fps,
10
10
  :theme, :hold, :loop_count,
11
11
  :label, :thinking_label, :title, :cursor_char,
12
- :out, :keep_frames
12
+ :out, :keep_frames,
13
+ :variability, :seed
13
14
 
14
15
  def initialize
15
16
  @prompt = ""
@@ -35,6 +36,8 @@ module TokenReel
35
36
  @cursor_char = "\u258A" # "▊"
36
37
  @out = "token_reel.gif"
37
38
  @keep_frames = false
39
+ @variability = false # jitter each token's delay by a random +/-10/20/30% (normal distribution)
40
+ @seed = nil # RNG seed for --variability; nil = a fresh random seed each run
38
41
  end
39
42
 
40
43
  def validate!
@@ -20,6 +20,11 @@ module TokenReel
20
20
  BLINK_HZ = 2.0 # cursor toggles this many times per second
21
21
  DOT_INTERVAL = 0.35 # seconds between "thinking..." dot ticks
22
22
 
23
+ # When config.variability is on, each token's delay is independently
24
+ # jittered by a normally-distributed offset, bounded to +/- one of
25
+ # these percentages (picked at random per token) of its base interval.
26
+ JITTER_PCTS = [0.10, 0.20, 0.30].freeze
27
+
23
28
  attr_reader :prompt_tokens, :reasoning_tokens, :response_tokens,
24
29
  :prompt_full, :reasoning_full, :response_full,
25
30
  :prompt_done_t, :thinking_end_t, :reasoning_end_t, :stream_end_t, :duration
@@ -37,10 +42,20 @@ module TokenReel
37
42
  @reasoning_interval = config.reasoning_tps.to_f.positive? ? 1.0 / config.reasoning_tps : 1.0 / config.tps
38
43
  @response_interval = 1.0 / config.tps
39
44
 
40
- @prompt_done_t = @prompt_interval * prompt_tokens.size
45
+ @rng = config.variability ? Random.new(config.seed || Random.new_seed) : nil
46
+
47
+ # Cumulative reveal times per token: offsets[n] is the moment the
48
+ # n-th token has fully appeared. With variability off this is just
49
+ # n * interval; with it on, each step is independently jittered,
50
+ # so the array can't be derived by a plain multiply anymore.
51
+ @prompt_offsets = cumulative_offsets(prompt_tokens.size, @prompt_interval)
52
+ @reasoning_offsets = cumulative_offsets(reasoning_tokens.size, @reasoning_interval)
53
+ @response_offsets = cumulative_offsets(response_tokens.size, @response_interval)
54
+
55
+ @prompt_done_t = @prompt_offsets.last
41
56
  @thinking_end_t = prompt_done_t + config.ttft.to_f
42
- @reasoning_end_t = thinking_end_t + @reasoning_interval * reasoning_tokens.size
43
- @stream_end_t = reasoning_end_t + @response_interval * response_tokens.size
57
+ @reasoning_end_t = thinking_end_t + @reasoning_offsets.last
58
+ @stream_end_t = reasoning_end_t + @response_offsets.last
44
59
  @duration = stream_end_t + config.hold.to_f
45
60
  end
46
61
 
@@ -48,7 +63,7 @@ module TokenReel
48
63
  t = t.clamp(0, duration)
49
64
 
50
65
  if t < prompt_done_t
51
- n = @prompt_interval.positive? ? (t / @prompt_interval).floor : prompt_tokens.size
66
+ n = @prompt_interval.positive? ? tokens_shown(@prompt_offsets, t) : prompt_tokens.size
52
67
  State.new(
53
68
  phase: :typing_prompt,
54
69
  prompt_text: prompt_tokens[0...n].join,
@@ -69,7 +84,7 @@ module TokenReel
69
84
  )
70
85
  elsif t < reasoning_end_t
71
86
  elapsed = t - thinking_end_t
72
- n = @reasoning_interval.positive? ? (elapsed / @reasoning_interval).floor : reasoning_tokens.size
87
+ n = @reasoning_interval.positive? ? tokens_shown(@reasoning_offsets, elapsed) : reasoning_tokens.size
73
88
  n = n.clamp(0, reasoning_tokens.size)
74
89
  State.new(
75
90
  phase: :reasoning,
@@ -81,7 +96,7 @@ module TokenReel
81
96
  )
82
97
  else
83
98
  elapsed = t - reasoning_end_t
84
- n = @response_interval.positive? ? (elapsed / @response_interval).floor : response_tokens.size
99
+ n = @response_interval.positive? ? tokens_shown(@response_offsets, elapsed) : response_tokens.size
85
100
  n = n.clamp(0, response_tokens.size)
86
101
  done = n >= response_tokens.size
87
102
  State.new(
@@ -113,6 +128,39 @@ module TokenReel
113
128
 
114
129
  private
115
130
 
131
+ # Builds the cumulative reveal-time array for `count` tokens spaced
132
+ # `interval` seconds apart: offsets[0] == 0, offsets[count] == the
133
+ # total time to reveal them all. With no RNG (variability off) each
134
+ # step is exactly `interval`, matching the un-jittered timeline
135
+ # exactly; with one, each step is independently perturbed.
136
+ def cumulative_offsets(count, interval)
137
+ offsets = [0.0]
138
+ count.times { offsets << offsets.last + jittered_delay(interval) }
139
+ offsets
140
+ end
141
+
142
+ def jittered_delay(interval)
143
+ return interval unless @rng
144
+
145
+ pct = JITTER_PCTS.sample(random: @rng)
146
+ z = normal_sample.clamp(-1.0, 1.0) # keep the (rare) normal-curve tail within the chosen bound
147
+ [interval * (1.0 + z * pct), 0.0].max
148
+ end
149
+
150
+ # Standard normal sample (mean 0, stddev 1) via the Box-Muller transform.
151
+ def normal_sample
152
+ u1 = 1.0 - @rng.rand # (0, 1], avoids log(0)
153
+ u2 = @rng.rand
154
+ Math.sqrt(-2.0 * Math.log(u1)) * Math.cos(2 * Math::PI * u2)
155
+ end
156
+
157
+ # Number of tokens fully revealed by `elapsed`, given their
158
+ # cumulative reveal-time array (size == token count + 1).
159
+ def tokens_shown(offsets, elapsed)
160
+ idx = offsets.bsearch_index { |offset| offset > elapsed }
161
+ idx.nil? ? offsets.size - 1 : idx - 1
162
+ end
163
+
116
164
  def blink_on?(t)
117
165
  (t * (2 * BLINK_HZ)).to_i.even?
118
166
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TokenReel
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.0"
5
5
  end
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: token_reel
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Thomas Powell
8
- autorequire:
9
8
  bindir: exe
10
9
  cert_chain: []
11
- date: 2026-08-26 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: rake
@@ -53,6 +52,8 @@ extensions: []
53
52
  extra_rdoc_files: []
54
53
  files:
55
54
  - ".github/workflows/ci.yml"
55
+ - ".github/workflows/release.yml"
56
+ - ".github/workflows/version-bump.yml"
56
57
  - ".gitignore"
57
58
  - Gemfile
58
59
  - README.md
@@ -79,7 +80,6 @@ licenses:
79
80
  metadata:
80
81
  homepage_uri: https://github.com/stringsn88keys/token_reel
81
82
  source_code_uri: https://github.com/stringsn88keys/token_reel
82
- post_install_message:
83
83
  rdoc_options: []
84
84
  require_paths:
85
85
  - lib
@@ -94,8 +94,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
94
94
  - !ruby/object:Gem::Version
95
95
  version: '0'
96
96
  requirements: []
97
- rubygems_version: 3.4.10
98
- signing_key:
97
+ rubygems_version: 4.0.16
99
98
  specification_version: 4
100
99
  summary: Render terminal-style GIFs of an LLM prompt/response, streamed at a chosen
101
100
  tokens/sec and time-to-first-token