Thanks for this week's interesting problem.  My solution is below and
I look forward to any feedback and seeing other techniques used.

There are two files pasted below; the second is the unit test, which
takes about 5-10 minutes to run to completion.  My first unit test
ever in any language :)

This is also my first greeting to the Ruby community... Hello!

Cheers,
Marcel


#!/usr/bin/env ruby
#
# Marcel Ward   <wardies ^a-t^ gmaildotcom>
# Sunday, 29 October 2006
# Solution for Ruby Quiz number 99
#
################################################
# fuzzy_time.rb

class FuzzyTime
  attr_reader :actual, :timed_observation_period

  # If the time passed is nil, then keep track of the time now.
  def initialize(tm=nil, range_secs=600, disp_accuracy_secs=range_secs,
                  fmt="%H:%M", obs_period=nil)
    @actual = @last_update = @next_diff = tm || Time.now
    @realtime = tm.nil?
    @maxrange = range_secs
    @display_accuracy = @best_accuracy = disp_accuracy_secs
    @tformat = fmt
    @last_observed = @max_disptime = Time.at(0)
    @timed_observation_period = obs_period
  end

  def to_s
    @last_update = Time.now
    @actual = @last_update if @realtime

    check_observation_period unless @timed_observation_period.nil?

	# We only calculate a new offset each time the last offset times out.
    if @next_diff <= @actual
      # Calculate a new time offset
      @diff = rand(@maxrange) - @maxrange/2
      # Decide when to calculate the next time offset
      @next_diff = @actual + rand(@maxrange)
    end
    @last_observed = @actual

    # Don't display a time less than the time already displayed
    @max_disptime = [@max_disptime, @actual + @diff].max

    # Take care to preserve any specific locale (time zone / dst) information
    # stored in @actual - for example, we cannot use Time::at(Time::to_i).
    disptime = @max_disptime.strftime(@tformat)

    # Lop off characters from the right of the display string until the
    # remaining string matches one of the extreme values; then fuzz out the
    # rightmost digits
    (0..disptime.size).to_a.reverse.each do
      |w|
      [@display_accuracy.div(2), - @display_accuracy.div(2)].map{
        |offs|
        (@max_disptime + offs).strftime(@tformat)
      }.each do
        |testtime|
        return disptime[0,w] + disptime[w..-1].tr("0123456789", "~") if \
          disptime[0,w] == testtime[0,w]
      end
    end
  end

  def advance(secs)
    if @realtime
      @actual = Time.now + secs
      # Once a real-time FuzzyTime is advanced, it can never again be
      # real-time.
      @realtime = false
    else
      @actual += secs
    end
    @last_update = Time.now
  end

  def update
    diff = Time.now - @last_update
    @actual += diff
    @last_update += diff
    # By calling update, you are effectively saying "set a fixed time"
    # so we must disable the real-time flag.
    @realtime = false
  end

  def accuracy
    "+/- #{@maxrange/2}s"
  end

  def dump
    "actual: #{@actual.strftime("%Y-%m-%d %H:%M:%S")}, " \
		"diff: #{@diff}, " \
		"next_diff: #{@next_diff.strftime("%Y-%m-%d %H:%M:%S")}, " \
		"accuracy: #{@display_accuracy}"
  end

private
  def check_observation_period
    # Is the clock being displayed too often?

    # Although this method seems to work, it may be a bit simplistic.
    # Proper statistical / mathematical analysis and a proper understanding
    # of the human ability to count seconds may be necessary to determine
    # whether this still gives away too much info for the average observer.

    patience = @actual - @last_observed

    if patience < @timed_observation_period / 2
      # Worsen display accuracy according to how impatient the observer is.
      @display_accuracy += (2 * @best_accuracy *
                            (@timed_observation_period - patience)) /
                            @timed_observation_period
    elsif patience < @timed_observation_period
      # Immediately punish impatience by enforcing a minumum accuracy
      # twice as bad as the best possible.
      # Don't give too much away but allow the accuracy to get slowly better
      # if the observer is a bit more patient and waits over half the
      # observation period
      @display_accuracy = [
          2 * @best_accuracy,
          @display_accuracy - ((@best_accuracy * patience) /
                                @timed_observation_period)
        ].max
    else
      # The observer has waited long enough.
      # Reset to the best possible accuracy.
      @display_accuracy = @best_accuracy
    end
  end
end

def wardies_clock
  # Get us a real-time clock by initializing Time with first parameter==nil
  # Make the seconds harder to guess by expanding the range to +/- 15s whilst
  # keeping the default display accuracy to +/- 5 secs.  The user will have
  # to wait 30s between observations to see the clock with best accuracy.
  ft = FuzzyTime.new(nil, 30, 10, "%H:%M:%S", 30)

  # This simpler instantiation does not check the observation period and
  # shows "HH:M~". (This is the default when no parameters are provided)
  #ft = FuzzyTime.new(nil, 600, 600, "%H:%M")

  puts "** Wardies Clock\n"
  puts "**\n** Observing more often than every " \
    "#{ft.timed_observation_period} seconds reduces accuracy" \
    unless ft.timed_observation_period.nil?
  puts "**\n\n"

  loop do
    puts "\n\nTime Now: #{ft.to_s}  (#{ft.accuracy})\n\n" \
      "-- Press Enter to observe the clock again or " \
      "q then Enter to quit --\n\n"

    # Flush the output text so that we can scan for character input.
    STDOUT.flush

    break if STDIN.getc == ?q
  end
end


def clocks_go_back_in_uk
  # Clocks go back in the UK on Sun Oct 29. (+0100 => +0000)
  # Start at Sun Oct 29 01:58:38 +0100 2006
  ft = FuzzyTime.new(Time.at(Time.at(1162083518)))

  # In the UK locale, we see time advancing as follows:
  # 01:5~
  # 01:5~
  # 01:0~  (clocks gone back one hour)
  # 01:0~
  # ...
  # 01:0~
  # 01:1~

  60.times do
    puts ft.to_s
    ft.advance(rand(30))
  end
end

def full_date_example
  # Accuracy can be set very high to fuzz out hours, days, etc.
  # E.g. accuracy of 2419200 (28 days) fuzzes out the day of the month
  # Note the fuzz factoring does not work so well with hours and
  # non-30-day months because these are not divisble exactly by 10.

  tm = FuzzyTime.new(nil, 2419200, 2419200, "%Y-%m-%d %H:%M:%S")
  300.times do
    puts "#{tm.to_s} (#{tm.dump})"
    # advance by about 23 days
    tm.advance(rand(2000000))
    #sleep 0.2
  end
end

# Note, all the examples given in the quiz are for time zone -0600.
# If you are in a different timezone, you should see other values.
def quiz_example
  ft = FuzzyTime.new                      # Start at the current time
  ft = FuzzyTime.new(Time.at(1161104503)) # Start at a specific time

  p ft.to_s                               # to_s format

  p ft.actual, ft.actual.class            # Reports real time as Time
  #=> Tue Oct 17 11:01:36 -0600 2006
  #=> Time

  ft.advance( 60 * 10 )                   # Manually advance time
  puts ft                                 # by a specified number of
  #=> 11:0~                               # seconds.

  sleep( 60 * 10 )

  ft.update              # Automatically update the time based on the
  puts ft                # time that has passed since the last call
  #=> 11:1~              # to #initialize, #advance or #update
end

if __FILE__ == $0
  wardies_clock
  #clocks_go_back_in_uk
  #full_date_example
  #quiz_example
end


################################################
# fuzzy_time_test.rb

require 'test/unit'
require 'fuzzy_time'

class FuzzyTime_Test < Test::Unit::TestCase
  #def setup
  #end

  #def teardown
  #end

  def test_advance

    # Initialize with a known UTC time (Tue Jun 10 03:14:52 UTC 1975)
    ft = FuzzyTime.new(Time.at(171602092).getgm, 60, 60, "%H:%M:%S")

    # Add 6 hours 45 minutes 30 secs to give us
    # (Tue Jun 10 09:59:22 UTC 1975)
    ft.advance(3600*6 + 60*45 + 30)
    @last_output = ""

    60.times do
      # Initial displayed time sourced from between 09:58:52 and 09:59:52
      # Time will be advanced by between 0 and 600 seconds.
      # So final displayed time source ranges from 10:08:52 to 10:09:52

      # The array of legal output strings:
      @legal  = ["09:58:~~", "09:59:~~", "10:00:~~", "10:01:~~",
        "10:02:~~", "10:03:~~", "10:04:~~", "10:05:~~",
        "10:06:~~", "10:07:~~", "10:08:~~", "10:09:~~"]

      @output = ft.to_s

      assert_block "#@output not one of #{@legal.inspect}" do
        @legal.include?( @output )
      end

      assert_block  "#@output must be greater than or equal to " \
        "last value, #@last_output" \
      do
        @output >= @last_output
      end
      @last_output = @output

      ft.advance( rand( 11 ) )
    end
  end

  def test_advance_rollover
    # Initialize with a known UTC time (Fri Dec 31 23:58:25 UTC 1999)
    # Test rollover at midnight
    # Note, we have an accuracy of +/- 5 secs now and enabled the
    # observations timer
    ft = FuzzyTime.new(Time.at(946684705).getgm, 10, 10, "%H:%M:%S", 10)

    30.times do
      # Initial displayed time sourced from between 23:58:20 and 23:58:30
      # Time will be advanced by between 0 and 150 seconds.
      # So final displayed time source ranges from 00:00:50 to 00:01:00

      # Note, if we watch too often over a short period of time,
      # our displayed accuracy will decrease.  Then we will lose
      # the 10's digit of the seconds and occasionally the 1's minute.

      # The array of legal output strings:
      @legal = ["23:58:1~", "23:58:2~", "23:58:3~",
        "23:58:4~", "23:58:5~", "23:58:6~",
        "23:58:~~", "23:59:~~", "23:5~:~~",
        "23:59:0~", "23:59:1~", "23:59:2~",
        "23:59:3~", "23:59:4~", "23:59:5~",
        "00:00:0~", "00:00:1~", "00:00:2~",
        "00:00:3~", "00:00:4~", "00:00:5~", "00:00:~~",
        "00:01:0~", "00:01:~~", "00:0~:~~"]

      @output = ft.to_s

      assert_block "#@output not one of #{@legal.inspect}" do
        @legal.include?( @output )
      end

      # We cannot easily check that the current output is greater or equal to
      # the last because with timed observations, a valid output sequence is:
      # 23:59:0~
      # 23:59:~~ (looking too often, accuracy has been reduced)
      # 23:59:0~ (waited long enough before observing for accuracy to return)

      ft.advance( rand(6) )
    end
  end

  def test_update
    # NOTE - this test takes 5-10 minutes to complete

    # Initialize with a known UTC time (Tue Jun 10 03:14:52 UTC 1975)
    ft = FuzzyTime.new(Time.at(171602092).getgm, 60, 60, "%H:%M:%S")
    @last_output = ""

    60.times do
      # Initial displayed time sourced from between 03:14:22 and 03:15:22
      # Duration of loop will be between 0 and ~600 seconds.
      # So final displayed time source ranges from 03:14:22 to 03:25:22

      # The array of legal output strings:
      @legal  = ["03:14:~~", "03:15:~~", "03:16:~~", "03:17:~~",
        "03:18:~~", "03:19:~~", "03:20:~~", "03:21:~~",
        "03:22:~~", "03:23:~~", "03:24:~~", "03:25:~~"]

      @output = ft.to_s

      assert_block "#@output not one of #{@legal.inspect}" do
        @legal.include?( @output )
      end

      assert_block  "#@output must be greater than or equal to " \
        "last value, #@last_output" \
      do
        @output >= @last_output
      end
      @last_output = @output

      sleep( rand( 11 ) ) # wait between 0..10 secs
      ft.update
    end
  end
end