horologium 0.0.3 → 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 513d825c2d141d84274c76333af312631c4b9a077803d2e2921033782af518ce
4
- data.tar.gz: '0108ee60e9bf9c1f149baefafe6e78f8d735dd824148ed91c69267b5ca10aad8'
3
+ metadata.gz: 65e0d322bb82639c79c818b3c8473f507ecfcb25215c522f20d41c5ef7f5be25
4
+ data.tar.gz: 1d2d822d56768a6d9383cc29a700208e03ef3e6f639460d1a99b4eb18e01e1de
5
5
  SHA512:
6
- metadata.gz: edfa8b82fc1b2d4f795abbf0c01b0a9753cbfb31aa03c37de952b5ea64e62687207bb4efde740d05246ec9df63b6226d682d998ef5d917e401f48fae2cf4402d
7
- data.tar.gz: a75b50d13419e546ae0b8cca4d0dce80d078a90578e3e80664a65b2f207ae5535900eb9f36a6291b5a303ce41ccd834b9cd5c226c46e4992d081cf1949b2ba56
6
+ metadata.gz: b4c87cfb9449bb41e362d2596c03ff44e2907ab593c332eb37155fb833b7d8a366911eece54a0759fff1e72410599c244c82d6757d49df66edf904783115f811
7
+ data.tar.gz: 3fda2d236fb3c9afaa33e921a0a3e3738518df46cefc9e10146acf590f144cdd67f4c162448fef7c2a002ba62cd74efbd45f500b069c91e8a8020341ec082f22
data/CHANGELOG.md CHANGED
@@ -1,5 +1,41 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.0.4 - 2026-09-05
4
+
5
+ The epochs astronomy counts from arrive as instants, a duration reads back in
6
+ the unit you want it in, and the conversions cost a good deal less than they
7
+ did.
8
+
9
+ ### Features
10
+
11
+ - Add `Epochs`, with `J2000`, `J1900`, `GPS_ZERO`, `UNIX` and
12
+ `TT_TCG_TCB_ORIGIN`. An epoch is an ordinary `Instant`, so the time elapsed
13
+ since one is a subtraction and no Julian Date is involved
14
+ - Add the `Duration` constructors `minutes`, `hours`, `julian_years`,
15
+ `julian_centuries` and `zero`, where a Julian year is exactly 365.25 days and
16
+ a Julian century 36,525
17
+ - Add `Duration#in_seconds`, `#in_minutes`, `#in_hours`, `#in_days`,
18
+ `#in_julian_years` and `#in_julian_centuries`, which come out as a Float at
19
+ `:standard` and a Rational at `:exact`
20
+ - Add `zero?`, `negative?` and `positive?` to `Numeric::TwoPartFloat` and
21
+ `Numeric::Exact`, and `Numeric::Precision.compare`, which orders two values
22
+ by the number they denote whatever precision each is held in
23
+
24
+ ### Improvements
25
+
26
+ - Build an instant in about half the time at `:standard` and a third less at
27
+ `:exact`, allocating 3 objects where it allocated 30. An instant no longer
28
+ works out its exact Rational value when it is built, the two-part arithmetic
29
+ keeps its intermediate parts in Floats, and a Julian Date given as a single
30
+ Float skips a step it does not need
31
+ - Read UTC twice as fast at `:standard` and a third faster at `:exact`. The
32
+ conversion reads each leap second offset once, and settles the day without
33
+ spelling the value out as a Rational
34
+ - Read `ScaleReading#provenance` from the scale that took the reading, when it
35
+ is asked rather than on every reading
36
+
37
+ **Full Changelog**: https://github.com/rhannequin/horologium/compare/v0.0.3...v0.0.4
38
+
3
39
  ## 0.0.3 - 2026-08-22
4
40
 
5
41
  The scale conversions arrive. An instant is now a point with no scale of its
data/README.md CHANGED
@@ -94,6 +94,10 @@ Horologium::Instant.from_tai(2025, 5, 1, 12, 0, 0)
94
94
  Horologium::Instant.from_tdb(2025, 5, 1, 12, 0, 0)
95
95
  ```
96
96
 
97
+ TDB is the one scale that rests on a model rather than a definition. Horologium
98
+ runs the full 787-term Fairhead and Bretagnon series, the one [ERFA] evaluates
99
+ in `dtdb`, rather than a truncation of it.
100
+
97
101
  A date that does not exist is refused rather than rolled over, and the message
98
102
  says which field is wrong.
99
103
 
@@ -218,6 +222,20 @@ so a duration and a calendar day are different things.
218
222
  Horologium::Duration.days(1) == Horologium::Duration.seconds(86_400) # => true
219
223
  Horologium::Duration.nanoseconds(1_000_000_000) ==
220
224
  Horologium::Duration.seconds(1) # => true
225
+
226
+ Horologium::Duration.minutes(90)
227
+ Horologium::Duration.hours(6)
228
+ Horologium::Duration.zero
229
+ ```
230
+
231
+ A Julian year is exactly 365.25 days and a Julian century is 36,525 days. They
232
+ are astronomical constants. A calendar year holds 365 or 366 days, so a Julian
233
+ year lands a few hours away from the same date next year.
234
+
235
+ ```rb
236
+ Horologium::Duration.julian_years(1) ==
237
+ Horologium::Duration.days(365.25) # => true
238
+ Horologium::Duration.julian_centuries(0.25)
221
239
  ```
222
240
 
223
241
  Durations add, subtract, and negate among themselves, and read back out in SI
@@ -232,6 +250,19 @@ Horologium::Duration.days(1).to_r # => (86400/1), the whole value
232
250
  Horologium::Duration.days(1).to_f # => 86400.0
233
251
  ```
234
252
 
253
+ A duration also reads in a unit. The division happens inside the precision the
254
+ duration is held in, so a `:standard` duration keeps the digits a collapsed
255
+ `Float` would lose, and an `:exact` one reads as a `Rational`.
256
+
257
+ ```rb
258
+ Horologium::Duration.days(1).in_hours # => 24.0
259
+ Horologium::Duration.hours(12).in_days # => 0.5
260
+ Horologium::Duration.days(36_525).in_julian_centuries # => 1.0
261
+
262
+ exact = Horologium::Duration.julian_years(1, precision: :exact)
263
+ exact.in_days # => (1461/4)
264
+ ```
265
+
235
266
  Adding a duration to an instant makes sense, but adding two instants together
236
267
  does not, so it raises an error.
237
268
 
@@ -239,6 +270,30 @@ does not, so it raises an error.
239
270
  instant + instant # => raises Horologium::DimensionalError
240
271
  ```
241
272
 
273
+ The astronomical epochs are built as instants, so the elapsed time since one is
274
+ a subtraction and no Julian Date is involved.
275
+
276
+ ```rb
277
+ instant = Horologium::Instant.from_tt(2026, 1, 1)
278
+
279
+ (instant - Horologium::Epochs::J2000).to_f # => 820497600.0 SI seconds
280
+ ```
281
+
282
+ The scale is part of an epoch's definition. `J2000` is noon TT, which is 64.184
283
+ seconds away from noon UTC on the same day.
284
+
285
+ ```rb
286
+ Horologium::Epochs::J2000 # 2000-01-01 12:00:00 TT
287
+ Horologium::Epochs::J1900 # 1899-12-31 12:00:00 TT
288
+ Horologium::Epochs::GPS_ZERO # 1980-01-06 00:00:00 UTC
289
+ Horologium::Epochs::UNIX # 1970-01-01 00:00:00 UTC
290
+ Horologium::Epochs::TT_TCG_TCB_ORIGIN # 1977-01-01 00:00:00 TAI
291
+ ```
292
+
293
+ An epoch is fixed at `:exact`, since it is a definition. Subtracting one from a
294
+ `:standard` instant gives an `:exact` duration, the way any other mix of the two
295
+ does.
296
+
242
297
  Exact equality is rarely what scientific code wants, so you can compare within a
243
298
  tolerance:
244
299
 
@@ -261,8 +316,8 @@ with ordinary floating-point arithmetic.
261
316
 
262
317
  Every value carries one of two precisions, fixed when it is built:
263
318
 
264
- - `:standard`, the default, keeps the value as a two-part float. It is fast and
265
- stays within a few nanoseconds of the true value.
319
+ - `:standard`, the default, keeps the value as a two-part float. It stays
320
+ within a few nanoseconds of the true value.
266
321
  - `:exact` keeps the value as a `Rational`, with no rounding. The test suite
267
322
  uses it to check that `:standard` stays within its stated precision.
268
323
 
@@ -291,12 +346,30 @@ Horologium.with_precision(:exact) do
291
346
  end
292
347
  ```
293
348
 
349
+ A library can depend on Horologium without taking the configuration away from
350
+ the application that depends on it. Reading the configuration does not freeze
351
+ it, so a gem that converts an instant while it loads still leaves
352
+ `Horologium.configure` open to the host. And a value built with an explicit
353
+ `precision:` ignores the default, so a gem that pins its own precision computes
354
+ the same thing whatever the host sets.
355
+
294
356
  Exactness is contagious. An operation between two `:standard` values stays
295
357
  `:standard`. Mixing a `:standard` and an `:exact` value gives an `:exact`
296
358
  result, so precision is not quietly lost. `:exact` guarantees the arithmetic
297
359
  Horologium performs. It cannot bring back precision that an input already lost
298
360
  when it was built.
299
361
 
362
+ A `:standard` value turns a `Rational` into a pair of `Float`s on the way in and
363
+ back again on the way out, and that conversion costs more than the arithmetic it
364
+ feeds. So `:exact` is the faster one whenever a `Rational` goes in or comes out,
365
+ by up to about twice on a build and a read. With a `Float` in and a `Float` out
366
+ the two are close enough that the machine decides. `:standard` is the cheaper
367
+ one for a long run of arithmetic between values already held that way. Mixing
368
+ the two is the expensive case, because promoting a `:standard` value to a
369
+ `Rational` costs more than the operation it is promoted for, so inside a loop it
370
+ is worth keeping to one precision. `bin/benchmark` measures all of this on your
371
+ own machine.
372
+
300
373
  ## Status
301
374
 
302
375
  This library is in early development, before its first public release. The
@@ -315,6 +388,12 @@ which is enforced at 100% of lines and branches in CI. You can also run
315
388
  holds stubs for gems that ship none of their own, and stays out of the gem so
316
389
  it cannot clash with a downstream RBS collection.
317
390
 
391
+ Run `bin/benchmark` to time the paths a consumer runs in a loop and count the
392
+ objects each one allocates. Timings drift by a few percent between runs, so it
393
+ rotates the order of the cases and reports the fastest round for each. The
394
+ allocation counts do not drift, so where a timing and a count disagree, trust
395
+ the count. Run it on a branch and on `main` to compare.
396
+
318
397
  Run `bin/ci` to run every check that GitHub Actions runs (RuboCop, Steep, YARD
319
398
  documentation coverage, and the tests with coverage) in a single pass. It runs
320
399
  each check even when an earlier one fails, so you see everything that needs
@@ -15,15 +15,31 @@ module Horologium
15
15
  # Adding an Instant to a Duration raises {DimensionalError}; it is
16
16
  # {Instant#+} that shifts a point by a span.
17
17
  #
18
+ # A duration reads back in a unit with {#in_seconds}, {#in_minutes},
19
+ # {#in_hours}, {#in_days}, {#in_julian_years} and {#in_julian_centuries}.
20
+ # They come out as a Float at +:standard+ and a Rational at +:exact+.
21
+ #
18
22
  # @example A day is a fixed number of SI seconds
19
23
  # Horologium::Duration.days(1) == Horologium::Duration.seconds(86_400)
20
24
  # # => true
21
25
  class Duration
22
26
  include PreciseValue
23
27
 
28
+ # The number of SI seconds in a minute.
29
+ SECONDS_PER_MINUTE = 60
30
+
31
+ # The number of SI seconds in an hour.
32
+ SECONDS_PER_HOUR = 3_600
33
+
24
34
  # The number of SI seconds in a day.
25
35
  SECONDS_PER_DAY = 86_400
26
36
 
37
+ # The number of SI seconds in a Julian year of 365.25 days.
38
+ SECONDS_PER_JULIAN_YEAR = 31_557_600
39
+
40
+ # The number of SI seconds in a Julian century of 36,525 days.
41
+ SECONDS_PER_JULIAN_CENTURY = 3_155_760_000
42
+
27
43
  # The number of nanoseconds in a second.
28
44
  NANOSECONDS_PER_SECOND = 1_000_000_000
29
45
 
@@ -40,6 +56,30 @@ module Horologium
40
56
  from_seconds(count, precision)
41
57
  end
42
58
 
59
+ # A duration of +count+ minutes.
60
+ #
61
+ # @param count [Numeric] the number of minutes
62
+ # @param precision [Symbol] +:standard+ or +:exact+, taken from the
63
+ # precision in effect when omitted
64
+ # @return [Horologium::Duration]
65
+ # @example
66
+ # Horologium::Duration.minutes(90)
67
+ def minutes(count, precision: Horologium.current_precision)
68
+ from_seconds(count * SECONDS_PER_MINUTE, precision)
69
+ end
70
+
71
+ # A duration of +count+ hours.
72
+ #
73
+ # @param count [Numeric] the number of hours
74
+ # @param precision [Symbol] +:standard+ or +:exact+, taken from the
75
+ # precision in effect when omitted
76
+ # @return [Horologium::Duration]
77
+ # @example
78
+ # Horologium::Duration.hours(6)
79
+ def hours(count, precision: Horologium.current_precision)
80
+ from_seconds(count * SECONDS_PER_HOUR, precision)
81
+ end
82
+
43
83
  # A duration of +count+ days, each of {SECONDS_PER_DAY} SI seconds. This
44
84
  # counts time and is not tied to the calendar.
45
85
  #
@@ -54,6 +94,36 @@ module Horologium
54
94
  from_seconds(count * SECONDS_PER_DAY, precision)
55
95
  end
56
96
 
97
+ # A duration of +count+ Julian years, each of exactly 365.25 days. It is
98
+ # the astronomical constant, and a calendar year holds 365 or 366 days,
99
+ # so shifting an instant by a Julian year lands a few hours away from
100
+ # the same date next year.
101
+ #
102
+ # @param count [Numeric] the number of Julian years
103
+ # @param precision [Symbol] +:standard+ or +:exact+, taken from the
104
+ # precision in effect when omitted
105
+ # @return [Horologium::Duration]
106
+ # @example
107
+ # Horologium::Duration.julian_years(1) ==
108
+ # Horologium::Duration.days(365.25) # => true
109
+ def julian_years(count, precision: Horologium.current_precision)
110
+ from_seconds(count * SECONDS_PER_JULIAN_YEAR, precision)
111
+ end
112
+
113
+ # A duration of +count+ Julian centuries, each of a hundred Julian
114
+ # years, or 36,525 days. It is the unit the astronomical series count
115
+ # their time in.
116
+ #
117
+ # @param count [Numeric] the number of Julian centuries
118
+ # @param precision [Symbol] +:standard+ or +:exact+, taken from the
119
+ # precision in effect when omitted
120
+ # @return [Horologium::Duration]
121
+ # @example
122
+ # Horologium::Duration.julian_centuries(0.25)
123
+ def julian_centuries(count, precision: Horologium.current_precision)
124
+ from_seconds(count * SECONDS_PER_JULIAN_CENTURY, precision)
125
+ end
126
+
57
127
  # A duration of +count+ nanoseconds.
58
128
  #
59
129
  # @param count [Numeric] the number of nanoseconds
@@ -66,6 +136,17 @@ module Horologium
66
136
  from_seconds(Rational(count) / NANOSECONDS_PER_SECOND, precision)
67
137
  end
68
138
 
139
+ # A duration of no time at all.
140
+ #
141
+ # @param precision [Symbol] +:standard+ or +:exact+, taken from the
142
+ # precision in effect when omitted
143
+ # @return [Horologium::Duration]
144
+ # @example
145
+ # Horologium::Duration.zero.zero? # => true
146
+ def zero(precision: Horologium.current_precision)
147
+ from_seconds(0, precision)
148
+ end
149
+
69
150
  private
70
151
 
71
152
  # Builds a duration of +seconds+ SI seconds at the given precision. At
@@ -134,24 +215,83 @@ module Horologium
134
215
 
135
216
  # @return [Boolean]
136
217
  def zero?
137
- rational.zero?
218
+ value.zero?
138
219
  end
139
220
 
140
221
  # @return [Boolean]
141
222
  def negative?
142
- rational.negative?
223
+ value.negative?
143
224
  end
144
225
 
145
226
  # @return [Boolean]
146
227
  def positive?
147
- rational.positive?
228
+ value.positive?
229
+ end
230
+
231
+ # The duration in SI seconds.
232
+ #
233
+ # @return [Float, Rational] a Float at +:standard+, a Rational at
234
+ # +:exact+
235
+ # @example
236
+ # Horologium::Duration.days(1).in_seconds # => 86400.0
237
+ def in_seconds
238
+ in_unit(1)
239
+ end
240
+
241
+ # The duration in minutes of {SECONDS_PER_MINUTE} SI seconds each.
242
+ #
243
+ # @return [Float, Rational] a Float at +:standard+, a Rational at
244
+ # +:exact+
245
+ # @example
246
+ # Horologium::Duration.hours(1).in_minutes # => 60.0
247
+ def in_minutes
248
+ in_unit(SECONDS_PER_MINUTE)
249
+ end
250
+
251
+ # The duration in hours of {SECONDS_PER_HOUR} SI seconds each.
252
+ #
253
+ # @return [Float, Rational] a Float at +:standard+, a Rational at
254
+ # +:exact+
255
+ # @example
256
+ # Horologium::Duration.days(1).in_hours # => 24.0
257
+ def in_hours
258
+ in_unit(SECONDS_PER_HOUR)
259
+ end
260
+
261
+ # The duration in days of {SECONDS_PER_DAY} SI seconds each.
262
+ #
263
+ # @return [Float, Rational] a Float at +:standard+, a Rational at
264
+ # +:exact+
265
+ # @example
266
+ # Horologium::Duration.hours(12).in_days # => 0.5
267
+ def in_days
268
+ in_unit(SECONDS_PER_DAY)
269
+ end
270
+
271
+ # The duration in Julian years of 365.25 days each.
272
+ #
273
+ # @return [Float, Rational] a Float at +:standard+, a Rational at
274
+ # +:exact+
275
+ def in_julian_years
276
+ in_unit(SECONDS_PER_JULIAN_YEAR)
277
+ end
278
+
279
+ # The duration in Julian centuries of 36,525 days each. It is the time
280
+ # argument the astronomical series are written for.
281
+ #
282
+ # @return [Float, Rational] a Float at +:standard+, a Rational at
283
+ # +:exact+
284
+ # @example
285
+ # Horologium::Duration.days(36_525).in_julian_centuries # => 1.0
286
+ def in_julian_centuries
287
+ in_unit(SECONDS_PER_JULIAN_CENTURY)
148
288
  end
149
289
 
150
290
  # The duration in SI seconds, exactly.
151
291
  #
152
292
  # @return [Rational]
153
293
  def to_r
154
- rational
294
+ value.to_r
155
295
  end
156
296
 
157
297
  # The duration in SI seconds. A Float has about 15 digits, so a long
@@ -166,5 +306,19 @@ module Horologium
166
306
  def inspect
167
307
  format("#<%s %s s (%s)>", self.class, to_f, precision)
168
308
  end
309
+
310
+ private
311
+
312
+ # The duration counted in a unit. The division happens in the precision
313
+ # the duration is held in, so the digits survive it.
314
+ #
315
+ # @param seconds_per_unit [Integer] the SI seconds one unit holds
316
+ # @return [Float, Rational] a Float at +:standard+, a Rational at
317
+ # +:exact+
318
+ def in_unit(seconds_per_unit)
319
+ return value.to_r / seconds_per_unit if precision == :exact
320
+
321
+ (value / seconds_per_unit).to_f
322
+ end
169
323
  end
170
324
  end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Horologium
4
+ # The astronomical epochs, built as instants. An epoch is an ordinary
5
+ # {Instant}, so the elapsed time since one is a subtraction and no Julian
6
+ # Date is involved.
7
+ #
8
+ # The scale is part of an epoch's definition. {J2000} is noon TT, which is
9
+ # 64.184 seconds away from noon UTC on the same day. The two epochs defined
10
+ # in UTC are given in TAI, with the TAI - UTC offset of their day written
11
+ # into the constant, so requiring the library reads no leap second data.
12
+ #
13
+ # An epoch is fixed at +:exact+, since it is a definition. Subtracting one
14
+ # from a +:standard+ instant gives an +:exact+ {Duration}, the way any other
15
+ # mix of the two does.
16
+ #
17
+ # @example The time elapsed since J2000
18
+ # instant = Horologium::Instant.from_tt(2026, 1, 1)
19
+ # (instant - Horologium::Epochs::J2000).to_f # => 820497600.0
20
+ module Epochs
21
+ # 2000-01-01 12:00:00 TT, Julian Date 2451545.0 in TT. The epoch the
22
+ # current theories of the solar system motion are written for.
23
+ J2000 = Instant.from_tt(2000, 1, 1, 12, precision: :exact)
24
+
25
+ # 1899-12-31 12:00:00 TT, Julian Date 2415020.0 in TT. It is one Julian
26
+ # century of 36,525 days before {J2000}, and it is what the older formulae
27
+ # count from. The date is a day earlier than the name suggests.
28
+ J1900 = Instant.from_tt(1899, 12, 31, 12, precision: :exact)
29
+
30
+ # 1980-01-06 00:00:00 UTC, where GPS time starts. TAI - UTC was 19 seconds
31
+ # on that day. GPS time counts SI seconds from here, so it stays a fixed
32
+ # offset from TAI.
33
+ GPS_ZERO = Instant.from_tai(1980, 1, 6, 0, 0, 19, precision: :exact)
34
+
35
+ # 1970-01-01 00:00:00 UTC, where Unix time starts. UTC was still steered
36
+ # by rate adjustments in 1970, so TAI - UTC was 8.000082 seconds on that
37
+ # day.
38
+ UNIX = Instant.from_tai(
39
+ 1970, 1, 1, 0, 0,
40
+ Rational(8_000_082, 1_000_000),
41
+ precision: :exact
42
+ )
43
+
44
+ # 1977-01-01 00:00:00 TAI, Julian Date 2443144.5003725 in TT. TT, TCG and
45
+ # TCB were set to read the same here, and the rates that separate them are
46
+ # counted from this point.
47
+ TT_TCG_TCB_ORIGIN = Instant.from_tai(1977, 1, 1, precision: :exact)
48
+ end
49
+ end
@@ -440,12 +440,7 @@ module Horologium
440
440
  time_scale = Horologium.configuration.scale(scale)
441
441
  reading = time_scale.from_reference(value, precision)
442
442
 
443
- ScaleReading.new(
444
- scale,
445
- reading,
446
- precision,
447
- time_scale.provenance(reading)
448
- )
443
+ ScaleReading.new(scale, reading, precision, time_scale)
449
444
  end
450
445
 
451
446
  # The instant in a representation, read in a scale. This is the shorthand
@@ -54,6 +54,21 @@ module Horologium
54
54
  self.class.new(value / scalar_rational(scalar))
55
55
  end
56
56
 
57
+ # @return [Boolean]
58
+ def zero?
59
+ value.zero?
60
+ end
61
+
62
+ # @return [Boolean]
63
+ def negative?
64
+ value.negative?
65
+ end
66
+
67
+ # @return [Boolean]
68
+ def positive?
69
+ value.positive?
70
+ end
71
+
57
72
  # @param other [Object]
58
73
  # @return [Boolean]
59
74
  def ==(other)
@@ -25,9 +25,12 @@ module Horologium
25
25
  # @return [Symbol] the same precision
26
26
  # @raise [UnknownPrecisionError] when it is not one of {NAMES}
27
27
  def validate!(precision)
28
- return precision if NAMES.include?(precision)
29
-
30
- raise UnknownPrecisionError.new(precision, NAMES)
28
+ case precision
29
+ when :standard, :exact
30
+ precision
31
+ else
32
+ raise UnknownPrecisionError.new(precision, NAMES)
33
+ end
31
34
  end
32
35
 
33
36
  # The precision a result takes from its two operands. Same precision
@@ -102,6 +105,8 @@ module Horologium
102
105
  def add(left, right)
103
106
  if left.is_a?(TwoPartFloat) && right.is_a?(TwoPartFloat)
104
107
  left + right
108
+ elsif left.is_a?(Exact) && right.is_a?(Exact)
109
+ left + right
105
110
  else
106
111
  promote(left) + promote(right)
107
112
  end
@@ -116,11 +121,36 @@ module Horologium
116
121
  def subtract(left, right)
117
122
  if left.is_a?(TwoPartFloat) && right.is_a?(TwoPartFloat)
118
123
  left - right
124
+ elsif left.is_a?(Exact) && right.is_a?(Exact)
125
+ left - right
119
126
  else
120
127
  promote(left) - promote(right)
121
128
  end
122
129
  end
123
130
 
131
+ # Orders two values by the number they denote, whatever precision each
132
+ # is held in. Two two-part floats are compared through the difference
133
+ # of their parts, which is where the answer already is.
134
+ #
135
+ # That difference answers only when it is finite and away from zero.
136
+ # Reaching zero says the values are close rather than equal, and parts
137
+ # large enough to overflow can cancel into a NaN, so both cases are
138
+ # settled exactly instead.
139
+ #
140
+ # @param left [TwoPartFloat, Exact] one value
141
+ # @param right [TwoPartFloat, Exact] the other value
142
+ # @return [Integer] -1, 0, or 1
143
+ def compare(left, right)
144
+ if left.is_a?(TwoPartFloat) && right.is_a?(TwoPartFloat)
145
+ difference = (left.high - right.high) + (left.low - right.low)
146
+
147
+ return sign_of(difference) if difference.finite? &&
148
+ !difference.zero?
149
+ end
150
+
151
+ sign_of(left.to_r - right.to_r)
152
+ end
153
+
124
154
  # Checks that a value matches a precision: a {TwoPartFloat} for
125
155
  # +:standard+, an {Exact} for +:exact+.
126
156
  #
@@ -147,11 +177,28 @@ module Horologium
147
177
  # @return [Class] the type its values take
148
178
  # @raise [UnknownPrecisionError] when the precision is not recognised
149
179
  def value_type(precision)
150
- (validate!(precision) == :exact) ? Exact : TwoPartFloat
180
+ case precision
181
+ when :standard
182
+ TwoPartFloat
183
+ when :exact
184
+ Exact
185
+ else
186
+ raise UnknownPrecisionError.new(precision, NAMES)
187
+ end
151
188
  end
152
189
 
153
190
  private
154
191
 
192
+ # Where a number sits against zero.
193
+ #
194
+ # @param number [Float, Rational] the number to read
195
+ # @return [Integer] -1 below zero, 0 at it, 1 above it
196
+ def sign_of(number)
197
+ return 0 if number.zero?
198
+
199
+ number.negative? ? -1 : 1
200
+ end
201
+
155
202
  # One side of an operation, as an exact value.
156
203
  #
157
204
  # @param value [TwoPartFloat, Exact] the value to promote