horologium 0.0.2 → 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.
Files changed (50) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +79 -0
  3. data/README.md +271 -14
  4. data/lib/horologium/configuration.rb +199 -8
  5. data/lib/horologium/data/barycentric_model.rb +901 -0
  6. data/lib/horologium/data/leap_seconds.rb +55 -0
  7. data/lib/horologium/duration.rb +241 -13
  8. data/lib/horologium/epochs.rb +49 -0
  9. data/lib/horologium/error.rb +86 -3
  10. data/lib/horologium/instant.rb +405 -62
  11. data/lib/horologium/numeric/exact.rb +61 -60
  12. data/lib/horologium/numeric/precision.rb +138 -8
  13. data/lib/horologium/numeric/two_part_float.rb +195 -124
  14. data/lib/horologium/precise_value.rb +13 -16
  15. data/lib/horologium/representations/civil.rb +518 -0
  16. data/lib/horologium/representations/civil_time.rb +162 -0
  17. data/lib/horologium/representations/iso8601.rb +286 -0
  18. data/lib/horologium/representations/julian_date.rb +252 -0
  19. data/lib/horologium/representations/modified_julian_date.rb +106 -0
  20. data/lib/horologium/scale_reading.rb +141 -0
  21. data/lib/horologium/scales/base.rb +118 -0
  22. data/lib/horologium/scales/tai.rb +41 -0
  23. data/lib/horologium/scales/tdb.rb +75 -0
  24. data/lib/horologium/scales/tt.rb +76 -0
  25. data/lib/horologium/scales/utc.rb +408 -0
  26. data/lib/horologium/version.rb +1 -1
  27. data/lib/horologium.rb +15 -1
  28. data/sig/horologium/configuration.rbs +32 -0
  29. data/sig/horologium/data/barycentric_model.rbs +19 -0
  30. data/sig/horologium/data/leap_seconds.rbs +11 -0
  31. data/sig/horologium/duration.rbs +50 -0
  32. data/sig/horologium/epochs.rbs +13 -0
  33. data/sig/horologium/instant.rbs +34 -5
  34. data/sig/horologium/numeric/exact.rbs +12 -0
  35. data/sig/horologium/numeric/precision.rbs +14 -0
  36. data/sig/horologium/numeric/two_part_float.rbs +19 -6
  37. data/sig/horologium/precise_value.rbs +1 -3
  38. data/sig/horologium/representations/civil.rbs +63 -0
  39. data/sig/horologium/representations/civil_time.rbs +49 -0
  40. data/sig/horologium/representations/iso8601.rbs +27 -0
  41. data/sig/horologium/representations/julian_date.rbs +27 -0
  42. data/sig/horologium/representations/modified_julian_date.rbs +15 -0
  43. data/sig/horologium/scale_reading.rbs +35 -0
  44. data/sig/horologium/scales/base.rbs +17 -0
  45. data/sig/horologium/scales/tai.rbs +9 -0
  46. data/sig/horologium/scales/tdb.rbs +11 -0
  47. data/sig/horologium/scales/tt.rbs +17 -0
  48. data/sig/horologium/scales/utc.rbs +53 -0
  49. data/sig/horologium.rbs +32 -0
  50. metadata +51 -9
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8ea577f62fb959e825d069499184cafa4b1b15d4702700a1588d3ac4b00b7c2b
4
- data.tar.gz: 71b854eb59c20acaa9cd1adf77f17e97b2c0c03c9f62168dfba952f50f273dda
3
+ metadata.gz: 65e0d322bb82639c79c818b3c8473f507ecfcb25215c522f20d41c5ef7f5be25
4
+ data.tar.gz: 1d2d822d56768a6d9383cc29a700208e03ef3e6f639460d1a99b4eb18e01e1de
5
5
  SHA512:
6
- metadata.gz: 8a5144d687290a581ef5ec2861f58383d35e7c416032bfbf351bc6033ab9f5889c57a3cb48bd812e52d4d19ee0b0688e929fe1e05e0d44231ac6b41f7d8e9251
7
- data.tar.gz: a5070ecf423cf7d49623d547a133e9fb0907935843397f8336e989bc4e7816cbcc662af2acfed63913c852bdf5620ef03fd7ba0e660c50c9463f3d7aa22d6624
6
+ metadata.gz: b4c87cfb9449bb41e362d2596c03ff44e2907ab593c332eb37155fb833b7d8a366911eece54a0759fff1e72410599c244c82d6757d49df66edf904783115f811
7
+ data.tar.gz: 3fda2d236fb3c9afaa33e921a0a3e3738518df46cefc9e10146acf590f144cdd67f4c162448fef7c2a002ba62cd74efbd45f500b069c91e8a8020341ec082f22
data/CHANGELOG.md CHANGED
@@ -1,5 +1,84 @@
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
+
39
+ ## 0.0.3 - 2026-08-22
40
+
41
+ The scale conversions arrive. An instant is now a point with no scale of its
42
+ own, given in one scale and read back in another, and it comes out as a Julian
43
+ Date, a calendar date, or an ISO 8601 string.
44
+
45
+ ### Features
46
+
47
+ - Add time scales, each converting to and from TAI, the scale an instant is
48
+ stored in: `TAI`, `TT` at its fixed 32.184 s, `TDB` over the full 787-term
49
+ Fairhead and Bretagnon model, and `UTC` with its leap seconds
50
+ - Add `Instant#to`, which reads an instant in a scale, and `ScaleReading`,
51
+ which is that reading, with `#as` for the shape it comes out in
52
+ - Add the representations a reading comes out as: `julian_date`,
53
+ `modified_julian_date`, `civil`, and `iso8601`, each as a Float, a Rational,
54
+ or a two-part Float where that makes sense
55
+ - Add `Instant.from_julian_date`, `from_modified_julian_date`, `from_civil`,
56
+ and `from_iso8601`, each reading its value in a named scale
57
+ - Add `Instant.from_tai`, `from_tt`, `from_tdb`, and `from_utc`, the same
58
+ calendar constructor with the scale in the name
59
+ - Add `CivilTime`, the calendar fields a clock and a calendar show, in the
60
+ proleptic Gregorian calendar with astronomical year numbering
61
+ - Read and write a leap second as second 60, on the days that hold one, and
62
+ refuse it on the days that do not
63
+ - Add `ScaleReading#provenance`, `:measured` up to the date the leap second
64
+ data vouches for and `:extrapolated` after it, with
65
+ `Configuration#leap_second_horizon` to refuse an extrapolation instead
66
+ - Add `Configuration#register_scale`, so a caller can add a scale of its own,
67
+ and `Configuration#leap_second_source`, so it can supply its own leap seconds
68
+ - Add duration arithmetic: `+`, `-`, unary `-`, `zero?`, `negative?`,
69
+ `positive?`, and `to_r` and `to_f` in SI seconds
70
+ - Add value equality to `ScaleReading`, and `inspect` to `Instant`, `Duration`,
71
+ and `ScaleReading`
72
+ - Refuse a calendar reading before -4799, where the conversion stops, so a
73
+ reading out always reads back in
74
+
75
+ ### Fixes
76
+
77
+ - Build the configuration under a lock, so two threads reaching it at once
78
+ cannot each build one and lose the other's scales
79
+
80
+ **Full Changelog**: https://github.com/rhannequin/horologium/compare/v0.0.2...v0.0.3
81
+
3
82
  ## 0.0.2 - 2026-07-14
4
83
 
5
84
  The first functional release. It ships the numeric core and the two value
data/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  [![Tests](https://github.com/rhannequin/horologium/workflows/CI/badge.svg)](https://github.com/rhannequin/horologium/actions?query=workflow%3ACI)
4
4
 
5
5
  Horologium is a Ruby library dedicated to **scientific time**: the time scales
6
- (UTC, TAI, TT, TDB, TCG, TCB, UT1, GPS), high-precision instants, Julian Dates,
6
+ (TAI, TT, TDB, and UTC so far), high-precision instants, Julian Dates,
7
7
  intervals, and rigorous conversions between scales that astronomy and physics
8
8
  require.
9
9
 
@@ -45,14 +45,173 @@ get the duration between them.
45
45
  ```rb
46
46
  require "horologium"
47
47
 
48
- instant = Horologium::Instant.from_tai_julian_date(2_460_000.5)
48
+ a = Horologium::Instant.from_julian_date(2_460_000.5, scale: :tai)
49
+ b = Horologium::Instant.from_julian_date(2_460_001.5, scale: :tai)
49
50
 
50
- later = instant + Horologium::Duration.days(1)
51
- later == Horologium::Instant.from_tai_julian_date(2_460_001.5) # => true
52
- instant < later # => true
51
+ a + Horologium::Duration.days(1) == b # => true
52
+ a < b # => true
53
+ b - a == Horologium::Duration.days(1) # => true
54
+ ```
55
+
56
+ An instant has no scale of its own. You give it a Julian Date read in a scale,
57
+ and you read it back in any scale the library knows: `to` chooses the scale, and
58
+ `as` chooses the shape it comes out in.
59
+
60
+ ```rb
61
+ instant = Horologium::Instant.from_julian_date(2_443_144.5, scale: :tai)
62
+
63
+ instant.to(:tt).as(:julian_date) # => 2443144.5003725
64
+ instant.as(:modified_julian_date, scale: :tt) # => 43144.0003725
65
+ ```
66
+
67
+ A calendar date is a shape too, and the one a person reads. It comes out as a
68
+ `CivilTime`, whose fields are the ones a clock and a calendar show, in the
69
+ proleptic Gregorian calendar.
70
+
71
+ ```rb
72
+ civil = instant.as(:civil, scale: :tt)
73
+
74
+ civil.year # => 1977
75
+ civil.month # => 1
76
+ civil.second # => 32
77
+ ```
78
+
79
+ You can build an instant from those fields as well, and nothing is lost on the
80
+ way in: the date becomes a whole number of days and the time of day an exact
81
+ fraction of one. Give a fractional second as a `Rational` to say it exactly.
82
+
83
+ ```rb
84
+ Horologium::Instant.from_civil(2025, 5, 1, 12, 0, 0, scale: :tt)
85
+ Horologium::Instant.from_civil(2025, 5, 1, 12, 0, Rational(1, 4), scale: :tt)
86
+ ```
87
+
88
+ Each scale has its own shortcut, so the scale is in the name instead of a
89
+ keyword.
90
+
91
+ ```rb
92
+ Horologium::Instant.from_tt(2025, 5, 1, 12, 0, 0)
93
+ Horologium::Instant.from_tai(2025, 5, 1, 12, 0, 0)
94
+ Horologium::Instant.from_tdb(2025, 5, 1, 12, 0, 0)
95
+ ```
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
+
101
+ A date that does not exist is refused rather than rolled over, and the message
102
+ says which field is wrong.
103
+
104
+ ```rb
105
+ Horologium::Instant.from_civil(1900, 2, 29, scale: :tt)
106
+ # => raises Horologium::InvalidCivilTimeError
107
+ ```
108
+
109
+ The same date and time write out as an extended ISO 8601 string, and read back
110
+ from one. The scale is not written into the string: there is no ISO 8601
111
+ designator for TAI or TT, and `Z` means UTC, so a bare time is a coordinate in
112
+ the scale you asked for.
113
+
114
+ ```rb
115
+ instant.as(:iso8601, scale: :tt) # => "1977-01-01T00:00:32.184000000"
116
+
117
+ Horologium::Instant.from_iso8601("2025-05-01T12:00:00", scale: :tt)
118
+ Horologium::Instant.from_iso8601("2025-05-01", scale: :tt) # midnight
119
+ ```
120
+
121
+ The parser reads a strict subset: a calendar date, an optional time of day
122
+ after a `T`, a fraction of a second kept to every digit, and an optional `Z` or
123
+ numeric offset applied as plain arithmetic, not a time zone. A week date, an
124
+ ordinal date, or anything outside the subset is refused with a `ParseError`.
125
+
126
+ UTC is the scale of civil clocks, the one that holds a leap second now and
127
+ then to keep step with the Earth's rotation. `from_utc` reads a UTC date, and
128
+ a leap second is a legal reading: the second is 60 on a day that holds one, and
129
+ that moment really existed.
130
+
131
+ ```rb
132
+ Horologium::Instant.from_utc(2025, 5, 1, 12, 0, 0)
133
+
134
+ leap = Horologium::Instant.from_utc(2016, 12, 31, 23, 59, 60)
135
+ leap.as(:iso8601, scale: :utc) # => "2016-12-31T23:59:60.000000000Z"
136
+ ```
137
+
138
+ A leap second is a real second on the timeline, so the arithmetic is right
139
+ across it: the second before 23:59:60, the leap second, and the next midnight
140
+ are one SI second apart each. Second 60 on a day with no leap second is
141
+ refused.
142
+
143
+ ```rb
144
+ before = Horologium::Instant.from_utc(
145
+ 2016, 12, 31, 23, 59, 59,
146
+ precision: :exact
147
+ )
148
+ leap = Horologium::Instant.from_utc(
149
+ 2016, 12, 31, 23, 59, 60,
150
+ precision: :exact
151
+ )
152
+ after = Horologium::Instant.from_utc(
153
+ 2017, 1, 1, 0, 0, 0,
154
+ precision: :exact
155
+ )
156
+
157
+ leap - before == Horologium::Duration.seconds(1) # => true
158
+ after - leap == Horologium::Duration.seconds(1) # => true
159
+
160
+ Horologium::Instant.from_utc(2020, 6, 15, 23, 59, 60)
161
+ # => raises Horologium::InvalidCivilTimeError
162
+ ```
163
+
164
+ The `:exact` above is what makes `==` the right question to ask. At the default
165
+ `:standard` precision the same three instants land a rounding step apart, well
166
+ under a nanosecond but not zero, so compare those with `equal_within?`.
167
+
168
+ UTC runs from 1961, whole leap seconds from 1972 and the earlier
169
+ rate-adjustment drift before that, where a UTC second was fractionally longer
170
+ than an SI one. An earlier UTC date raises `Horologium::OutOfRangeError`. The
171
+ instant is still reachable, only its UTC label is not, so the error names the
172
+ continuous scales, which have no lower bound. The leap seconds and the drift
173
+ come from the [iers] gem, with no network access: the data ships with the gem.
53
174
 
54
- b = Horologium::Instant.from_tai_julian_date(2_460_001.5)
55
- b - instant == Horologium::Duration.days(1) # => true
175
+ ```rb
176
+ Horologium::Instant.from_utc(1960, 12, 31) # => OutOfRangeError
177
+ Horologium::Instant.from_civil(1960, 12, 31, scale: :tt) # reaches any date
178
+ ```
179
+
180
+ Leap seconds are announced about six months ahead, so past the date its data
181
+ vouches for, the last known offset is the best there is. A UTC reading says
182
+ which it rests on: `:measured` up to that date, `:extrapolated` after, where a
183
+ leap second announced since would not be known.
184
+
185
+ ```rb
186
+ instant.to(:utc).provenance # => :measured, or :extrapolated past the horizon
187
+ ```
188
+
189
+ A pipeline that must not rest on an offset a leap second could overturn sets a
190
+ strict horizon, and a reading past it raises instead.
191
+
192
+ ```rb
193
+ Horologium.configure { |c| c.leap_second_horizon = :raise }
194
+ # => reading a date past the data horizon raises OutOfDataRangeError
195
+ ```
196
+
197
+ The configuration is set once, in a single `Horologium.configure` block, and
198
+ frozen when the block returns. See [Precision](#precision).
199
+
200
+ A Julian Date is around 2.46 million, which leaves a single `Float` about 40
201
+ microseconds for the fraction of a day, and the loss is already in the literal
202
+ before Horologium sees it. So the lossless shapes come first: a `String` and a
203
+ `Rational` say the Julian Date exactly, and a high and a low part say it to
204
+ about twice what one `Float` holds.
205
+
206
+ ```rb
207
+ Horologium::Instant.from_julian_date("2456463.052272", scale: :tt)
208
+ Horologium::Instant.from_julian_date(
209
+ Rational(2_456_463_052_272, 1_000_000),
210
+ scale: :tt
211
+ )
212
+ Horologium::Instant.from_julian_date(2_456_463.0, 0.052272, scale: :tt)
213
+
214
+ Horologium::Instant.from_modified_julian_date(60_796.0, scale: :tai)
56
215
  ```
57
216
 
58
217
  A `Duration` counts SI seconds, so `Duration.days(1)` is always 86,400 SI
@@ -63,6 +222,45 @@ so a duration and a calendar day are different things.
63
222
  Horologium::Duration.days(1) == Horologium::Duration.seconds(86_400) # => true
64
223
  Horologium::Duration.nanoseconds(1_000_000_000) ==
65
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)
239
+ ```
240
+
241
+ Durations add, subtract, and negate among themselves, and read back out in SI
242
+ seconds.
243
+
244
+ ```rb
245
+ Horologium::Duration.seconds(30) + Horologium::Duration.seconds(12)
246
+ Horologium::Duration.seconds(30) - Horologium::Duration.seconds(42) # negative
247
+ -Horologium::Duration.seconds(3)
248
+
249
+ Horologium::Duration.days(1).to_r # => (86400/1), the whole value
250
+ Horologium::Duration.days(1).to_f # => 86400.0
251
+ ```
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)
66
264
  ```
67
265
 
68
266
  Adding a duration to an instant makes sense, but adding two instants together
@@ -72,11 +270,35 @@ does not, so it raises an error.
72
270
  instant + instant # => raises Horologium::DimensionalError
73
271
  ```
74
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
+
75
297
  Exact equality is rarely what scientific code wants, so you can compare within a
76
298
  tolerance:
77
299
 
78
300
  ```rb
79
- a = Horologium::Instant.from_tai_julian_date(2_460_000.5)
301
+ a = Horologium::Instant.from_julian_date(2_460_000.5, scale: :tai)
80
302
  near = a + Horologium::Duration.nanoseconds(1)
81
303
 
82
304
  a.equal_within?(near, Horologium::Duration.nanoseconds(2)) # => true
@@ -94,35 +316,60 @@ with ordinary floating-point arithmetic.
94
316
 
95
317
  Every value carries one of two precisions, fixed when it is built:
96
318
 
97
- - `:standard`, the default, keeps the value as a two-part float. It is fast and
98
- 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.
99
321
  - `:exact` keeps the value as a `Rational`, with no rounding. The test suite
100
322
  uses it to check that `:standard` stays within its stated precision.
101
323
 
102
- Set the default once at boot:
324
+ Set the default once at boot. `Horologium.configure` freezes the configuration
325
+ when its block returns, so it is called once and everything is set in the one
326
+ block. A second call raises `Horologium::ConfigurationError`.
103
327
 
104
328
  ```rb
105
329
  Horologium.configure do |c|
106
330
  c.default_precision = :exact
331
+ c.leap_second_horizon = :raise
107
332
  end
108
333
  ```
109
334
 
110
335
  Choose it for a single value, or for a scoped block:
111
336
 
112
337
  ```rb
113
- Horologium::Instant.from_tai_julian_date(2_460_000.5, precision: :exact)
338
+ Horologium::Instant.from_julian_date(
339
+ 2_460_000.5,
340
+ scale: :tai,
341
+ precision: :exact
342
+ )
114
343
 
115
344
  Horologium.with_precision(:exact) do
116
345
  # instants and durations built here default to :exact
117
346
  end
118
347
  ```
119
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
+
120
356
  Exactness is contagious. An operation between two `:standard` values stays
121
357
  `:standard`. Mixing a `:standard` and an `:exact` value gives an `:exact`
122
358
  result, so precision is not quietly lost. `:exact` guarantees the arithmetic
123
359
  Horologium performs. It cannot bring back precision that an input already lost
124
360
  when it was built.
125
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
+
126
373
  ## Status
127
374
 
128
375
  This library is in early development, before its first public release. The
@@ -134,8 +381,18 @@ changes until a 1.0 release. Changes are documented in the [CHANGELOG].
134
381
  After checking out the repo, run `bin/setup` to install dependencies. Then, run
135
382
  `rake` to run the tests and RuboCop, or `rake steep` to type-check the
136
383
  signatures in `sig/`. Run `COVERAGE=true rake test` to measure test coverage,
137
- which is enforced at a 95% line minimum in CI. You can also run `bin/console`
138
- for an interactive prompt that will allow you to experiment.
384
+ which is enforced at 100% of lines and branches in CI. You can also run
385
+ `bin/console` for an interactive prompt that will allow you to experiment.
386
+
387
+ `sig/` holds Horologium's own signatures and ships with the gem. `sig-vendor/`
388
+ holds stubs for gems that ship none of their own, and stays out of the gem so
389
+ it cannot clash with a downstream RBS collection.
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.
139
396
 
140
397
  Run `bin/ci` to run every check that GitHub Actions runs (RuboCop, Steep, YARD
141
398
  documentation coverage, and the tests with coverage) in a single pass. It runs