From 8ed767bed4d6f34814faf616fa1b1c293398eca2 Mon Sep 17 00:00:00 2001 From: IG Date: Fri, 11 Sep 2026 06:05:09 +0400 Subject: [PATCH 01/21] feat: make pre and code opaque, and the opaque element set configurable Google mangled `jq '.meters'` into `jq '.metros'` inside a live

block because nothing told the pipeline that code holds language, not prose.
pre and code now join script and style in Passage::Scanner's opaque set, and
the set is a plain config.opaque_elements option (default: script, style,
pre, code) so an application can widen or narrow it.

While testing the nesting case (a notranslate span inside an opaque element),
found and fixed a real bug: closing a protected element that itself raised
@opaque_depth (either by name, like , or by
ancestry, like a notranslate span inside ) never gave that increment
back, because a protected element's own end_element takes the early-return
branch and never reaches the opaque decrement. Every sentence after such a
block silently stopped being sent for translation. attr now undoes the
bump right where it made it.

Metrics/ClassLength bumped from 100 to 110 for lib/**/* -- Configuration is a
flat table of declared options, and this one is a genuine addition, not
untidiness.
---
 .rubocop.yml                                |  2 +
 lib/translation_diff/configuration.rb       |  1 +
 lib/translation_diff/passage.rb             | 22 +++++---
 test/translation_diff/configuration_test.rb | 10 ++++
 test/translation_diff/passage_test.rb       | 60 +++++++++++++++++++++
 5 files changed, 88 insertions(+), 7 deletions(-)

diff --git a/.rubocop.yml b/.rubocop.yml
index 578f546..e0717d3 100644
--- a/.rubocop.yml
+++ b/.rubocop.yml
@@ -33,6 +33,8 @@ Metrics/ParameterLists:
   CountKeywordArgs: false
 
 Metrics/ClassLength:
+  # Configuration is a flat table of declared options; each one is a line, not a sign the class is doing too much.
+  Max: 110
   Exclude:
     # Test classes are mostly tables of cases.
     - test/**/*
diff --git a/lib/translation_diff/configuration.rb b/lib/translation_diff/configuration.rb
index 634348a..3a479e8 100644
--- a/lib/translation_diff/configuration.rb
+++ b/lib/translation_diff/configuration.rb
@@ -55,6 +55,7 @@ def provider_option_owners = @provider_option_owners ||= ProviderOptionOwners.ne
   option :rate_interval, 60
   option :rate_limiter, nil
   option :segmenter, :pragmatic
+  option :opaque_elements, %i[script style pre code]
   option :instrumenter, nil
   option :logger, nil
   option :open_timeout, 5
diff --git a/lib/translation_diff/passage.rb b/lib/translation_diff/passage.rb
index fa1117d..a866bb9 100644
--- a/lib/translation_diff/passage.rb
+++ b/lib/translation_diff/passage.rb
@@ -3,11 +3,13 @@ class TranslationDiff::Passage
   attr_reader :fragments
 
   # The source is scanned with every lone `<` escaped, so the offsets, the slices and the render all agree on it.
-  def initialize(source, segmenter:, language: nil)
+  # opaque_elements defaults to the live config, read here rather than memoised, so a runtime change takes effect
+  # without a caller that only ever passes segmenter and language having to be touched.
+  def initialize(source, segmenter:, language: nil, opaque_elements: TranslationDiff.config.opaque_elements)
     @source = TranslationDiff::Markup.escape_bare_angles(source)
     @segmenter = segmenter
     @language = language
-    @fragments = Scanner.new(@source).runs.map { |run| fragment(run) }
+    @fragments = Scanner.new(@source, opaque_elements: opaque_elements).runs.map { |run| fragment(run) }
   end
 
   # The translatable sentences, in document order; the empty ones are whitespace a provider has no use for.
@@ -31,9 +33,6 @@ def fragment(run)
 
   # Ox reports a byte position for every construct it sees; recording those is what lets rendering slice the source.
   class Scanner < Ox::Sax
-    # Content nobody wants translated, however much of it looks like prose.
-    OPAQUE = %i[script style].freeze
-
     # Providers honour this class themselves under the HTML mode this gem sends, so the element must reach them whole.
     PROTECTED = "notranslate".freeze
 
@@ -43,13 +42,16 @@ class Scanner < Ox::Sax
     Mark = Struct.new(:offset, :prose)
 
     # Ox reports positions only to a handler that already has the ivar, so @pos exists before parsing starts.
-    def initialize(source)
+    # opaque_elements is a caller-supplied set of element names, so it is normalised here rather than trusted as given.
+    def initialize(source, opaque_elements:)
       super()
       @source = source
+      @opaque = opaque_elements.map { |element| element.to_s.downcase.to_sym }
       @pos = 0
       @marks = []
       @protected_depth = 0
       @opaque_depth = 0
+      @opaque_bump = false
       @pending = nil
     end
 
@@ -60,18 +62,24 @@ def runs
     end
 
     # Protection beats opacity on purpose: a caller wrapping a subtree asked for it to be passed through as it is.
+    # @opaque_bump remembers whether *this* element is the one that raised @opaque_depth, so attr can undo exactly
+    # that increment if the element turns out to be protected -- its own end_element never gets the chance to.
     def start_element(name)
       return @protected_depth += 1 if @protected_depth.positive?
 
-      @opaque_depth += 1 if @opaque_depth.positive? || OPAQUE.include?(name)
+      @opaque_bump = @opaque_depth.positive? || @opaque.include?(name)
+      @opaque_depth += 1 if @opaque_bump
       @pending = mark(prose: false)
     end
 
     # Attributes arrive straight after their own start element, so @pending is that element and never another.
+    # A protected element's own end_element takes the protected branch and never reaches the opaque decrement, so
+    # an opaque_depth this element raised has to be given back here or it would outlive the element that raised it.
     def attr(name, value)
       return unless @pending && protection?(name, value)
 
       @pending.prose = true
+      @opaque_depth -= 1 if @opaque_bump
       @protected_depth = 1
       @pending = nil
     end
diff --git a/test/translation_diff/configuration_test.rb b/test/translation_diff/configuration_test.rb
index 3d59820..7b90f48 100644
--- a/test/translation_diff/configuration_test.rb
+++ b/test/translation_diff/configuration_test.rb
@@ -498,6 +498,16 @@ def test_an_unknown_segmenter_name_raises_listing_what_is_registered
     assert_includes error.message, "pragmatic"
   end
 
+  def test_opaque_elements_defaults_to_script_style_pre_and_code
+    assert_equal %i[script style pre code], @config.opaque_elements
+  end
+
+  def test_opaque_elements_is_a_plain_setting_an_application_can_replace
+    @config.opaque_elements = %i[script style kbd samp]
+
+    assert_equal %i[script style kbd samp], @config.opaque_elements
+  end
+
   def test_the_redis_pool_is_built_once_and_shared
     @config.redis_url = "redis://localhost:6379"
     @config.rate_limit = 100
diff --git a/test/translation_diff/passage_test.rb b/test/translation_diff/passage_test.rb
index 808f062..d5ea147 100644
--- a/test/translation_diff/passage_test.rb
+++ b/test/translation_diff/passage_test.rb
@@ -8,6 +8,13 @@ def passage(source)
   # What a provider would be asked to translate, in order.
   def cores(source) = passage(source).segments.reject(&:empty?).map(&:core)
 
+  # What the :null provider does: every sentence comes back upcased, so only markup handling shows.
+  def translated(source)
+    subject = passage(source)
+    subject.segments.reject(&:empty?).each { |s| s.translation = s.core.upcase }
+    subject.render
+  end
+
   def assert_round_trips(source)
     assert_equal source, passage(source).render, "render must return the source byte for byte"
   end
@@ -46,6 +53,59 @@ def test_script_and_style_contents_are_not_prose
     assert_equal %w[аль бра кил], cores("альбракил")
   end
 
+  # pre and code join script and style: their text is left alone, however much of it looks like prose.
+  def test_pre_and_code_contents_are_not_prose
+    source = %(

See:

curl -s https://example.com/level | jq '.meters'

after

) + + assert_equal ["See:", "after"], cores(source) + assert_round_trips(source) + end + + # The common case, not the block one: a code span mid-sentence must not split the sentence around it or eat a space. + def test_an_inline_code_span_leaves_the_sentence_around_it_intact + source = "Press Ctrl+C to stop." + + assert_equal ["Press", "to stop."], cores(source) + assert_equal "PRESS Ctrl+C TO STOP.", translated(source) + end + + def test_an_empty_code_element_round_trips + assert_round_trips("Before.After.") + end + + def test_a_code_element_holding_only_whitespace_round_trips + assert_round_trips("Before. After.") + end + + def test_a_pre_holding_markup_looking_text_round_trips + assert_round_trips("
<div> not real markup
After.") + end + + # A notranslate span nested inside an opaque element used to leak: closing it left @opaque_depth one too high, + # so every sentence after the code block silently stopped being sent. This is the regression test for that. + def test_a_notranslate_span_inside_a_code_block_does_not_confuse_the_walker + source = %(DO_NOT_TOUCH After this all good.) + + assert_equal [%(DO_NOT_TOUCH), "After this all good."], cores(source) + assert_equal %(DO_NOT_TOUCH AFTER THIS ALL GOOD.), translated(source) + end + + # Protection beats opacity on purpose, same as it does for script: a code span inside notranslate stays one unit. + def test_a_code_span_inside_a_notranslate_element_stays_inside_the_protected_unit + source = %(x After.) + + assert_equal [source], cores(source) + end + + # The set is configurable: an application can widen it, or shrink it back to script and style. + def test_opaque_elements_is_configurable_per_passage + source = "Before.keep meAfter." + subject = TranslationDiff::Passage.new(source, segmenter: TranslationDiff::Segmenters::Pragmatic.new, + opaque_elements: %i[script style]) + + assert_equal ["Before.", "keep me", "After."], subject.segments.reject(&:empty?).map(&:core) + end + def test_a_comment_is_not_prose assert_equal ["Visible text here."], cores(" Visible text here.") end From b7bb9d37db4c521e17872014011c10869ced97bf Mon Sep 17 00:00:00 2001 From: IG Date: Fri, 11 Sep 2026 06:14:58 +0400 Subject: [PATCH 02/21] feat: give every event from one call a shared call_id A subscriber receiving cache, request and usage events had no way to tell which translate call they belonged to short of tagging Thread.current themselves -- a workaround that breaks the moment two translations share a thread. Translator now generates one opaque call_id per call and puts it in translate, cache, request, rate_limit, usage and cache_error; Dispatcher receives it rather than making its own, since it emits three of the six. --- lib/translation_diff.rb | 1 + lib/translation_diff/dispatcher.rb | 11 +++-- lib/translation_diff/translator.rb | 13 ++++-- test/translation_diff/dispatcher_test.rb | 19 ++++++-- test/translation_diff/instrumentation_test.rb | 43 +++++++++++++++++++ test/translation_diff/translator_test.rb | 18 ++++++++ 6 files changed, 93 insertions(+), 12 deletions(-) diff --git a/lib/translation_diff.rb b/lib/translation_diff.rb index e721416..ad5b0c3 100644 --- a/lib/translation_diff.rb +++ b/lib/translation_diff.rb @@ -2,6 +2,7 @@ require "digest/md5" require "digest/sha2" require "forwardable" +require "securerandom" require "stringio" require "ox" diff --git a/lib/translation_diff/dispatcher.rb b/lib/translation_diff/dispatcher.rb index 2f6c6b9..20a8963 100644 --- a/lib/translation_diff/dispatcher.rb +++ b/lib/translation_diff/dispatcher.rb @@ -4,10 +4,11 @@ class TranslationDiff::Dispatcher attr_reader :config - def initialize(provider:, from:, to:, options: {}, config: nil) + def initialize(provider:, from:, to:, call_id:, options: {}, config: nil) @provider = provider @from = from @to = to + @call_id = call_id @options = options @config = config || TranslationDiff.config end @@ -22,7 +23,7 @@ def dispatch(segments) # The batch applies the reply to the segments that produced it, so no step ever correlates by position again. def send_batch(batch) texts = batch.texts - payload = { provider: @provider.cache_key, batch: texts.size, characters: texts.sum(&:size) } + payload = { call_id: @call_id, provider: @provider.cache_key, batch: texts.size, characters: texts.sum(&:size) } throttle(payload[:characters]) response = instrument("request", payload) { @provider.translate(request(texts)) } report_usage(response, payload[:characters]) @@ -38,14 +39,16 @@ def throttle(characters) limiter = config.rate_limiter_instance return if limiter.nil? - instrument("rate_limit", provider: @provider.cache_key, characters: characters) { limiter.check(characters) } + instrument("rate_limit", call_id: @call_id, provider: @provider.cache_key, + characters: characters) { limiter.check(characters) } end # A point event: what this request cost, as this library counted it -- a provider's own count never overrides it. def report_usage(response, characters) usage = response.usage - instrument("usage", provider: @provider.cache_key, + instrument("usage", call_id: @call_id, + provider: @provider.cache_key, characters: characters, billed_characters: usage&.billed_characters, reported: @provider.class.capabilities.reports_billing?, diff --git a/lib/translation_diff/translator.rb b/lib/translation_diff/translator.rb index ad9d8f1..d5a550c 100644 --- a/lib/translation_diff/translator.rb +++ b/lib/translation_diff/translator.rb @@ -65,13 +65,16 @@ def pair_description(from) = from.nil? ? "to #{@to}" : "#{from} to #{@to}" # The `translate` event wraps everything a call that reaches a provider does, and nothing an early return does. def translated(document, passages, segments, provider, from) values = TranslationDiff::Leaves.count(@values) - payload = { from: from.to_s, to: @to.to_s, provider: provider.cache_key, values: values } + payload = { call_id: call_id, from: from.to_s, to: @to.to_s, provider: provider.cache_key, values: values } instrument("translate", payload) do fill(provider, segments, from) rebuild(document, passages) end end + # Opaque and short: a correlation key for this call's own events, generated once, never derived from the text. + def call_id = @call_id ||= SecureRandom.hex(6) + # Resolved at first use, never in the constructor: a value with nothing to translate needs no provider at all. def resolve_provider build_provider.tap do |provider| @@ -131,9 +134,11 @@ def fill(provider, segments, from) cache = TranslationDiff::SentenceCache.new(store: config.cache_store, provider: provider.cache_key, from: from, to: @to, options: @options) misses = cache.fill(segments) - instrument("cache", provider: provider.cache_key, hits: segments.size - misses.size, misses: misses.size) + id = call_id + instrument("cache", call_id: id, provider: provider.cache_key, + hits: segments.size - misses.size, misses: misses.size) TranslationDiff::Dispatcher.new(provider: provider, from: from, to: @to, options: @options, - config: config).dispatch(misses) + config: config, call_id: id).dispatch(misses) store(cache, misses, provider) end @@ -142,6 +147,6 @@ def store(cache, misses, provider) cache.store(misses) rescue StandardError => e warn_log("cache write failed (#{e.class}), the translation is returned uncached") - instrument("cache_error", provider: provider.cache_key, error: e.class.to_s) + instrument("cache_error", call_id: call_id, provider: provider.cache_key, error: e.class.to_s) end end diff --git a/test/translation_diff/dispatcher_test.rb b/test/translation_diff/dispatcher_test.rb index 9d4d0cb..01b2572 100644 --- a/test/translation_diff/dispatcher_test.rb +++ b/test/translation_diff/dispatcher_test.rb @@ -64,8 +64,8 @@ def check(size) = @sizes << size def segments(*sources) = sources.map { |s| TranslationDiff::Segment.new(s) } - def dispatcher(provider, **) - TranslationDiff::Dispatcher.new(provider: provider, from: "en", to: "ru", **) + def dispatcher(provider, call_id: "call-1", **) + TranslationDiff::Dispatcher.new(provider: provider, from: "en", to: "ru", call_id: call_id, **) end def configured(**settings) @@ -75,10 +75,10 @@ def configured(**settings) end # Dispatches the given texts through the given provider, and hands back everything it instrumented. - def instrumented(provider, *sources, **settings) + def instrumented(provider, *sources, call_id: "call-1", **settings) recorder = Recorder.new config = configured(instrumenter: recorder, **settings) - dispatcher(provider, config: config).dispatch(segments(*sources)) + dispatcher(provider, config: config, call_id: call_id).dispatch(segments(*sources)) recorder end @@ -148,6 +148,17 @@ def test_the_rate_limiter_is_consulted_before_the_request_with_the_characters_ab assert_equal ["one two".size], limiter.sizes end + # Dispatcher emits three of the six events, so it is handed the call's identifier rather than inventing its own. + def test_the_call_id_it_is_given_appears_in_every_event_it_emits + limiter = FakeRateLimiter.new + provider = RecordingProvider.new(TranslationDiff::Configuration.new) + recorder = instrumented(provider, "one two", rate_limiter: limiter, call_id: "abc123") + + assert_equal "abc123", payload_for(recorder, "request")[:call_id] + assert_equal "abc123", payload_for(recorder, "rate_limit")[:call_id] + assert_equal "abc123", payload_for(recorder, "usage")[:call_id] + end + def test_no_payload_ever_contains_the_text_being_translated secret = "Zaphod Beeblebrox is president." provider = RecordingProvider.new(TranslationDiff::Configuration.new) diff --git a/test/translation_diff/instrumentation_test.rb b/test/translation_diff/instrumentation_test.rb index 4874f8b..087c582 100644 --- a/test/translation_diff/instrumentation_test.rb +++ b/test/translation_diff/instrumentation_test.rb @@ -51,6 +51,49 @@ def test_the_translate_event_carries_languages_provider_and_a_count assert_equal 2, payload[:values] end + def test_every_event_from_one_call_carries_the_same_call_id + TranslationDiff.translate("Hello there.", from: "en", to: "ru") + + call_ids = @recorder.events.map { |_, payload| payload[:call_id] } + + refute_nil call_ids.first + assert_equal [call_ids.first] * call_ids.size, call_ids + end + + # A thread-safe stand-in for Recorder, so two concurrent calls can share one instrumenter without racing. + class ThreadSafeRecorder + def initialize + @events = [] + @mutex = Mutex.new + end + + def events = @mutex.synchronize { @events.dup } + + def instrument(name, payload) + @mutex.synchronize { @events << [name, payload] } + yield if block_given? + end + end + + def test_two_concurrent_calls_never_share_a_call_id + recorder = ThreadSafeRecorder.new + TranslationDiff.configure { |c| c.instrumenter = recorder } + run_concurrently(2) { TranslationDiff.translate("Hello there.", from: "en", to: "ru") } + + call_ids = translate_call_ids(recorder) + + assert_equal 2, call_ids.size + assert_equal 2, call_ids.uniq.size + end + + def run_concurrently(count, &) + Array.new(count) { Thread.new(&) }.each(&:join) + end + + def translate_call_ids(recorder) + recorder.events.filter_map { |event| event.last[:call_id] if event.first == "translate.translation_diff" } + end + def test_the_cache_event_carries_hit_and_miss_counts TranslationDiff.translate("Hello there.", from: "en", to: "ru") diff --git a/test/translation_diff/translator_test.rb b/test/translation_diff/translator_test.rb index 3c5a445..71b73b7 100644 --- a/test/translation_diff/translator_test.rb +++ b/test/translation_diff/translator_test.rb @@ -212,6 +212,17 @@ def test_the_cache_event_carries_hit_and_miss_counts assert_equal "recording", payload[:provider] end + # One `Translator`, one identifier, in every event that call emits -- a subscriber's only way to group them. + def test_every_event_from_one_call_carries_the_same_call_id + recorder = instrumented { |c| c.rate_limiter = FakeRateLimiter.new } + instrumented_translate("Hello there.") + + call_ids = recorder.events.map { |_, payload| payload[:call_id] } + + refute_nil call_ids.first + assert_equal [call_ids.first] * call_ids.size, call_ids + end + # The cache is an optimisation: a translation already paid for at the provider must reach the caller regardless. def test_a_failing_cache_write_does_not_lose_a_translation_already_paid_for TranslationDiff.configure { |c| c.cache = FailingCacheStore.new } @@ -229,6 +240,13 @@ def test_a_failing_cache_write_emits_a_cache_error_event_naming_the_provider_and assert_equal "TranslatorTest::FailingCacheStore::BoomError", payload[:error] end + def test_a_failing_cache_write_events_call_id_matches_the_translate_events + recorder = instrumented { |c| c.cache = FailingCacheStore.new } + instrumented_translate("Hello there.") + + assert_equal payload_for(recorder, "translate")[:call_id], payload_for(recorder, "cache_error")[:call_id] + end + # The instrumentation payload carries the error's class, never the store's own message, which could quote the row. def test_a_failing_cache_writes_event_never_carries_the_text_being_translated secret = "Zaphod Beeblebrox is president." From a06a578d1594f8c6db31c0ad798bc42d622d8b0e Mon Sep 17 00:00:00 2001 From: IG Date: Fri, 11 Sep 2026 06:15:39 +0400 Subject: [PATCH 03/21] feat: report the characters a translate call considered request and usage only fire when a provider is reached, so a call served entirely from cache left a subscriber with nil where it wanted a count. translate now carries characters: every non-blank segment this call looked at, hit or miss, computed once regardless of how the cache split it into batches. request's own characters keeps its narrower meaning -- what one batch actually sent -- distinguished by which event it's on. --- lib/translation_diff/translator.rb | 4 +++- test/translation_diff/instrumentation_test.rb | 10 +++++++++ test/translation_diff/translator_test.rb | 21 +++++++++++++++++++ 3 files changed, 34 insertions(+), 1 deletion(-) diff --git a/lib/translation_diff/translator.rb b/lib/translation_diff/translator.rb index d5a550c..247206a 100644 --- a/lib/translation_diff/translator.rb +++ b/lib/translation_diff/translator.rb @@ -65,7 +65,9 @@ def pair_description(from) = from.nil? ? "to #{@to}" : "#{from} to #{@to}" # The `translate` event wraps everything a call that reaches a provider does, and nothing an early return does. def translated(document, passages, segments, provider, from) values = TranslationDiff::Leaves.count(@values) - payload = { call_id: call_id, from: from.to_s, to: @to.to_s, provider: provider.cache_key, values: values } + characters = segments.sum { |segment| segment.core.size } + payload = { call_id: call_id, from: from.to_s, to: @to.to_s, provider: provider.cache_key, + values: values, characters: characters } instrument("translate", payload) do fill(provider, segments, from) rebuild(document, passages) diff --git a/test/translation_diff/instrumentation_test.rb b/test/translation_diff/instrumentation_test.rb index 087c582..fee08eb 100644 --- a/test/translation_diff/instrumentation_test.rb +++ b/test/translation_diff/instrumentation_test.rb @@ -51,6 +51,16 @@ def test_the_translate_event_carries_languages_provider_and_a_count assert_equal 2, payload[:values] end + # `characters` here is what this call considered, whatever the cache had -- `request`'s `characters` is only + # what one batch actually sent, so a fully-cached call reports here and never has a `request` event at all. + def test_the_translate_event_carries_the_characters_this_call_considered + TranslationDiff.translate("Hello there.", from: "en", to: "ru") + + payload = @recorder.events.find { |name, _| name == "translate.translation_diff" }.last + + assert_equal "Hello there.".size, payload[:characters] + end + def test_every_event_from_one_call_carries_the_same_call_id TranslationDiff.translate("Hello there.", from: "en", to: "ru") diff --git a/test/translation_diff/translator_test.rb b/test/translation_diff/translator_test.rb index 71b73b7..90487ca 100644 --- a/test/translation_diff/translator_test.rb +++ b/test/translation_diff/translator_test.rb @@ -201,6 +201,27 @@ def test_the_translate_event_carries_languages_provider_and_a_count assert_equal 2, payload[:values] end + # `request`'s own `characters` is only what one batch sent; this is what the call considered, hit or miss. + def test_the_translate_event_carries_the_characters_this_call_considered + recorder = instrumented + instrumented_translate("Hello there.") + + assert_equal "Hello there.".size, payload_for(recorder, "translate")[:characters] + end + + # A call served entirely from cache still knows what it considered, even though no request event ever fires. + def test_the_translate_event_reports_characters_even_when_the_call_is_served_entirely_from_cache + recorder = instrumented + instrumented_translate("Hello there.") + instrumented_translate("Hello there.") + + payload = recorder.events.reverse.find { |event| event.first == "translate.translation_diff" }.last + request_count = recorder.events.count { |event| event.first == "request.translation_diff" } + + assert_equal "Hello there.".size, payload[:characters] + assert_equal 1, request_count + end + def test_the_cache_event_carries_hit_and_miss_counts recorder = instrumented instrumented_translate("Hello there.") From fc7b6357220c944226ace33a8095514eef2adae7 Mon Sep 17 00:00:00 2001 From: IG Date: Fri, 11 Sep 2026 06:16:00 +0400 Subject: [PATCH 04/21] fix: invalidate only the memoised collaborator a written option affects Configuration memoised provider_instance, cache_store, segmenter_instance, rate_limiter_instance and redis_pool, and nothing ever cleared them. An application wanting to switch provider at runtime had no way to do it short of TranslationDiff.reset! and reconfiguring from scratch, which also threw away the cache store and the Redis pool it had no reason to touch. Each option declaration now names, via `option ... invalidates:`, exactly which memoised reader(s) it feeds; the writer `option` generates clears only those ivars. Provider options get `invalidates: :provider_instance` automatically from register_provider_options, so a provider's own option names never need listing by hand. An option nobody classifies invalidates nothing, the same as logger, instrumenter and the timeouts. cache_ttl=, cache_namespace= and cache_prune_probability= are defined on prepended modules and were setting their ivars directly, bypassing the generic writer entirely; they now delegate to it through `super` so their invalidation runs too. --- lib/translation_diff/cache_guard_options.rb | 7 +- lib/translation_diff/cache_ttl_option.rb | 5 +- lib/translation_diff/configuration.rb | 61 +++++++++------ test/translation_diff/configuration_test.rb | 85 +++++++++++++++++++++ 4 files changed, 128 insertions(+), 30 deletions(-) diff --git a/lib/translation_diff/cache_guard_options.rb b/lib/translation_diff/cache_guard_options.rb index 53a0f92..1c4809c 100644 --- a/lib/translation_diff/cache_guard_options.rb +++ b/lib/translation_diff/cache_guard_options.rb @@ -2,10 +2,11 @@ module TranslationDiff::CacheGuardOptions CACHE_NAMESPACE_LIMIT = 64 - # An ENV var arrives as a String; coerced here so a translate call never meets a bare String's missing #positive?. + # An ENV var arrives as a String; coerced here so a translate call never meets a bare String's missing + # #positive?. Handed to super so the declared writer's cache_store invalidation still runs. def cache_prune_probability=(value) value = nil if value.is_a?(String) && value.strip.empty? - @cache_prune_probability = value.nil? ? nil : coerce_probability(value) + super(value.nil? ? nil : coerce_probability(value)) end # Refused here, rather than at the first write's ActiveRecord::ValueTooLong. @@ -13,7 +14,7 @@ def cache_namespace=(value) value = nil if value.is_a?(String) && value.strip.empty? raise namespace_too_long(value) if value.is_a?(String) && value.length > CACHE_NAMESPACE_LIMIT - @cache_namespace = value + super end private diff --git a/lib/translation_diff/cache_ttl_option.rb b/lib/translation_diff/cache_ttl_option.rb index 015f80f..067aaa1 100644 --- a/lib/translation_diff/cache_ttl_option.rb +++ b/lib/translation_diff/cache_ttl_option.rb @@ -7,11 +7,12 @@ def initialize super end - # A non-positive number folds into nil too -- a TTL of zero or less can never keep a row. + # A non-positive number folds into nil too -- a TTL of zero or less can never keep a row. Coerced here, + # then handed to super so the declared writer's cache_store invalidation still runs. def cache_ttl=(value) value = nil if value.is_a?(String) && value.strip.empty? value = coerce_ttl(value) if value.is_a?(String) - @cache_ttl = value.is_a?(Numeric) && value <= 0 ? nil : value + super(value.is_a?(Numeric) && value <= 0 ? nil : value) end def cache_ttl diff --git a/lib/translation_diff/configuration.rb b/lib/translation_diff/configuration.rb index 3a479e8..e57b67b 100644 --- a/lib/translation_diff/configuration.rb +++ b/lib/translation_diff/configuration.rb @@ -1,25 +1,25 @@ # Every declared setting in one place; callable defaults are invoked on read, not at load time. class TranslationDiff::Configuration class << self - def option(key, default = nil) + # `invalidates:` names the memoised reader(s) this option feeds; a writer clears exactly those ivars. + # An option that names none -- logger, instrumenter, the timeouts -- clears nothing, which is also + # what an option nobody classifies does: invalidation is opt-in, never a guess from the option's name. + def option(key, default = nil, invalidates: nil) key = key.to_sym return if options.include?(key) - define_method(:"#{key}=") do |value| - value = nil if value.is_a?(String) && value.strip.empty? - instance_variable_set(:"@#{key}", value) - end - define_method(key) { read(key) } - + define_option_accessors(key, Array(invalidates)) defaults[key] = default options << key end - # See ProviderOptionOwners for the conflict rules and the all-or-nothing guarantee. + # See ProviderOptionOwners for the conflict rules and the all-or-nothing guarantee. Every provider + # option invalidates provider_instance, whatever it is named -- the registry, not a remembered list, + # is what makes the set known. def register_provider_options(declared, provider) declared = normalise_declarations(declared) provider_option_owners.claim(declared.keys, provider) - declared.each { |key, default| option(key, default) } + declared.each { |key, default| option(key, default, invalidates: :provider_instance) } end def options = @options ||= [] @@ -27,6 +27,16 @@ def defaults = @defaults ||= {} private + # The writer clears exactly the memos this option was declared to invalidate; the reader defers to `read`. + def define_option_accessors(key, memos) + define_method(:"#{key}=") do |value| + value = nil if value.is_a?(String) && value.strip.empty? + instance_variable_set(:"@#{key}", value) + memos.each { |memo| instance_variable_set(:"@#{memo}", nil) } + end + define_method(key) { read(key) } + end + # `:key` declares an option with no default; `{ key => default }` declares one, and a callable is read lazily. def normalise_declarations(declared) entries = declared.is_a?(Hash) ? [declared] : Array(declared) @@ -39,22 +49,23 @@ def normalise_declarations(declared) def provider_option_owners = @provider_option_owners ||= ProviderOptionOwners.new end - option :provider, :deepl - option :cache, nil - option :cache_ttl, 604_800 - option :cache_namespace, "translation-diff" - option :cache_max_size, 1_000 - option :cache_table_name, "translation_diff_translations" - option :rate_limit_table_name, "translation_diff_rate_limits" - option :active_record_base, nil - option :cache_prune_probability, 0.0 - option :redis_url, -> { ENV.fetch("REDIS_URL", nil) } - option :redis_pool_size, 5 - option :redis_pool_timeout, 5 - option :rate_limit, nil - option :rate_interval, 60 - option :rate_limiter, nil - option :segmenter, :pragmatic + option :provider, :deepl, invalidates: :provider_instance + option :cache, nil, invalidates: :cache_store + option :cache_ttl, 604_800, invalidates: :cache_store + option :cache_namespace, "translation-diff", invalidates: :cache_store + option :cache_max_size, 1_000, invalidates: :cache_store + option :cache_table_name, "translation_diff_translations", invalidates: :cache_store + option :rate_limit_table_name, "translation_diff_rate_limits", invalidates: :rate_limiter_instance + option :active_record_base, nil, invalidates: :cache_store + option :cache_prune_probability, 0.0, invalidates: :cache_store + option :redis_url, -> { ENV.fetch("REDIS_URL", nil) }, + invalidates: %i[redis_pool cache_store rate_limiter_instance] + option :redis_pool_size, 5, invalidates: %i[redis_pool cache_store rate_limiter_instance] + option :redis_pool_timeout, 5, invalidates: %i[redis_pool cache_store rate_limiter_instance] + option :rate_limit, nil, invalidates: :rate_limiter_instance + option :rate_interval, 60, invalidates: :rate_limiter_instance + option :rate_limiter, nil, invalidates: :rate_limiter_instance + option :segmenter, :pragmatic, invalidates: :segmenter_instance option :opaque_elements, %i[script style pre code] option :instrumenter, nil option :logger, nil diff --git a/test/translation_diff/configuration_test.rb b/test/translation_diff/configuration_test.rb index 7b90f48..7e6b3ee 100644 --- a/test/translation_diff/configuration_test.rb +++ b/test/translation_diff/configuration_test.rb @@ -522,4 +522,89 @@ def test_copy_does_not_share_memoised_collaborators refute_same original_store, @config.copy.cache_store end + + # A real registered provider, to prove invalidation reaches provider_instance through a declared option too. + class DoubleProvider < TranslationDiff::Provider + def self.configuration_options = %i[double_provider_key] + + def translate(request) = TranslationDiff::Translation::Response.build(request: request, texts: request.texts) + end + + def test_changing_the_provider_rebuilds_the_memoised_instance + @config.provider = :null + first = @config.provider_instance + + @config.provider = :null + + refute_same first, @config.provider_instance + end + + def test_changing_an_option_a_provider_declared_rebuilds_the_provider_instance + TranslationDiff::Providers.register(:double_provider, DoubleProvider) + @config.provider = :double_provider + @config.double_provider_key = "first" + first = @config.provider_instance + + @config.double_provider_key = "second" + + refute_same first, @config.provider_instance + end + + def test_changing_the_logger_leaves_the_redis_pool_in_place + @config.redis_url = "redis://localhost:6379" + pool = @config.redis_pool + + @config.logger = Object.new + + assert_same pool, @config.redis_pool + end + + def test_changing_the_cache_namespace_rebuilds_the_cache_store + original = @config.cache_store + + @config.cache_namespace = "a-different-namespace" + + refute_same original, @config.cache_store + end + + def test_changing_the_redis_url_rebuilds_the_pool_the_store_and_the_rate_limiter + @config.redis_url = "redis://localhost:6379" + @config.rate_limit = 100 + pool = @config.redis_pool + store = @config.cache_store + limiter = @config.rate_limiter_instance + + @config.redis_url = "redis://localhost:6380" + + refute_same pool, @config.redis_pool + refute_same store, @config.cache_store + refute_same limiter, @config.rate_limiter_instance + end + + def test_changing_the_rate_limit_leaves_the_cache_store_in_place + store = @config.cache_store + + @config.rate_limit = 50 + + assert_same store, @config.cache_store + end + + def test_changing_the_segmenter_rebuilds_the_memoised_instance + first = @config.segmenter_instance + + @config.segmenter = :simple + + refute_same first, @config.segmenter_instance + end + + def test_an_unclassified_option_invalidates_nothing + @config.provider = :null + provider = @config.provider_instance + store = @config.cache_store + + @config.max_retries = 1 + + assert_same provider, @config.provider_instance + assert_same store, @config.cache_store + end end From ec4d71511298766eb60953a3828ed6904da47d08 Mon Sep 17 00:00:00 2001 From: IG Date: Fri, 11 Sep 2026 06:16:38 +0400 Subject: [PATCH 05/21] style: restore Metrics/ClassLength to 100 and extract Configuration's option table The previous change raised the global ClassLength ceiling from 100 to 110 just to let Configuration fit, which makes the limit meaningless for every other class. Put it back to 100. Configuration's bulk was a flat table of `option` declarations -- exactly the kind of thing that belongs in its own module rather than in the class that implements the behaviour. Moved it to TranslationDiff::Configuration::OptionTable, a plain [key, default, invalidates] table with one line per option, applied to the class with `OptionTable.declare_on(self)`. Configuration now fits under 100 lines without an exclude or a raised ceiling. --- .rubocop.yml | 3 +- lib/translation_diff/configuration.rb | 27 ++------------ .../configuration/option_table.rb | 35 +++++++++++++++++++ 3 files changed, 39 insertions(+), 26 deletions(-) create mode 100644 lib/translation_diff/configuration/option_table.rb diff --git a/.rubocop.yml b/.rubocop.yml index e0717d3..d26f34f 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -33,8 +33,7 @@ Metrics/ParameterLists: CountKeywordArgs: false Metrics/ClassLength: - # Configuration is a flat table of declared options; each one is a line, not a sign the class is doing too much. - Max: 110 + Max: 100 Exclude: # Test classes are mostly tables of cases. - test/**/* diff --git a/lib/translation_diff/configuration.rb b/lib/translation_diff/configuration.rb index e57b67b..f10b008 100644 --- a/lib/translation_diff/configuration.rb +++ b/lib/translation_diff/configuration.rb @@ -49,30 +49,9 @@ def normalise_declarations(declared) def provider_option_owners = @provider_option_owners ||= ProviderOptionOwners.new end - option :provider, :deepl, invalidates: :provider_instance - option :cache, nil, invalidates: :cache_store - option :cache_ttl, 604_800, invalidates: :cache_store - option :cache_namespace, "translation-diff", invalidates: :cache_store - option :cache_max_size, 1_000, invalidates: :cache_store - option :cache_table_name, "translation_diff_translations", invalidates: :cache_store - option :rate_limit_table_name, "translation_diff_rate_limits", invalidates: :rate_limiter_instance - option :active_record_base, nil, invalidates: :cache_store - option :cache_prune_probability, 0.0, invalidates: :cache_store - option :redis_url, -> { ENV.fetch("REDIS_URL", nil) }, - invalidates: %i[redis_pool cache_store rate_limiter_instance] - option :redis_pool_size, 5, invalidates: %i[redis_pool cache_store rate_limiter_instance] - option :redis_pool_timeout, 5, invalidates: %i[redis_pool cache_store rate_limiter_instance] - option :rate_limit, nil, invalidates: :rate_limiter_instance - option :rate_interval, 60, invalidates: :rate_limiter_instance - option :rate_limiter, nil, invalidates: :rate_limiter_instance - option :segmenter, :pragmatic, invalidates: :segmenter_instance - option :opaque_elements, %i[script style pre code] - option :instrumenter, nil - option :logger, nil - option :open_timeout, 5 - option :timeout, 30 - option :max_retries, 3 - option :validate_languages, true + # Required here, not centrally: the module it defines nests under this class, which must exist first. + require "translation_diff/configuration/option_table" + TranslationDiff::Configuration::OptionTable.declare_on(self) prepend TranslationDiff::CacheTtlOption prepend TranslationDiff::CacheGuardOptions diff --git a/lib/translation_diff/configuration/option_table.rb b/lib/translation_diff/configuration/option_table.rb new file mode 100644 index 0000000..10fd2f9 --- /dev/null +++ b/lib/translation_diff/configuration/option_table.rb @@ -0,0 +1,35 @@ +# The full option table: every setting Configuration exposes, its default, and what it invalidates. +# Lives apart from Configuration itself so the class that implements the behaviour isn't measured by +# a list that only grows -- this module is documentation as much as code, read it as a reference. +module TranslationDiff::Configuration::OptionTable + # [key, default, the memoised reader(s) it invalidates -- nil means it invalidates nothing] + TABLE = [ + [:provider, :deepl, :provider_instance], # rubocop:disable Style/SymbolArray -- stays [key, default, invalidates] + [:cache, nil, :cache_store], + [:cache_ttl, 604_800, :cache_store], + [:cache_namespace, "translation-diff", :cache_store], + [:cache_max_size, 1_000, :cache_store], + [:cache_table_name, "translation_diff_translations", :cache_store], + [:rate_limit_table_name, "translation_diff_rate_limits", :rate_limiter_instance], + [:active_record_base, nil, :cache_store], + [:cache_prune_probability, 0.0, :cache_store], + [:redis_url, -> { ENV.fetch("REDIS_URL", nil) }, %i[redis_pool cache_store rate_limiter_instance]], + [:redis_pool_size, 5, %i[redis_pool cache_store rate_limiter_instance]], + [:redis_pool_timeout, 5, %i[redis_pool cache_store rate_limiter_instance]], + [:rate_limit, nil, :rate_limiter_instance], + [:rate_interval, 60, :rate_limiter_instance], + [:rate_limiter, nil, :rate_limiter_instance], + [:segmenter, :pragmatic, :segmenter_instance], # rubocop:disable Style/SymbolArray + [:opaque_elements, %i[script style pre code], nil], + [:instrumenter, nil, nil], + [:logger, nil, nil], + [:open_timeout, 5, nil], + [:timeout, 30, nil], + [:max_retries, 3, nil], + [:validate_languages, true, nil] + ].freeze + + def self.declare_on(configuration_class) + TABLE.each { |key, default, invalidates| configuration_class.option(key, default, invalidates: invalidates) } + end +end From 3d7cdfd33479fa8295549dead98e0d4b2b4e6201 Mon Sep 17 00:00:00 2001 From: IG Date: Fri, 11 Sep 2026 06:22:42 +0400 Subject: [PATCH 06/21] feat: add TranslationDiff.preview to predict a translate call's cache hits Reuses translate's own Document/Passage segmenter, SentenceCache key, and provider resolution rather than reimplementing them, so it cannot drift from what translate actually does. Refuses cleanly when from: is nil and the provider would need a paid detection request to answer. --- lib/translation_diff.rb | 8 + lib/translation_diff/preview.rb | 2 + lib/translation_diff/previewer.rb | 117 ++++++++++++++ test/translation_diff/previewer_test.rb | 193 ++++++++++++++++++++++++ 4 files changed, 320 insertions(+) create mode 100644 lib/translation_diff/preview.rb create mode 100644 lib/translation_diff/previewer.rb create mode 100644 test/translation_diff/previewer_test.rb diff --git a/lib/translation_diff.rb b/lib/translation_diff.rb index ad5b0c3..52c5d84 100644 --- a/lib/translation_diff.rb +++ b/lib/translation_diff.rb @@ -59,6 +59,8 @@ require "translation_diff/instrumentation" require "translation_diff/dispatcher" require "translation_diff/translator" +require "translation_diff/preview" +require "translation_diff/previewer" require "translation_diff/context" # Only when a host application has already loaded Rails -- never required unconditionally, so a non-Rails @@ -82,5 +84,11 @@ def translate(values, from: nil, to: nil, provider: nil, assume_supported: false Translator.new(values, from: from, to: to, provider: provider, config: config, assume_supported: assume_supported, **).call end + + # Answers what `translate` would do to `values`, without calling a provider or writing anything. + def preview(values, from: nil, to: nil, provider: nil, assume_supported: false, **) + Previewer.new(values, from: from, to: to, provider: provider, config: config, + assume_supported: assume_supported, **).call + end end end diff --git a/lib/translation_diff/preview.rb b/lib/translation_diff/preview.rb new file mode 100644 index 0000000..1e82df4 --- /dev/null +++ b/lib/translation_diff/preview.rb @@ -0,0 +1,2 @@ +# What a #translate call would do without doing it: sentences it would send, sentences already cached, their size. +TranslationDiff::Preview = Data.define(:sendable_sentences, :cached_sentences, :sendable_characters) diff --git a/lib/translation_diff/previewer.rb b/lib/translation_diff/previewer.rb new file mode 100644 index 0000000..d37d696 --- /dev/null +++ b/lib/translation_diff/previewer.rb @@ -0,0 +1,117 @@ +# Answers what #translate would send and find cached, using the same segmenter, cache key and provider +# resolution translate uses -- without calling the provider or writing anything. Detection is a paid request +# this method never makes, so a nil `from:` for a provider that must detect the language is refused, not guessed at. +class TranslationDiff::Previewer + # Its own class, so rescuing a preview that cannot be answered cannot also swallow a cache or provider failure. + class Error < TranslationDiff::Error; end + + EMPTY = TranslationDiff::Preview.new(sendable_sentences: 0, cached_sentences: 0, sendable_characters: 0).freeze + + # `provider:`, `config:` and `assume_supported:` are reserved, exactly as they are for Translator#initialize. + def initialize(values, from: nil, to: nil, provider: nil, config: nil, assume_supported: false, **options) + raise ArgumentError, "a preview needs a target language: pass `to:` a language code." if to.nil? + + @values = values + @from = from + @to = to + @options = options + @config = config || TranslationDiff.config + @requested_provider = provider + @assume_supported = assume_supported + end + + def call + segments = document_segments + return EMPTY if segments.empty? || same_language?(@from) + + provider = resolve_provider + from = resolve_source_language(provider) + return EMPTY if same_language?(from) + + preview_for(provider, from, segments) + end + + private + + def document_segments + document = TranslationDiff::Document.new(TranslationDiff::Leaves.collapse_nils(@values)) + document.strings.flat_map { |string| passage(string).segments }.reject(&:empty?) + end + + def passage(string) + TranslationDiff::Passage.new(string, segmenter: @config.segmenter_instance, language: @from) + end + + # A detected language arrives as a String while `to:` is usually a Symbol, so neither type nor case can be assumed. + def same_language?(from) = from.to_s.casecmp?(@to.to_s) + + # Mirrors Translator#build_provider: a name to build, an object to use as it is, or the configured one. + def resolve_provider + provider = built_provider + ensure_cache_key!(provider) + provider + end + + def built_provider + requested = @requested_provider + return @config.provider_instance if requested.nil? + return TranslationDiff::Providers.build(requested, @config) if requested.is_a?(Symbol) || requested.is_a?(String) + + TranslationDiff::Providers.ensure_provider!(requested) + end + + # The cache key names the provider in the cache key too: it is the one identifier every provider must have. + def ensure_cache_key!(provider) + return unless provider.cache_key.to_s.strip.empty? + + raise Error, "#{provider.class} must define #cache_key: a blank one would file its " \ + "translations in every other provider's cache namespace." + end + + # `from:` given means the pair is already known, so it is validated once; `from:` nil needs a detection this + # method never pays for, so it stops here instead of guessing what a paid request would have answered. + def resolve_source_language(provider) + return @from.tap { |from| ensure_supported!(provider, from) } unless @from.nil? + + ensure_supported!(provider, nil) + raise_undetectable!(provider) + end + + def raise_undetectable!(provider) + unless provider.class.capabilities.detects_language? + raise Error, "Provider #{provider.cache_key} cannot detect the source language. Pass `from:` with the " \ + "source language code of the values you are previewing." + end + + raise Error, "TranslationDiff.preview cannot detect the source language for #{provider.cache_key} " \ + "without a paid request: pass `from:` explicitly." + end + + # nil means we ship no data for this provider, and silence is not evidence of absence. + def ensure_supported!(provider, from) + return if @assume_supported || !@config.validate_languages + + supported = TranslationDiff::Languages.supports?(provider.cache_key, from: from, to: @to) + return if supported.nil? || supported + + raise TranslationDiff::UnsupportedLanguageError, + "Provider #{provider.cache_key} does not translate #{pair_description(from)}. If it does " \ + "now, pass `assume_supported: true` for this call, or set " \ + "`config.validate_languages = false`, and run `rake languages:refresh`." + end + + def pair_description(from) = from.nil? ? "to #{@to}" : "#{from} to #{@to}" + + def preview_for(provider, from, segments) + misses = fill(provider, from, segments) + TranslationDiff::Preview.new(sendable_sentences: misses.size, cached_sentences: segments.size - misses.size, + sendable_characters: misses.sum { |segment| segment.core.size }) + end + + # Reads the store through the same SentenceCache#fill translate uses; nothing here ever calls #store. + def fill(provider, from, segments) + cache = TranslationDiff::SentenceCache.new(store: @config.cache_store, provider: provider.cache_key, + from: from, to: @to, options: @options) + cache.fill(segments) + end +end diff --git a/test/translation_diff/previewer_test.rb b/test/translation_diff/previewer_test.rb new file mode 100644 index 0000000..08ef492 --- /dev/null +++ b/test/translation_diff/previewer_test.rb @@ -0,0 +1,193 @@ +require "test_helper" + +class PreviewerTest < ConfiguredTest + class RecordingProvider < TranslationDiff::Provider + def self.capabilities + TranslationDiff::Capabilities.new( + max_request_size: 1_000, max_batch_size: 10, max_text_size: nil, + html: :none, notranslate: false, detects_language: true, reports_billing: false + ) + end + + attr_reader :requests + + def initialize(config) + super + @requests = [] + end + + def translate(request) + @requests << request + TranslationDiff::Translation::Response.build(request: request, texts: request.texts.map(&:upcase)) + end + + def detect(_text) = "en" + def cache_key = "recording" + end + + # The capability is the only honest test: every provider inherits a #detect that raises. + class BlindProvider < RecordingProvider + def self.capabilities + TranslationDiff::Capabilities.new( + max_request_size: 1_000, max_batch_size: 10, max_text_size: nil, + html: :none, notranslate: false, detects_language: false, reports_billing: false + ) + end + + def cache_key = "blind" + end + + # Counts every write the pipeline attempts, whichever contract it uses, so "writes nothing" has real evidence. + class WriteTrackingStore + attr_reader :write_calls + + def initialize + @inner = TranslationDiff::MemoryCacheStore.new(max_size: 100) + @write_calls = 0 + end + + def read_multi(keys) = @inner.read_multi(keys) + + def write_multi(pairs) + @write_calls += 1 + @inner.write_multi(pairs) + end + end + + class Recorder + attr_reader :events + + def initialize = @events = [] + + def instrument(name, payload) + @events << [name, payload] + yield if block_given? + end + end + + def setup + super + @provider = RecordingProvider.new(TranslationDiff::Configuration.new) + TranslationDiff.configure { |c| c.cache = :memory } + end + + def preview(values, **) + TranslationDiff.preview(values, provider: @provider, **) + end + + def test_a_document_nothing_has_cached_counts_every_sentence_as_sendable + result = preview("One. Two.", from: "en", to: "ru") + + assert_equal 2, result.sendable_sentences + assert_equal 0, result.cached_sentences + assert_equal "One.Two.".size, result.sendable_characters + end + + def test_after_translating_nothing_is_left_to_send + TranslationDiff.translate("One. Two.", from: "en", to: "ru", provider: @provider) + + result = preview("One. Two.", from: "en", to: "ru") + + assert_equal 0, result.sendable_sentences + assert_equal 2, result.cached_sentences + assert_equal 0, result.sendable_characters + end + + # This is the property the whole thing exists for: an edit to one sentence sends exactly that sentence. + def test_editing_one_sentence_sends_exactly_that_sentence + TranslationDiff.translate("One. Two.", from: "en", to: "ru", provider: @provider) + + result = preview("One. Three.", from: "en", to: "ru") + + assert_equal 1, result.sendable_sentences + assert_equal 1, result.cached_sentences + assert_equal "Three.".size, result.sendable_characters + end + + # The strongest test available: it pins the preview to the pipeline, not to an idea of the pipeline. + def test_the_previews_counts_equal_what_translate_then_reports_through_the_cache_event + TranslationDiff.translate("One. Two.", from: "en", to: "ru", provider: @provider) + predicted = preview("One. Three.", from: "en", to: "ru") + + recorder = Recorder.new + TranslationDiff.configure { |c| c.instrumenter = recorder } + TranslationDiff.translate("One. Three.", from: "en", to: "ru", provider: @provider) + payload = recorder.events.find { |name, _| name == "cache.translation_diff" }.last + + assert_equal predicted.sendable_sentences, payload[:misses] + assert_equal predicted.cached_sentences, payload[:hits] + end + + def test_an_explicit_provider_and_the_configured_one_agree + TranslationDiff.configure { |c| c.provider = @provider } + + from_config = TranslationDiff.preview("One. Two.", from: "en", to: "ru") + explicit = TranslationDiff.preview("One. Two.", from: "en", to: "ru", provider: @provider) + + assert_equal from_config, explicit + end + + def test_nothing_is_written + tracker = WriteTrackingStore.new + TranslationDiff.configure { |c| c.cache = tracker } + TranslationDiff.translate("One. Two.", from: "en", to: "ru", provider: @provider) + writes_after_translate = tracker.write_calls + + preview("One. Two.", from: "en", to: "ru") + preview("One. Three.", from: "en", to: "ru") + + assert_equal writes_after_translate, tracker.write_calls + end + + def test_the_same_language_needs_no_provider_and_sends_nothing + result = TranslationDiff.preview("One.", from: "en", to: "en", provider: Object.new) + + assert_equal 0, result.sendable_sentences + end + + def test_a_value_with_nothing_to_preview_resolves_no_provider + result = TranslationDiff.preview("", from: "en", to: "ru", provider: Object.new) + + assert_equal 0, result.sendable_sentences + end + + # Detection is a paid request; a preview never makes one, so it says so instead of guessing. + def test_a_missing_source_language_is_refused_when_the_provider_would_have_to_detect_it + error = assert_raises(TranslationDiff::Previewer::Error) { preview("One.", to: "ru") } + + assert_match(/paid request/, error.message) + assert_match(/from:/, error.message) + end + + def test_a_provider_that_cannot_detect_says_so_before_it_is_called + @provider = BlindProvider.new(TranslationDiff::Configuration.new) + + error = assert_raises(TranslationDiff::Previewer::Error) { preview("One.", to: "ru") } + + assert_match(/cannot detect/, error.message) + end + + def test_a_missing_target_language_is_refused_by_name + error = assert_raises(ArgumentError) { preview("One.", from: "en") } + + assert_match(/to:/, error.message) + end + + def test_per_call_options_change_the_cache_key_the_same_way_translate_does + TranslationDiff.translate("One.", from: "en", to: "ru", provider: @provider, formality: :less) + + default_options = preview("One.", from: "en", to: "ru") + same_options = preview("One.", from: "en", to: "ru", formality: :less) + + assert_equal 1, default_options.sendable_sentences + assert_equal 0, same_options.sendable_sentences + end + + def test_no_preview_error_message_carries_the_text_being_previewed + secret = "Zaphod Beeblebrox is president." + + error = assert_raises(TranslationDiff::Previewer::Error) { preview(secret, to: "ru") } + + refute_includes error.message, secret + end +end From cc7ca90ba02c2f0e36c91197563e785df9b458f8 Mon Sep 17 00:00:00 2001 From: IG Date: Fri, 11 Sep 2026 06:26:57 +0400 Subject: [PATCH 07/21] refactor: share provider resolution between Translator and Previewer Previewer had copied Translator's private provider-resolution branch verbatim because it was private, leaving two copies of "which provider does this call use" free to drift. Move it to TranslationDiff::Providers, which already owns registration and the build/ensure_provider! guards, as TranslationDiff::Providers.resolve -- the cache_key guard travels with it, since a blank cache_key is a provider-validity failure, not a translator- or previewer-specific one. Its raised error changes from Translator::Error/Previewer::Error to the more precise InvalidProviderError. This also drops Translator under Metrics/ClassLength's 100-line limit (104 -> under 100). --- lib/translation_diff/previewer.rb | 24 ++----------------- lib/translation_diff/providers.rb | 24 +++++++++++++++++++ lib/translation_diff/translator.rb | 22 +---------------- test/translation_diff/previewer_test.rb | 15 ++++++++++++ test/translation_diff/providers_test.rb | 30 ++++++++++++++++++++++++ test/translation_diff/translator_test.rb | 4 +++- 6 files changed, 75 insertions(+), 44 deletions(-) diff --git a/lib/translation_diff/previewer.rb b/lib/translation_diff/previewer.rb index d37d696..293f1c8 100644 --- a/lib/translation_diff/previewer.rb +++ b/lib/translation_diff/previewer.rb @@ -45,28 +45,8 @@ def passage(string) # A detected language arrives as a String while `to:` is usually a Symbol, so neither type nor case can be assumed. def same_language?(from) = from.to_s.casecmp?(@to.to_s) - # Mirrors Translator#build_provider: a name to build, an object to use as it is, or the configured one. - def resolve_provider - provider = built_provider - ensure_cache_key!(provider) - provider - end - - def built_provider - requested = @requested_provider - return @config.provider_instance if requested.nil? - return TranslationDiff::Providers.build(requested, @config) if requested.is_a?(Symbol) || requested.is_a?(String) - - TranslationDiff::Providers.ensure_provider!(requested) - end - - # The cache key names the provider in the cache key too: it is the one identifier every provider must have. - def ensure_cache_key!(provider) - return unless provider.cache_key.to_s.strip.empty? - - raise Error, "#{provider.class} must define #cache_key: a blank one would file its " \ - "translations in every other provider's cache namespace." - end + # Same resolution Translator#call uses: a name to build, an object to use as it is, or the configured one. + def resolve_provider = TranslationDiff::Providers.resolve(@requested_provider, @config) # `from:` given means the pair is already known, so it is validated once; `from:` nil needs a detection this # method never pays for, so it stops here instead of guessing what a paid request would have answered. diff --git a/lib/translation_diff/providers.rb b/lib/translation_diff/providers.rb index 2ec23d8..bba841d 100644 --- a/lib/translation_diff/providers.rb +++ b/lib/translation_diff/providers.rb @@ -34,6 +34,14 @@ def build(name, config) registry.build(name, config).tap { |provider| provider.name = name.to_sym } end + # The one seam Translator and Previewer both need: a provider by name, by object, or -- given nothing -- + # the configured one. The cache_key guard travels with it: a provider that skips it would file its + # translations in every other provider's namespace, which is the one failure this method exists to prevent. + def resolve(requested, config) + provider = requested.nil? ? config.provider_instance : resolve_requested(requested, config) + ensure_cache_key!(provider) + end + def registered?(name) = registry.registered?(name) def names = registry.names def classes = registry.classes @@ -41,6 +49,22 @@ def registry = @registry ||= TranslationDiff::Registry.new("provider") private + # A provider arrives as a name to build, or as an object to use as it is. + def resolve_requested(requested, config) + return build(requested, config) if requested.is_a?(Symbol) || requested.is_a?(String) + + ensure_provider!(requested) + end + + # The cache key names the provider in every payload too: it is the one identifier every provider must have. + def ensure_cache_key!(provider) + return provider unless provider.cache_key.to_s.strip.empty? + + raise TranslationDiff::InvalidProviderError, + "#{provider.class} must define #cache_key: a blank one would file its " \ + "translations in every other provider's cache namespace." + end + # Never #to_s on a non-Module: an arbitrary object renders its own content, or an address. def describe(value) = value.is_a?(Module) ? value.to_s : "an instance of #{value.class}" end diff --git a/lib/translation_diff/translator.rb b/lib/translation_diff/translator.rb index 247206a..181db52 100644 --- a/lib/translation_diff/translator.rb +++ b/lib/translation_diff/translator.rb @@ -79,27 +79,7 @@ def call_id = @call_id ||= SecureRandom.hex(6) # Resolved at first use, never in the constructor: a value with nothing to translate needs no provider at all. def resolve_provider - build_provider.tap do |provider| - log("provider #{provider.class}") - ensure_cache_key!(provider) - end - end - - # A provider arrives as a name to build, as an object to use as it is, or not at all -- then it is the configured one. - def build_provider - requested = @requested_provider - return config.provider_instance if requested.nil? - return TranslationDiff::Providers.build(requested, config) if requested.is_a?(Symbol) || requested.is_a?(String) - - TranslationDiff::Providers.ensure_provider!(requested) - end - - # The cache key names the provider in every payload too: it is the one identifier every provider must have. - def ensure_cache_key!(provider) - return unless provider.cache_key.to_s.strip.empty? - - raise Error, "#{provider.class} must define #cache_key: a blank one would file its " \ - "translations in every other provider's cache namespace." + TranslationDiff::Providers.resolve(@requested_provider, config).tap { |provider| log("provider #{provider.class}") } end def passage(string) diff --git a/test/translation_diff/previewer_test.rb b/test/translation_diff/previewer_test.rb index 08ef492..d81a511 100644 --- a/test/translation_diff/previewer_test.rb +++ b/test/translation_diff/previewer_test.rb @@ -37,6 +37,11 @@ def self.capabilities def cache_key = "blind" end + # An empty cache key would file this provider's translations in every other provider's namespace. + class NamelessProvider < RecordingProvider + def cache_key = " " + end + # Counts every write the pipeline attempts, whichever contract it uses, so "writes nothing" has real evidence. class WriteTrackingStore attr_reader :write_calls @@ -183,6 +188,16 @@ def test_per_call_options_change_the_cache_key_the_same_way_translate_does assert_equal 0, same_options.sendable_sentences end + # Same guard Translator uses, shared through TranslationDiff::Providers.resolve: a preview that read the + # wrong namespace would lie about the cache, so a blank cache_key is refused here too, and as the same error. + def test_a_provider_whose_cache_key_is_blank_is_refused_rather_than_lying_about_the_cache + @provider = NamelessProvider.new(TranslationDiff::Configuration.new) + + error = assert_raises(TranslationDiff::InvalidProviderError) { preview("One.", from: "en", to: "ru") } + + assert_match(/must define #cache_key/, error.message) + end + def test_no_preview_error_message_carries_the_text_being_previewed secret = "Zaphod Beeblebrox is president." diff --git a/test/translation_diff/providers_test.rb b/test/translation_diff/providers_test.rb index 64e2c27..549136b 100644 --- a/test/translation_diff/providers_test.rb +++ b/test/translation_diff/providers_test.rb @@ -76,6 +76,36 @@ def test_null_keeps_its_own_cache_key assert_equal "null", TranslationDiff::Providers.build(:null, @config).cache_key end + # The one seam Translator and Previewer both resolve a provider through. + def test_resolve_with_nil_returns_the_configured_provider + @config.provider = :acme + + assert_instance_of AcmeProvider, TranslationDiff::Providers.resolve(nil, @config) + end + + def test_resolve_with_a_name_builds_that_provider + assert_instance_of AcmeProvider, TranslationDiff::Providers.resolve(:acme, @config) + end + + def test_resolve_with_an_object_uses_it_as_is + instance = TranslationDiff::Providers.build(:acme, @config) + + assert_same instance, TranslationDiff::Providers.resolve(instance, @config) + end + + # A blank cache_key would file a provider's translations in every other provider's namespace. + class BlankCacheKeyProvider < TranslationDiff::Provider + def cache_key = " " + end + + def test_resolve_refuses_a_provider_whose_cache_key_is_blank + instance = BlankCacheKeyProvider.new(@config) + + error = assert_raises(TranslationDiff::InvalidProviderError) { TranslationDiff::Providers.resolve(instance, @config) } + + assert_match(/must define #cache_key/, error.message) + end + # A defensive double `require` and a Rails reload both re-run registration; redeclaring must stay silent. def test_registering_the_same_provider_twice_is_not_a_conflict TranslationDiff::Providers.register(:acme, AcmeProvider) diff --git a/test/translation_diff/translator_test.rb b/test/translation_diff/translator_test.rb index 90487ca..05d4c8e 100644 --- a/test/translation_diff/translator_test.rb +++ b/test/translation_diff/translator_test.rb @@ -153,10 +153,12 @@ def test_a_provider_that_cannot_detect_says_so_before_it_is_called assert_empty @provider.requests end + # Provider resolution, and this guard with it, now lives in TranslationDiff::Providers: Translator and + # Previewer share it, so a blank cache_key is refused the same way -- and as the same error -- for both. def test_a_provider_whose_cache_key_is_blank_is_refused_rather_than_sharing_a_namespace @provider = NamelessProvider.new(TranslationDiff::Configuration.new) - error = assert_raises(TranslationDiff::Translator::Error) { translate("one.", from: "en", to: "ru") } + error = assert_raises(TranslationDiff::InvalidProviderError) { translate("one.", from: "en", to: "ru") } assert_match(/must define #cache_key/, error.message) end From 7839d71df9eb7bc232b3243a33a4aa21990f95d8 Mon Sep 17 00:00:00 2001 From: IG Date: Fri, 11 Sep 2026 06:29:39 +0400 Subject: [PATCH 08/21] style: trim provider-resolution comments to one line Comments in this codebase stay at most one line; the extraction commit left a few spanning two or three. --- lib/translation_diff/providers.rb | 4 +--- test/translation_diff/previewer_test.rb | 3 +-- test/translation_diff/translator_test.rb | 3 +-- 3 files changed, 3 insertions(+), 7 deletions(-) diff --git a/lib/translation_diff/providers.rb b/lib/translation_diff/providers.rb index bba841d..32bf6e7 100644 --- a/lib/translation_diff/providers.rb +++ b/lib/translation_diff/providers.rb @@ -34,9 +34,7 @@ def build(name, config) registry.build(name, config).tap { |provider| provider.name = name.to_sym } end - # The one seam Translator and Previewer both need: a provider by name, by object, or -- given nothing -- - # the configured one. The cache_key guard travels with it: a provider that skips it would file its - # translations in every other provider's namespace, which is the one failure this method exists to prevent. + # The one seam Translator and Previewer both resolve a provider through, cache_key guard included. def resolve(requested, config) provider = requested.nil? ? config.provider_instance : resolve_requested(requested, config) ensure_cache_key!(provider) diff --git a/test/translation_diff/previewer_test.rb b/test/translation_diff/previewer_test.rb index d81a511..98a4948 100644 --- a/test/translation_diff/previewer_test.rb +++ b/test/translation_diff/previewer_test.rb @@ -188,8 +188,7 @@ def test_per_call_options_change_the_cache_key_the_same_way_translate_does assert_equal 0, same_options.sendable_sentences end - # Same guard Translator uses, shared through TranslationDiff::Providers.resolve: a preview that read the - # wrong namespace would lie about the cache, so a blank cache_key is refused here too, and as the same error. + # Same guard Translator uses, shared through TranslationDiff::Providers.resolve. def test_a_provider_whose_cache_key_is_blank_is_refused_rather_than_lying_about_the_cache @provider = NamelessProvider.new(TranslationDiff::Configuration.new) diff --git a/test/translation_diff/translator_test.rb b/test/translation_diff/translator_test.rb index 05d4c8e..1c17a84 100644 --- a/test/translation_diff/translator_test.rb +++ b/test/translation_diff/translator_test.rb @@ -153,8 +153,7 @@ def test_a_provider_that_cannot_detect_says_so_before_it_is_called assert_empty @provider.requests end - # Provider resolution, and this guard with it, now lives in TranslationDiff::Providers: Translator and - # Previewer share it, so a blank cache_key is refused the same way -- and as the same error -- for both. + # Provider resolution, and this guard with it, now lives in TranslationDiff::Providers. def test_a_provider_whose_cache_key_is_blank_is_refused_rather_than_sharing_a_namespace @provider = NamelessProvider.new(TranslationDiff::Configuration.new) From ba16dc041b1479316946628cc8e2ba35c2ae4967 Mon Sep 17 00:00:00 2001 From: IG Date: Fri, 11 Sep 2026 06:29:45 +0400 Subject: [PATCH 09/21] fix: invalidate the rate limiter when cache_namespace changes RedisRateLimiter.build and ActiveRecordRateLimiter.build both take their namespace from config.cache_namespace, but the per-option invalidation only mapped cache_namespace to cache_store. Changing the namespace at runtime left a memoised rate_limiter_instance counting under the old one, silently, and only in the Redis case (the ActiveRecord limiter reads the DB row's namespace column fresh on every check). The mapping was wrong, not the limiter's choice of option: the limiter reading cache_namespace is the existing, intended behaviour (see Configuration#copy's own comment on it), so cache_namespace now also invalidates rate_limiter_instance. This is a behaviour change for anyone who set cache_namespace after first touching the limiter, expecting the old (buggy) memoised instance to stick: it now moves too. --- lib/translation_diff/configuration/option_table.rb | 3 ++- test/translation_diff/configuration_test.rb | 10 ++++++++++ test/translation_diff/redis_rate_limiter_test.rb | 14 ++++++++++++++ 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/lib/translation_diff/configuration/option_table.rb b/lib/translation_diff/configuration/option_table.rb index 10fd2f9..88581fd 100644 --- a/lib/translation_diff/configuration/option_table.rb +++ b/lib/translation_diff/configuration/option_table.rb @@ -7,7 +7,8 @@ module TranslationDiff::Configuration::OptionTable [:provider, :deepl, :provider_instance], # rubocop:disable Style/SymbolArray -- stays [key, default, invalidates] [:cache, nil, :cache_store], [:cache_ttl, 604_800, :cache_store], - [:cache_namespace, "translation-diff", :cache_store], + # Also the rate limiter's own namespace (RedisRateLimiter, ActiveRecordRateLimiter both read it). + [:cache_namespace, "translation-diff", %i[cache_store rate_limiter_instance]], [:cache_max_size, 1_000, :cache_store], [:cache_table_name, "translation_diff_translations", :cache_store], [:rate_limit_table_name, "translation_diff_rate_limits", :rate_limiter_instance], diff --git a/test/translation_diff/configuration_test.rb b/test/translation_diff/configuration_test.rb index 7e6b3ee..cf23e9e 100644 --- a/test/translation_diff/configuration_test.rb +++ b/test/translation_diff/configuration_test.rb @@ -567,6 +567,16 @@ def test_changing_the_cache_namespace_rebuilds_the_cache_store refute_same original, @config.cache_store end + # cache_namespace also names the rate limiter's own namespace, so it must move that limiter too. + def test_changing_the_cache_namespace_rebuilds_the_rate_limiter + @config.rate_limit = 100 + limiter = @config.rate_limiter_instance + + @config.cache_namespace = "a-different-namespace" + + refute_same limiter, @config.rate_limiter_instance + end + def test_changing_the_redis_url_rebuilds_the_pool_the_store_and_the_rate_limiter @config.redis_url = "redis://localhost:6379" @config.rate_limit = 100 diff --git a/test/translation_diff/redis_rate_limiter_test.rb b/test/translation_diff/redis_rate_limiter_test.rb index 56acc7c..34ed58f 100644 --- a/test/translation_diff/redis_rate_limiter_test.rb +++ b/test/translation_diff/redis_rate_limiter_test.rb @@ -150,6 +150,20 @@ def test_build_falls_back_to_the_default_threshold_when_rate_limit_is_unset built.check(1) end + # The settings screen that found this: changing cache_namespace must move the limiter, not just the cache. + def test_changing_the_cache_namespace_moves_the_limiter_to_the_new_redis_namespace + server = FakeRedisServer.new + config = TranslationDiff::Configuration.new + config.rate_limit = 100 + config.instance_variable_set(:@redis_pool, FakeConnectionPool.new(server)) + + config.rate_limiter_instance.check(10) + config.cache_namespace = "tenant-42" + config.rate_limiter_instance.check(7) + + assert_equal({ "ratelimit:translation-diff:call" => 10, "ratelimit:tenant-42:call" => 7 }, server.totals) + end + private def limiter(server, **) From 591189bfdef1e12a1f0caa288f46f5c57b9aa7de Mon Sep 17 00:00:00 2001 From: IG Date: Fri, 11 Sep 2026 06:37:13 +0400 Subject: [PATCH 10/21] docs: document call_id, characters, opaque pre/code, selective invalidation and preview Five behaviours from building a real Rails + Hotwire app on this gem, verified against the code and (where practical) live providers: call_id on every event, translate's own characters, pre/code joining the opaque element set, options invalidating only the collaborator they feed, and TranslationDiff.preview. --- CHANGELOG.md | 60 +++++++++++++++++++++++++++++++++++++++++ docs/caching.md | 34 +++++++++++++++++++++++ docs/configuration.md | 49 ++++++++++++++++++++++++--------- docs/how-it-works.md | 33 ++++++++++++++--------- docs/instrumentation.md | 28 ++++++++++++++----- 5 files changed, 173 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f17ceac..6b24d0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,60 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added +- **Every event from one `translate` call now shares a `call_id`.** Generated + once per call, opaque, and never derived from the text, it lands in + `translate`, `cache`, `request`, `rate_limit`, `usage` and `cache_error` + alike. Before it, a subscriber receiving `cache` or `request` events had no + way to tell which `translate` call they belonged to, short of tagging + `Thread.current` itself -- a workaround that breaks the moment two + translations share a thread. See + [Instrumentation](docs/instrumentation.md). + +- **`translate` now carries `characters`: the total this call considered, + hit or miss.** A call served entirely from cache never fires a `request` + event and used to report nothing about its size; it now reports a number + there instead. `request`'s own `characters` keeps its narrower meaning -- + what one batch actually sent -- so the two fields share a name but not an + event: summing the wrong one produces a wrong bill. See + [Instrumentation](docs/instrumentation.md). + +- **`TranslationDiff.preview` predicts a `translate` call without making + it.** It answers how many sentences a call would send, how many the cache + already has, and how many characters that is -- without calling a + provider and without writing anything. A preview never pays for language + detection, so `from:` is required wherever there is anything to preview; + leaving it unset raises `TranslationDiff::Previewer::Error`. Built for an + editor that wants to show "this edit will send 1 sentence" before the + author saves. See + [Caching](docs/caching.md#asking-what-a-call-would-do-without-doing-it). + +- **`pre` and `code` are no longer sent for translation, and changing a + configuration option at runtime now rebuilds only what it actually + feeds.** `pre` and `code` join `script` and `style` in + `config.opaque_elements`, the set `TranslationDiff::Passage` never treats + as prose -- default `%i[script style pre code]`, widen or narrow it as + needed -- after a live `
` block came back from Google with
+  `jq '.meters'` mangled into `jq '.metros'`, because nothing told the
+  pipeline that code holds language, not prose. Separately, `provider` and
+  the cache, pool, rate and segmenter options each rebuild only their own
+  collaborator now, instead of nothing: before this, switching `provider`
+  at runtime meant `TranslationDiff.reset!` and reconfiguring from scratch,
+  discarding a Redis pool that had no reason to go. See
+  [Configuration](docs/configuration.md#changing-configuration-at-runtime).
+  Two consequences to check before you upgrade:
+  - **Cache keys change for any document containing a `pre` or `code`
+    element.** What gets sent to the provider changed, so what gets keyed
+    changed too; an entry cached under the old behaviour keeps serving what
+    the old behaviour produced. Give the configuration a new
+    `cache_namespace`, or let `cache_ttl` lapse, to get every such document
+    retranslated. See
+    [Caching](docs/caching.md#what-a-cache-key-is-made-of).
+  - **A runtime `cache_namespace` change now moves the rate limiter too.**
+    `cache_namespace` names the limiter's own bookkeeping namespace as well
+    as the cache store's; it used to move only the store, leaving the
+    limiter counting silently under the old namespace. See
+    [Configuration](docs/configuration.md#changing-configuration-at-runtime).
+
 - A `usage` instrumentation event, firing once per provider request, beside
   `translate`, `cache`, `request` and `rate_limit`. Its payload carries
   `provider`, `characters` (what this library sent, counted locally),
@@ -143,6 +197,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
 
 ### Fixed
 
+- **A `notranslate` span nested inside an opaque element (`pre`, `code`,
+  `script` or `style`) no longer silences every sentence after it.**
+  Closing the protected span used to leave the scanner's own opacity depth
+  one too high, so nothing past it was ever handed to the segmenter again.
+  Found while adding the `pre`/`code` opaque elements above, and fixed the
+  same way for all four. See [How it works](docs/how-it-works.md#html).
 - **Google and DeepL translations in HTML mode no longer come back
   double-escaped.** Both vendors return entity-escaped text -- an
   apostrophe as `'`, a quote as `"`, an ampersand as `&` -- and
diff --git a/docs/caching.md b/docs/caching.md
index 5834f64..f702fbe 100644
--- a/docs/caching.md
+++ b/docs/caching.md
@@ -58,6 +58,40 @@ you nothing.
 No options at all contributes no field to the key, which is the four-field
 key every already-warm cache is keyed on.
 
+## Asking what a call would do, without doing it
+
+`TranslationDiff.preview` answers what a `translate` call would send and
+find cached, without calling a provider and without writing anything: how
+many sentences it would send, how many the cache already has, and how many
+characters that is. It reads the same store, through the same
+`SentenceCache`, keyed the same way -- see [What a cache key is made
+of](#what-a-cache-key-is-made-of) above -- so a preview and the call it
+predicts always agree.
+
+```ruby
+preview = TranslationDiff.preview(article_body, from: "en", to: "es")
+preview.sendable_sentences   # => 1, not yet cached
+preview.cached_sentences     # => 4, already cached
+preview.sendable_characters  # => 23
+```
+
+**`from:` is required wherever there is anything to preview.** `translate`
+can leave `from:` unset and pay for one `#detect` request to find it; a
+preview never calls the provider, so it cannot pay for that request either.
+Passing `to:` alone raises `TranslationDiff::Previewer::Error`, naming the
+provider and telling you to pass `from:` explicitly -- unless the document
+holds nothing translatable, or the source and target already match, in
+which case there is nothing to preview and an empty result comes back
+regardless of `from:`.
+
+This is the supported way to ask an editor's question before it becomes a
+bill -- show "this edit will send 1 sentence" before the author saves:
+
+```ruby
+preview = TranslationDiff.preview(edited_body, from: "en", to: "es")
+"This edit will send #{preview.sendable_sentences} sentence#{'s' unless preview.sendable_sentences == 1}."
+```
+
 ## The cache store contract
 
 `config.cache` accepts either a registered name (`:redis`, `:memory`,
diff --git a/docs/configuration.md b/docs/configuration.md
index bca53cf..ceecf5f 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -69,6 +69,7 @@ at all, so an unset environment variable never has to be special-cased.
 | `rate_interval` | `60` | Seconds over which `rate_limit` (or a limiter's own default threshold) is measured. **Actually enforced over roughly 5-600 seconds** -- see [The rate limiter contract](contracts.md#the-rate-limiter-contract). |
 | `rate_limiter` | `nil` | A registered name (`:redis`, `:active_record`) or an object satisfying the [rate limiter contract](contracts.md#the-rate-limiter-contract). `nil` with `rate_limit` also `nil` means no rate limiting; `nil` with `rate_limit` set resolves to `:redis`. Setting `rate_limiter` alone -- with `rate_limit` left unset -- is enough to turn rate limiting on, at the limiter's own default threshold; it no longer needs `rate_limit` set to avoid crashing. |
 | `segmenter` | `:pragmatic` | The sentence segmenter: a registered name or an object satisfying the [segmenter contract](contracts.md#the-segmenter-contract). |
+| `opaque_elements` | `%i[script style pre code]` | Element names `TranslationDiff::Passage` never treats as prose, whatever they contain. Read fresh on every passage rather than memoised, so a runtime change applies immediately, to the next translation. See [How it works](how-it-works.md#html). |
 | `instrumenter` | `nil` | Anything satisfying `ActiveSupport::Notifications`' `#instrument(name, payload) { }` interface. See [Instrumentation and logging](instrumentation.md). |
 | `logger` | `nil` | A standard `Logger` -- anything answering to `debug` and `warn` with a block. Receives one `debug` line per provider resolution, naming the provider class, and a `warn` line when a cache write fails; never content and never a credential. Note that `warn` must be a public method: a bare object inherits a private `Kernel#warn` and would raise instead of logging. See [Instrumentation and logging](instrumentation.md). |
 | `open_timeout` | `5` | Seconds an HTTP-backed provider waits to open a connection before raising `TranslationDiff::TransportError`. |
@@ -102,18 +103,42 @@ Every provider declares its own configuration options, registered the moment
 A provider you register yourself can declare its own options the same way --
 see [Writing a provider](providers.md#writing-a-provider) below.
 
-## Configure once, before the first translation
-
-**Configure once, before the first translation.** `provider`, `cache`,
-`segmenter` and `rate_limiter` each resolve to a collaborator on first use
-and that collaborator is memoised for the life of the configuration. Options
-stay writable afterwards, but changing one no longer reaches an object that
-has already been built: setting `cache_max_size` after something has
-translated leaves the store built with the old bound in place, and
-reassigning `provider` after a translation has run does not change the
-provider that configuration uses. `TranslationDiff.context` -- or
-`config.copy`, which it is built on -- is the way to get a configuration that
-resolves everything afresh from its own values.
+## Changing configuration at runtime
+
+`provider`, `cache`, `segmenter` and `rate_limiter` each resolve to a
+collaborator on first use, and that collaborator is memoised. Writing an
+option afterwards rebuilds only the memoised collaborator(s) that option
+actually feeds, not the whole configuration:
+
+- `provider`, and any option a provider declares for itself (`deepl_api_key`
+  and the like), rebuild the provider.
+- The cache options (`cache`, `cache_ttl`, `cache_max_size`,
+  `cache_table_name`, `active_record_base`, `cache_prune_probability`,
+  `cache_namespace`) rebuild the cache store.
+- `redis_url`, `redis_pool_size` and `redis_pool_timeout` rebuild the
+  connection pool and everything holding it -- the cache store and the rate
+  limiter both.
+- The rate options (`rate_limit`, `rate_interval`, `rate_limiter`,
+  `rate_limit_table_name`) and `cache_namespace` rebuild the rate limiter.
+- `segmenter` rebuilds the segmenter.
+- `logger`, `instrumenter` and the timeouts (`open_timeout`, `timeout`,
+  `max_retries`) rebuild nothing -- nothing memoised reads them.
+
+Before this, nothing was ever rebuilt: an application wanting to switch
+`provider` at runtime had no way to do it short of `TranslationDiff.reset!`
+and reconfiguring from scratch, which also threw away a Redis pool, and
+everything built from it, that had no reason to go.
+
+One behaviour is worth flagging on its own: `cache_namespace` names the rate
+limiter's own bookkeeping namespace as well as the cache store's, so
+changing it at runtime now moves the limiter too -- it counts under the new
+namespace from the next check on, rather than continuing silently under the
+old one.
+
+`TranslationDiff.context` -- or `config.copy`, which it is built on -- is
+still the way to get a configuration that resolves everything afresh from
+its own values, independently of whatever the configuration it was copied
+from has already built.
 
 ## Choosing the cache store
 
diff --git a/docs/how-it-works.md b/docs/how-it-works.md
index 3bd71e5..ebdcffa 100644
--- a/docs/how-it-works.md
+++ b/docs/how-it-works.md
@@ -23,8 +23,9 @@ Everything below is a collaborator one of the two drives.
 2. **Each leaf becomes a passage of markup and prose.**
    `TranslationDiff::Passage` parses the string with `ox` and records where
    every construct begins, so each run of the source is either markup --
-   tags, comments, CDATA, doctypes, processing instructions, `")
+  end
+
+  def test_mixed_case_script_and_style_contents_are_not_prose
+    assert_equal %w[аль бра кил], cores("альбракил")
+  end
+
   # The common case, not the block one: a code span mid-sentence must not split the sentence around it or eat a space.
   def test_an_inline_code_span_leaves_the_sentence_around_it_intact
     source = "Press Ctrl+C to stop."
@@ -133,7 +157,7 @@ def test_a_notranslate_span_reaches_the_provider_with_its_tags
     assert_equal [%(Bold Mountain is a good place.)], cores(source)
   end
 
-  # Ox lowercases element names but not attribute names, and HTML attribute names are case-insensitive.
+  # Ox does not lowercase attribute names either, and HTML attribute names are case-insensitive.
   def test_an_uppercase_class_attribute_still_protects
     source = %(Bold Mountain is a good place.)
 

From 60c7cba2f235ff343b717668b2a1b7d4a7ac06c9 Mon Sep 17 00:00:00 2001
From: IG 
Date: Fri, 11 Sep 2026 07:02:00 +0400
Subject: [PATCH 13/21] feat: add TranslationDiff::Context#preview

translate and preview are a matched pair at the top level, but Context
only carried translate, so a tenant's call could never be previewed.
Add preview, built the same way translate is, against the context's
own configuration.
---
 lib/translation_diff/context.rb       |  8 ++++++++
 test/translation_diff/context_test.rb | 20 ++++++++++++++++++++
 2 files changed, 28 insertions(+)

diff --git a/lib/translation_diff/context.rb b/lib/translation_diff/context.rb
index 5631413..01257b0 100644
--- a/lib/translation_diff/context.rb
+++ b/lib/translation_diff/context.rb
@@ -12,4 +12,12 @@ def translate(values, from: nil, to: nil, provider: nil, assume_supported: false
               assume_supported: assume_supported, **
     ).call
   end
+
+  # Answers what #translate would do to `values` under this context's own configuration, without calling it.
+  def preview(values, from: nil, to: nil, provider: nil, assume_supported: false, **)
+    TranslationDiff::Previewer.new(
+      values, from: from, to: to, provider: provider, config: config,
+              assume_supported: assume_supported, **
+    ).call
+  end
 end
diff --git a/test/translation_diff/context_test.rb b/test/translation_diff/context_test.rb
index d2a4f49..5649c2a 100644
--- a/test/translation_diff/context_test.rb
+++ b/test/translation_diff/context_test.rb
@@ -46,6 +46,26 @@ def test_a_context_translates_through_its_own_configuration
     assert_equal "Hello.", context.translate("Hello.", from: "en", to: "ru")
   end
 
+  # translate and preview are a matched pair at the top level; a context is the same entry point, so it
+  # must be able to preview a tenant's call too, not just carry it out.
+  def test_a_context_previews_through_its_own_configuration
+    context = TranslationDiff.context do |c|
+      c.provider = TranslationDiff::Providers::Null.new(TranslationDiff::Configuration.new)
+    end
+
+    preview = context.preview("Hello.", from: "en", to: "ru")
+
+    assert_equal 1, preview.sendable_sentences
+  end
+
+  # `to:` still defaults to nil here too, so a context refuses a missing target by naming the keyword.
+  def test_a_missing_target_language_is_refused_by_name_for_preview
+    context = TranslationDiff.context { |c| c.cache_namespace = "tenant" }
+    error = assert_raises(ArgumentError) { context.preview("Hello.", from: "en") }
+
+    assert_match(/to:/, error.message)
+  end
+
   # `to:` still defaults to nil here too, so a context refuses a missing target by naming the keyword.
   def test_a_missing_target_language_is_refused_by_name
     context = TranslationDiff.context { |c| c.cache_namespace = "tenant" }

From 16e25b9eb9548a58494c1f25ea2ed5515e0276d3 Mon Sep 17 00:00:00 2001
From: IG 
Date: Fri, 11 Sep 2026 07:02:49 +0400
Subject: [PATCH 14/21] fix: pass config.opaque_elements to Passage from
 Translator and Previewer

Both passed segmenter: and language: from the configuration a call is
actually using, but not opaque_elements, so Passage fell back to the
global TranslationDiff.config and a context's own opaque_elements was
silently ignored -- the one mechanism the docs recommend for a
per-tenant setting. Pass it through in both places.
---
 lib/translation_diff/previewer.rb     |  5 +++-
 lib/translation_diff/translator.rb    |  5 +++-
 test/translation_diff/context_test.rb | 43 +++++++++++++++++++++++++++
 3 files changed, 51 insertions(+), 2 deletions(-)

diff --git a/lib/translation_diff/previewer.rb b/lib/translation_diff/previewer.rb
index 293f1c8..8028992 100644
--- a/lib/translation_diff/previewer.rb
+++ b/lib/translation_diff/previewer.rb
@@ -38,8 +38,11 @@ def document_segments
     document.strings.flat_map { |string| passage(string).segments }.reject(&:empty?)
   end
 
+  # opaque_elements comes from the configuration this call is actually using -- a context's own setting must
+  # never fall back to Passage's global default.
   def passage(string)
-    TranslationDiff::Passage.new(string, segmenter: @config.segmenter_instance, language: @from)
+    TranslationDiff::Passage.new(string, segmenter: @config.segmenter_instance, language: @from,
+                                         opaque_elements: @config.opaque_elements)
   end
 
   # A detected language arrives as a String while `to:` is usually a Symbol, so neither type nor case can be assumed.
diff --git a/lib/translation_diff/translator.rb b/lib/translation_diff/translator.rb
index 181db52..ee66f70 100644
--- a/lib/translation_diff/translator.rb
+++ b/lib/translation_diff/translator.rb
@@ -82,8 +82,11 @@ def resolve_provider
     TranslationDiff::Providers.resolve(@requested_provider, config).tap { |provider| log("provider #{provider.class}") }
   end
 
+  # opaque_elements comes from the configuration this call is actually using -- a context's own setting must
+  # never fall back to Passage's global default.
   def passage(string)
-    TranslationDiff::Passage.new(string, segmenter: config.segmenter_instance, language: @from)
+    TranslationDiff::Passage.new(string, segmenter: config.segmenter_instance, language: @from,
+                                         opaque_elements: config.opaque_elements)
   end
 
   # The strings walk and the map walk visit the same leaves in the same order, and the value itself was never touched.
diff --git a/test/translation_diff/context_test.rb b/test/translation_diff/context_test.rb
index 5649c2a..fd42b54 100644
--- a/test/translation_diff/context_test.rb
+++ b/test/translation_diff/context_test.rb
@@ -1,6 +1,23 @@
 require "test_helper"
 
 class ContextTest < Minitest::Test
+  # Records what each call actually sent, so a context's own opaque_elements can be proven to have reached it.
+  class RecordingProvider < TranslationDiff::Provider
+    attr_reader :requests
+
+    def initialize(config)
+      super
+      @requests = []
+    end
+
+    def translate(request)
+      @requests << request.texts
+      TranslationDiff::Translation::Response.build(request: request, texts: request.texts)
+    end
+
+    def cache_key = "recording"
+  end
+
   def setup
     TranslationDiff.reset!
     TranslationDiff.configure do |c|
@@ -73,4 +90,30 @@ def test_a_missing_target_language_is_refused_by_name
 
     assert_match(/to:/, error.message)
   end
+
+  # config.opaque_elements is the one mechanism the docs recommend for a per-tenant opaque set; a context
+  # that silently fell back to the global configuration would defeat it.
+  def test_a_contexts_opaque_elements_reaches_translate
+    provider = RecordingProvider.new(TranslationDiff::Configuration.new)
+    context = TranslationDiff.context do |c|
+      c.provider = provider
+      c.opaque_elements = %i[kbd]
+    end
+
+    context.translate("Before.keep meAfter.", from: "en", to: "ru")
+
+    assert_equal [["Before."], ["After."]], provider.requests
+  end
+
+  def test_a_contexts_opaque_elements_reaches_preview
+    provider = RecordingProvider.new(TranslationDiff::Configuration.new)
+    context = TranslationDiff.context do |c|
+      c.provider = provider
+      c.opaque_elements = %i[kbd]
+    end
+
+    preview = context.preview("Before.keep meAfter.", from: "en", to: "ru")
+
+    assert_equal 2, preview.sendable_sentences
+  end
 end

From 134c582495fcc52f8ba2cc611f6189bda1e1e421 Mon Sep 17 00:00:00 2001
From: IG 
Date: Fri, 11 Sep 2026 07:04:31 +0400
Subject: [PATCH 15/21] refactor: share provider-call preparation between
 Translator and Previewer

Previewer and Translator carried verbatim copies of passage,
same_language?, ensure_supported!, the "cannot detect the source
language" refusal and the SentenceCache construction -- the exact
duplication that let the opaque_elements argument be forgotten in
both places. Extract the shared parts into CallPreparation, included
by both, so the next omission cannot happen twice. Each class keeps
its own Error and does not merge into the other.
---
 lib/translation_diff.rb                  |  1 +
 lib/translation_diff/call_preparation.rb | 47 ++++++++++++++++++++++++
 lib/translation_diff/previewer.rb        | 44 +++++-----------------
 lib/translation_diff/translator.rb       | 40 ++------------------
 4 files changed, 60 insertions(+), 72 deletions(-)
 create mode 100644 lib/translation_diff/call_preparation.rb

diff --git a/lib/translation_diff.rb b/lib/translation_diff.rb
index 52c5d84..dbeb620 100644
--- a/lib/translation_diff.rb
+++ b/lib/translation_diff.rb
@@ -57,6 +57,7 @@
 require "translation_diff/redis_rate_limiter"
 require "translation_diff/active_record_rate_limiter"
 require "translation_diff/instrumentation"
+require "translation_diff/call_preparation"
 require "translation_diff/dispatcher"
 require "translation_diff/translator"
 require "translation_diff/preview"
diff --git a/lib/translation_diff/call_preparation.rb b/lib/translation_diff/call_preparation.rb
new file mode 100644
index 0000000..ff18222
--- /dev/null
+++ b/lib/translation_diff/call_preparation.rb
@@ -0,0 +1,47 @@
+# Shared by Translator and Previewer: turning values into a passage, checking a language pair is
+# supported, and opening the cache -- the parts that answer the same question either way a call is made.
+module TranslationDiff::CallPreparation
+  # `include` ignores the includer's own `private` keyword, so visibility has to be declared here.
+
+  private
+
+  # opaque_elements comes from the configuration this call is actually using -- a context's own setting must
+  # never fall back to Passage's global default.
+  def passage(string)
+    TranslationDiff::Passage.new(string, segmenter: config.segmenter_instance, language: @from,
+                                         opaque_elements: config.opaque_elements)
+  end
+
+  # A detected language arrives as a String while `to:` is usually a Symbol, so neither type nor case can be assumed.
+  def same_language?(from) = from.to_s.casecmp?(@to.to_s)
+
+  # nil means we ship no data for this provider, and silence is not evidence of absence.
+  def ensure_supported!(provider, from)
+    return if @assume_supported || !config.validate_languages
+
+    supported = TranslationDiff::Languages.supports?(provider.cache_key, from: from, to: @to)
+    return if supported.nil? || supported
+
+    raise TranslationDiff::UnsupportedLanguageError,
+          "Provider #{provider.cache_key} does not translate #{pair_description(from)}. If it does " \
+          "now, pass `assume_supported: true` for this call, or set " \
+          "`config.validate_languages = false`, and run `rake languages:refresh`."
+  end
+
+  def pair_description(from) = from.nil? ? "to #{@to}" : "#{from} to #{@to}"
+
+  # The refusal every caller uses when a provider cannot detect at all; error_class and verb are the only
+  # per-caller bits -- Previewer raises a second, stricter refusal of its own even when this one passes.
+  def ensure_detects_language!(provider, error_class, verb)
+    return if provider.class.capabilities.detects_language?
+
+    raise error_class, "Provider #{provider.cache_key} cannot detect the source language. Pass `from:` " \
+                       "with the source language code of the values you are #{verb}."
+  end
+
+  # Reads and writes go through the same cache key format either way, so a hit for one is a hit for the other.
+  def cache_for(provider, from)
+    TranslationDiff::SentenceCache.new(store: config.cache_store, provider: provider.cache_key,
+                                       from: from, to: @to, options: @options)
+  end
+end
diff --git a/lib/translation_diff/previewer.rb b/lib/translation_diff/previewer.rb
index 8028992..08cc6fc 100644
--- a/lib/translation_diff/previewer.rb
+++ b/lib/translation_diff/previewer.rb
@@ -5,8 +5,12 @@ class TranslationDiff::Previewer
   # Its own class, so rescuing a preview that cannot be answered cannot also swallow a cache or provider failure.
   class Error < TranslationDiff::Error; end
 
+  include TranslationDiff::CallPreparation
+
   EMPTY = TranslationDiff::Preview.new(sendable_sentences: 0, cached_sentences: 0, sendable_characters: 0).freeze
 
+  attr_reader :config
+
   # `provider:`, `config:` and `assume_supported:` are reserved, exactly as they are for Translator#initialize.
   def initialize(values, from: nil, to: nil, provider: nil, config: nil, assume_supported: false, **options)
     raise ArgumentError, "a preview needs a target language: pass `to:` a language code." if to.nil?
@@ -38,18 +42,8 @@ def document_segments
     document.strings.flat_map { |string| passage(string).segments }.reject(&:empty?)
   end
 
-  # opaque_elements comes from the configuration this call is actually using -- a context's own setting must
-  # never fall back to Passage's global default.
-  def passage(string)
-    TranslationDiff::Passage.new(string, segmenter: @config.segmenter_instance, language: @from,
-                                         opaque_elements: @config.opaque_elements)
-  end
-
-  # A detected language arrives as a String while `to:` is usually a Symbol, so neither type nor case can be assumed.
-  def same_language?(from) = from.to_s.casecmp?(@to.to_s)
-
   # Same resolution Translator#call uses: a name to build, an object to use as it is, or the configured one.
-  def resolve_provider = TranslationDiff::Providers.resolve(@requested_provider, @config)
+  def resolve_provider = TranslationDiff::Providers.resolve(@requested_provider, config)
 
   # `from:` given means the pair is already known, so it is validated once; `from:` nil needs a detection this
   # method never pays for, so it stops here instead of guessing what a paid request would have answered.
@@ -60,31 +54,15 @@ def resolve_source_language(provider)
     raise_undetectable!(provider)
   end
 
+  # A provider that cannot detect at all is refused with the same message translate uses; one that could but
+  # would cost a paid request is refused too -- preview never spends money to answer what it would send.
   def raise_undetectable!(provider)
-    unless provider.class.capabilities.detects_language?
-      raise Error, "Provider #{provider.cache_key} cannot detect the source language. Pass `from:` with the " \
-                   "source language code of the values you are previewing."
-    end
+    ensure_detects_language!(provider, Error, "previewing")
 
     raise Error, "TranslationDiff.preview cannot detect the source language for #{provider.cache_key} " \
                  "without a paid request: pass `from:` explicitly."
   end
 
-  # nil means we ship no data for this provider, and silence is not evidence of absence.
-  def ensure_supported!(provider, from)
-    return if @assume_supported || !@config.validate_languages
-
-    supported = TranslationDiff::Languages.supports?(provider.cache_key, from: from, to: @to)
-    return if supported.nil? || supported
-
-    raise TranslationDiff::UnsupportedLanguageError,
-          "Provider #{provider.cache_key} does not translate #{pair_description(from)}. If it does " \
-          "now, pass `assume_supported: true` for this call, or set " \
-          "`config.validate_languages = false`, and run `rake languages:refresh`."
-  end
-
-  def pair_description(from) = from.nil? ? "to #{@to}" : "#{from} to #{@to}"
-
   def preview_for(provider, from, segments)
     misses = fill(provider, from, segments)
     TranslationDiff::Preview.new(sendable_sentences: misses.size, cached_sentences: segments.size - misses.size,
@@ -92,9 +70,5 @@ def preview_for(provider, from, segments)
   end
 
   # Reads the store through the same SentenceCache#fill translate uses; nothing here ever calls #store.
-  def fill(provider, from, segments)
-    cache = TranslationDiff::SentenceCache.new(store: @config.cache_store, provider: provider.cache_key,
-                                               from: from, to: @to, options: @options)
-    cache.fill(segments)
-  end
+  def fill(provider, from, segments) = cache_for(provider, from).fill(segments)
 end
diff --git a/lib/translation_diff/translator.rb b/lib/translation_diff/translator.rb
index ee66f70..a57d1cb 100644
--- a/lib/translation_diff/translator.rb
+++ b/lib/translation_diff/translator.rb
@@ -4,6 +4,7 @@ class TranslationDiff::Translator
   class Error < TranslationDiff::Error; end
 
   include TranslationDiff::Instrumentation
+  include TranslationDiff::CallPreparation
 
   attr_reader :config
 
@@ -46,22 +47,6 @@ def resolve_source_language(provider, segments)
     source_language(provider, segments).tap { |from| ensure_supported!(provider, from) }
   end
 
-  # nil means we ship no data for this provider, and silence is not evidence of absence.
-  # `from` nil (source not known yet) checks the target alone: a nil source is never itself refused.
-  def ensure_supported!(provider, from)
-    return if @assume_supported || !config.validate_languages
-
-    supported = TranslationDiff::Languages.supports?(provider.cache_key, from: from, to: @to)
-    return if supported.nil? || supported
-
-    raise TranslationDiff::UnsupportedLanguageError,
-          "Provider #{provider.cache_key} does not translate #{pair_description(from)}. If it does " \
-          "now, pass `assume_supported: true` for this call, or set " \
-          "`config.validate_languages = false`, and run `rake languages:refresh`."
-  end
-
-  def pair_description(from) = from.nil? ? "to #{@to}" : "#{from} to #{@to}"
-
   # The `translate` event wraps everything a call that reaches a provider does, and nothing an early return does.
   def translated(document, passages, segments, provider, from)
     values = TranslationDiff::Leaves.count(@values)
@@ -82,13 +67,6 @@ def resolve_provider
     TranslationDiff::Providers.resolve(@requested_provider, config).tap { |provider| log("provider #{provider.class}") }
   end
 
-  # opaque_elements comes from the configuration this call is actually using -- a context's own setting must
-  # never fall back to Passage's global default.
-  def passage(string)
-    TranslationDiff::Passage.new(string, segmenter: config.segmenter_instance, language: @from,
-                                         opaque_elements: config.opaque_elements)
-  end
-
   # The strings walk and the map walk visit the same leaves in the same order, and the value itself was never touched.
   def rebuild(document, passages)
     rendered = passages.map(&:render)
@@ -99,25 +77,13 @@ def rebuild(document, passages)
   def source_language(provider, segments)
     return @from unless @from.nil?
 
-    ensure_detects_language!(provider)
+    ensure_detects_language!(provider, Error, "translating")
     provider.detect(segments.first.core)
   end
 
-  def ensure_detects_language!(provider)
-    return if provider.class.capabilities.detects_language?
-
-    raise Error, "Provider #{provider.cache_key} cannot detect the source language. Pass " \
-                 "`from:` with the source language code of the values you are translating."
-  end
-
-  # A detected language arrives as a String while `to:` is usually a Symbol, so neither type nor case can be assumed.
-  # A `from:` the caller gave settles this before a provider is resolved; a nil one cannot, and never matches.
-  def same_language?(from) = from.to_s.casecmp?(@to.to_s)
-
   # The cache answers for what it has, the provider for the rest, and only what came back is written home.
   def fill(provider, segments, from)
-    cache = TranslationDiff::SentenceCache.new(store: config.cache_store, provider: provider.cache_key,
-                                               from: from, to: @to, options: @options)
+    cache = cache_for(provider, from)
     misses = cache.fill(segments)
     id = call_id
     instrument("cache", call_id: id, provider: provider.cache_key,

From 8cd47f2cac61ea1120f48c4fdbf48d6915b71863 Mon Sep 17 00:00:00 2001
From: IG 
Date: Fri, 11 Sep 2026 07:05:14 +0400
Subject: [PATCH 16/21] fix: rebuild the provider instance when a timeout
 option changes

open_timeout, timeout and max_retries were classified as invalidating
nothing, but HTTPProvider#connection memoises a Faraday connection
built from all three, and the provider itself is memoised on
Configuration. Raising config.timeout from a settings screen changed
the value and nothing else until the process restarted. logger and
instrumenter really are read live and stay classified as they are.
---
 .../configuration/option_table.rb             |  8 +++--
 test/translation_diff/configuration_test.rb   | 31 ++++++++++++++++++-
 2 files changed, 35 insertions(+), 4 deletions(-)

diff --git a/lib/translation_diff/configuration/option_table.rb b/lib/translation_diff/configuration/option_table.rb
index 4df3241..82eba76 100644
--- a/lib/translation_diff/configuration/option_table.rb
+++ b/lib/translation_diff/configuration/option_table.rb
@@ -24,9 +24,11 @@ module TranslationDiff::Configuration::OptionTable
     [:opaque_elements, %i[script style pre code], nil],
     [:instrumenter, nil, nil],
     [:logger, nil, nil],
-    [:open_timeout, 5, nil],
-    [:timeout, 30, nil],
-    [:max_retries, 3, nil],
+    # HTTPProvider#connection memoises a Faraday connection built from these three, and the provider itself
+    # is memoised too, so a change here has to reach provider_instance or it never reaches the connection.
+    [:open_timeout, 5, :provider_instance],
+    [:timeout, 30, :provider_instance],
+    [:max_retries, 3, :provider_instance],
     [:validate_languages, true, nil]
   ].freeze
 
diff --git a/test/translation_diff/configuration_test.rb b/test/translation_diff/configuration_test.rb
index aab9b43..d2aee55 100644
--- a/test/translation_diff/configuration_test.rb
+++ b/test/translation_diff/configuration_test.rb
@@ -550,6 +550,35 @@ def test_changing_an_option_a_provider_declared_rebuilds_the_provider_instance
     refute_same first, @config.provider_instance
   end
 
+  # A memoised HTTPProvider#connection is built from these three, so raising one from a settings screen must
+  # rebuild the provider instance -- otherwise the change takes effect only after the process restarts.
+  def test_changing_the_open_timeout_rebuilds_the_provider_instance
+    @config.provider = :null
+    first = @config.provider_instance
+
+    @config.open_timeout = 9
+
+    refute_same first, @config.provider_instance
+  end
+
+  def test_changing_the_timeout_rebuilds_the_provider_instance
+    @config.provider = :null
+    first = @config.provider_instance
+
+    @config.timeout = 45
+
+    refute_same first, @config.provider_instance
+  end
+
+  def test_changing_max_retries_rebuilds_the_provider_instance
+    @config.provider = :null
+    first = @config.provider_instance
+
+    @config.max_retries = 5
+
+    refute_same first, @config.provider_instance
+  end
+
   def test_changing_the_logger_leaves_the_redis_pool_in_place
     @config.redis_url = "redis://localhost:6379"
     pool = @config.redis_pool
@@ -624,7 +653,7 @@ def test_an_unclassified_option_invalidates_nothing
     provider = @config.provider_instance
     store = @config.cache_store
 
-    @config.max_retries = 1
+    @config.validate_languages = false
 
     assert_same provider, @config.provider_instance
     assert_same store, @config.cache_store

From 4cce279ccc2f44a1a345468a1f571a6ef0a9c792 Mon Sep 17 00:00:00 2001
From: IG 
Date: Fri, 11 Sep 2026 07:06:09 +0400
Subject: [PATCH 17/21] fix: a write that does not change an option's value
 invalidates nothing

Every write cleared its declared memos even when the value was
unchanged, so a per-request `configure { |c| c.cache_namespace =
tenant }` rebuilt the cache store on every request -- on the default
MemoryCacheStore a rebuild is a brand new empty Hash, so the
application paid the provider for its whole warm cache again. Compare
the raw stored value before writing and invalidating.

This does not cover every case: changing cache_ttl on a memory store
still drops a cache that never read that option, since cache_ttl is
still classified as invalidating cache_store.
---
 lib/translation_diff/configuration.rb       |  7 +++++-
 test/translation_diff/configuration_test.rb | 27 +++++++++++++++++++--
 2 files changed, 31 insertions(+), 3 deletions(-)

diff --git a/lib/translation_diff/configuration.rb b/lib/translation_diff/configuration.rb
index f10b008..9deec84 100644
--- a/lib/translation_diff/configuration.rb
+++ b/lib/translation_diff/configuration.rb
@@ -28,10 +28,15 @@ def defaults = @defaults ||= {}
     private
 
     # The writer clears exactly the memos this option was declared to invalidate; the reader defers to `read`.
+    # A write that leaves the raw value unchanged clears none of them -- a per-request write of the same
+    # tenant must not rebuild a cache store that was already warm.
     def define_option_accessors(key, memos)
+      ivar = :"@#{key}"
       define_method(:"#{key}=") do |value|
         value = nil if value.is_a?(String) && value.strip.empty?
-        instance_variable_set(:"@#{key}", value)
+        next if instance_variable_get(ivar) == value
+
+        instance_variable_set(ivar, value)
         memos.each { |memo| instance_variable_set(:"@#{memo}", nil) }
       end
       define_method(key) { read(key) }
diff --git a/test/translation_diff/configuration_test.rb b/test/translation_diff/configuration_test.rb
index d2aee55..ec952b1 100644
--- a/test/translation_diff/configuration_test.rb
+++ b/test/translation_diff/configuration_test.rb
@@ -529,18 +529,29 @@ def self.configuration_options = %i[double_provider_key]
 
     def translate(request) = TranslationDiff::Translation::Response.build(request: request, texts: request.texts)
   end
+  TranslationDiff::Providers.register(:double_provider, DoubleProvider)
 
   def test_changing_the_provider_rebuilds_the_memoised_instance
     @config.provider = :null
     first = @config.provider_instance
 
-    @config.provider = :null
+    @config.provider = :double_provider
 
     refute_same first, @config.provider_instance
   end
 
+  # A write that leaves an option at the value it already held clears no memo -- a per-request
+  # `configure { |c| c.cache_namespace = tenant }` must not rebuild a store that was already warm.
+  def test_writing_the_same_provider_again_invalidates_nothing
+    @config.provider = :null
+    first = @config.provider_instance
+
+    @config.provider = :null
+
+    assert_same first, @config.provider_instance
+  end
+
   def test_changing_an_option_a_provider_declared_rebuilds_the_provider_instance
-    TranslationDiff::Providers.register(:double_provider, DoubleProvider)
     @config.provider = :double_provider
     @config.double_provider_key = "first"
     first = @config.provider_instance
@@ -596,6 +607,18 @@ def test_changing_the_cache_namespace_rebuilds_the_cache_store
     refute_same original, @config.cache_store
   end
 
+  # The scenario the bug actually costs: a per-request `configure { |c| c.cache_namespace = tenant }` re-writing
+  # the same tenant on every request must never rebuild the store -- on MemoryCacheStore a rebuild is a brand
+  # new empty Hash, so the application would pay the provider again for its whole warm cache.
+  def test_writing_the_same_cache_namespace_again_leaves_the_cache_store_in_place
+    @config.cache_namespace = "tenant-1"
+    store = @config.cache_store
+
+    @config.cache_namespace = "tenant-1"
+
+    assert_same store, @config.cache_store
+  end
+
   # cache_namespace also names the rate limiter's own namespace, so it must move that limiter too.
   def test_changing_the_cache_namespace_rebuilds_the_rate_limiter
     @config.rate_limit = 100

From 90eb95014eb84574984add3008d8ca6f7093be6c Mon Sep 17 00:00:00 2001
From: IG 
Date: Fri, 11 Sep 2026 07:08:15 +0400
Subject: [PATCH 18/21] feat: add the total character count to Preview

Preview reported sendable_characters -- the numerator -- with no
denominator, on the same branch that added characters to the
translate event because a fully-cached call could not report its
size. Add characters, the same total translate reports, and test that
the two agree for the same call: the strongest available check.
---
 lib/translation_diff/preview.rb         |  5 +++--
 lib/translation_diff/previewer.rb       |  8 ++++++--
 test/translation_diff/previewer_test.rb | 16 ++++++++++++++++
 3 files changed, 25 insertions(+), 4 deletions(-)

diff --git a/lib/translation_diff/preview.rb b/lib/translation_diff/preview.rb
index 1e82df4..e457b7a 100644
--- a/lib/translation_diff/preview.rb
+++ b/lib/translation_diff/preview.rb
@@ -1,2 +1,3 @@
-# What a #translate call would do without doing it: sentences it would send, sentences already cached, their size.
-TranslationDiff::Preview = Data.define(:sendable_sentences, :cached_sentences, :sendable_characters)
+# What a #translate call would do without doing it: sentences it would send, sentences already cached, their
+# size, and characters -- the total the translate event itself reports, the denominator sendable_characters needs.
+TranslationDiff::Preview = Data.define(:sendable_sentences, :cached_sentences, :sendable_characters, :characters)
diff --git a/lib/translation_diff/previewer.rb b/lib/translation_diff/previewer.rb
index 08cc6fc..e5c2485 100644
--- a/lib/translation_diff/previewer.rb
+++ b/lib/translation_diff/previewer.rb
@@ -7,7 +7,8 @@ class Error < TranslationDiff::Error; end
 
   include TranslationDiff::CallPreparation
 
-  EMPTY = TranslationDiff::Preview.new(sendable_sentences: 0, cached_sentences: 0, sendable_characters: 0).freeze
+  EMPTY = TranslationDiff::Preview.new(sendable_sentences: 0, cached_sentences: 0, sendable_characters: 0,
+                                       characters: 0).freeze
 
   attr_reader :config
 
@@ -63,10 +64,13 @@ def raise_undetectable!(provider)
                  "without a paid request: pass `from:` explicitly."
   end
 
+  # characters is the denominator: the same total the translate event itself reports, present even when every
+  # segment is already cached and sendable_characters alone would leave nothing to divide by.
   def preview_for(provider, from, segments)
     misses = fill(provider, from, segments)
     TranslationDiff::Preview.new(sendable_sentences: misses.size, cached_sentences: segments.size - misses.size,
-                                 sendable_characters: misses.sum { |segment| segment.core.size })
+                                 sendable_characters: misses.sum { |segment| segment.core.size },
+                                 characters: segments.sum { |segment| segment.core.size })
   end
 
   # Reads the store through the same SentenceCache#fill translate uses; nothing here ever calls #store.
diff --git a/test/translation_diff/previewer_test.rb b/test/translation_diff/previewer_test.rb
index 98a4948..c43fa2d 100644
--- a/test/translation_diff/previewer_test.rb
+++ b/test/translation_diff/previewer_test.rb
@@ -86,8 +86,11 @@ def test_a_document_nothing_has_cached_counts_every_sentence_as_sendable
     assert_equal 2, result.sendable_sentences
     assert_equal 0, result.cached_sentences
     assert_equal "One.Two.".size, result.sendable_characters
+    assert_equal "One.Two.".size, result.characters
   end
 
+  # The denominator a fully-cached call still needs: sendable_characters alone would report 0 of nothing,
+  # leaving no way to say "this call would send 0 of 8 characters" rather than "there was nothing to send".
   def test_after_translating_nothing_is_left_to_send
     TranslationDiff.translate("One. Two.", from: "en", to: "ru", provider: @provider)
 
@@ -96,6 +99,7 @@ def test_after_translating_nothing_is_left_to_send
     assert_equal 0, result.sendable_sentences
     assert_equal 2, result.cached_sentences
     assert_equal 0, result.sendable_characters
+    assert_equal "One.Two.".size, result.characters
   end
 
   # This is the property the whole thing exists for: an edit to one sentence sends exactly that sentence.
@@ -123,6 +127,18 @@ def test_the_previews_counts_equal_what_translate_then_reports_through_the_cache
     assert_equal predicted.cached_sentences, payload[:hits]
   end
 
+  # The strongest available check that a preview's total and a translate call's own report of its size agree.
+  def test_the_previews_total_characters_equals_what_the_translate_event_reports
+    predicted = preview("One. Two.", from: "en", to: "ru")
+
+    recorder = Recorder.new
+    TranslationDiff.configure { |c| c.instrumenter = recorder }
+    TranslationDiff.translate("One. Two.", from: "en", to: "ru", provider: @provider)
+    payload = recorder.events.find { |name, _| name == "translate.translation_diff" }.last
+
+    assert_equal predicted.characters, payload[:characters]
+  end
+
   def test_an_explicit_provider_and_the_configured_one_agree
     TranslationDiff.configure { |c| c.provider = @provider }
 

From 5bc84fb6ccecf942bb749b370faeb9a465709199 Mon Sep 17 00:00:00 2001
From: IG 
Date: Fri, 11 Sep 2026 07:16:27 +0400
Subject: [PATCH 19/21] docs: catch up prose with the opaque, context, timeout,
 preview and no-op-write fixes

Opaque element matching is now documented as case-insensitive; a
context's own config.opaque_elements is documented as reaching
Passage instead of falling back to the global value; the timeouts are
corrected from "rebuild nothing" to "rebuild the provider"; a same-
value write is documented as invalidating nothing, and what a
genuinely new cache option value still costs on MemoryCacheStore;
Context is documented as offering #preview alongside #translate;
Preview#characters is documented and tied back to the cache and
translate events it reconciles against.

Also fixes three standalone documentation defects: the instrumentation
event table contradicted its own page about translate/cache firing on
a fully-cached call; usage's characters was left out of the "two
events, different meaning" characters note; and two upgrade
consequences were filed as Added sub-bullets instead of under
Breaking, where a blank provider cache_key now raising
InvalidProviderError instead of Translator::Error has been added
alongside them.

README now shows TranslationDiff.preview in its code section.
---
 CHANGELOG.md            | 38 +++++++++++++++++++++++++-------------
 README.md               |  7 +++++++
 docs/caching.md         |  8 ++++++++
 docs/configuration.md   | 28 +++++++++++++++++++++-------
 docs/errors.md          |  7 ++++---
 docs/how-it-works.md    |  9 ++++++---
 docs/instrumentation.md |  9 +++++----
 7 files changed, 76 insertions(+), 30 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6b24d0b..d000669 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -18,6 +18,28 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
   `config.validate_languages = false` globally. See
   [Languages](docs/languages.md).
 
+- **Cache keys change for any document containing a `pre` or `code`
+  element.** `pre` and `code` are now opaque (see Added, below), so what
+  gets sent to the provider changed, and what gets keyed changed with it;
+  an entry cached under the old behaviour keeps serving what the old
+  behaviour produced. Give the configuration a new `cache_namespace`, or
+  let `cache_ttl` lapse, to get every such document retranslated. See
+  [Caching](docs/caching.md#what-a-cache-key-is-made-of).
+
+- **A runtime `cache_namespace` change now moves the rate limiter too.**
+  `cache_namespace` names the limiter's own bookkeeping namespace as well
+  as the cache store's; it used to move only the store, leaving the
+  limiter counting silently under the old namespace. See
+  [Configuration](docs/configuration.md#changing-configuration-at-runtime).
+
+- **A provider with a blank `cache_key` now raises
+  `TranslationDiff::InvalidProviderError`, not
+  `TranslationDiff::Translator::Error`.** The two are siblings under
+  `TranslationDiff::Error`, not parent and child, so an application
+  rescuing the old class specifically stops catching this failure.
+  Rescue `TranslationDiff::Error` to catch both. See
+  [Errors](docs/errors.md).
+
 ### Added
 
 - **Every event from one `translate` call now shares a `call_id`.** Generated
@@ -60,19 +82,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
   at runtime meant `TranslationDiff.reset!` and reconfiguring from scratch,
   discarding a Redis pool that had no reason to go. See
   [Configuration](docs/configuration.md#changing-configuration-at-runtime).
-  Two consequences to check before you upgrade:
-  - **Cache keys change for any document containing a `pre` or `code`
-    element.** What gets sent to the provider changed, so what gets keyed
-    changed too; an entry cached under the old behaviour keeps serving what
-    the old behaviour produced. Give the configuration a new
-    `cache_namespace`, or let `cache_ttl` lapse, to get every such document
-    retranslated. See
-    [Caching](docs/caching.md#what-a-cache-key-is-made-of).
-  - **A runtime `cache_namespace` change now moves the rate limiter too.**
-    `cache_namespace` names the limiter's own bookkeeping namespace as well
-    as the cache store's; it used to move only the store, leaving the
-    limiter counting silently under the old namespace. See
-    [Configuration](docs/configuration.md#changing-configuration-at-runtime).
+  Two upgrade consequences of this are filed under Breaking, above:
+  cache keys changing for a document containing `pre` or `code`, and a
+  runtime `cache_namespace` change now moving the rate limiter too.
 
 - A `usage` instrumentation event, firing once per provider request, beside
   `translate`, `cache`, `request` and `rate_limit`. Its payload carries
diff --git a/README.md b/README.md
index e469e7d..1a85048 100644
--- a/README.md
+++ b/README.md
@@ -67,6 +67,13 @@ end
 formal.translate(contract, from: "en", to: "de", formality: :more)
 ```
 
+```ruby
+# See what a call would send and find cached, without calling the provider or writing anything
+preview = TranslationDiff.preview(contract, from: "en", to: "de")
+preview.sendable_sentences # => sentences not yet cached
+preview.cached_sentences   # => sentences already cached
+```
+
 ```ruby
 # Redis-backed cache, shared across processes
 TranslationDiff.configure { |config| config.redis_url = ENV["REDIS_URL"] }
diff --git a/docs/caching.md b/docs/caching.md
index f702fbe..8b439c7 100644
--- a/docs/caching.md
+++ b/docs/caching.md
@@ -73,8 +73,16 @@ preview = TranslationDiff.preview(article_body, from: "en", to: "es")
 preview.sendable_sentences   # => 1, not yet cached
 preview.cached_sentences     # => 4, already cached
 preview.sendable_characters  # => 23
+preview.characters           # => 412, the total this call would consider
 ```
 
+`sendable_sentences` and `cached_sentences` are the same two counts the
+`cache` event reports as `misses` and `hits`; `characters` is the same total
+the `translate` event reports. A preview and the call it predicts are
+answering the same question through the same numbers, so "this edit will
+send 23 of 412 characters" and what the events for that call later report
+should agree.
+
 **`from:` is required wherever there is anything to preview.** `translate`
 can leave `from:` unset and pay for one `#detect` request to find it; a
 preview never calls the provider, so it cannot pay for that request either.
diff --git a/docs/configuration.md b/docs/configuration.md
index ceecf5f..1bc5b01 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -69,7 +69,7 @@ at all, so an unset environment variable never has to be special-cased.
 | `rate_interval` | `60` | Seconds over which `rate_limit` (or a limiter's own default threshold) is measured. **Actually enforced over roughly 5-600 seconds** -- see [The rate limiter contract](contracts.md#the-rate-limiter-contract). |
 | `rate_limiter` | `nil` | A registered name (`:redis`, `:active_record`) or an object satisfying the [rate limiter contract](contracts.md#the-rate-limiter-contract). `nil` with `rate_limit` also `nil` means no rate limiting; `nil` with `rate_limit` set resolves to `:redis`. Setting `rate_limiter` alone -- with `rate_limit` left unset -- is enough to turn rate limiting on, at the limiter's own default threshold; it no longer needs `rate_limit` set to avoid crashing. |
 | `segmenter` | `:pragmatic` | The sentence segmenter: a registered name or an object satisfying the [segmenter contract](contracts.md#the-segmenter-contract). |
-| `opaque_elements` | `%i[script style pre code]` | Element names `TranslationDiff::Passage` never treats as prose, whatever they contain. Read fresh on every passage rather than memoised, so a runtime change applies immediately, to the next translation. See [How it works](how-it-works.md#html). |
+| `opaque_elements` | `%i[script style pre code]` | Element names `TranslationDiff::Passage` never treats as prose, whatever they contain, matched case-insensitively so `
` and `