Skip to content

Introduce time scales, with TAI and TT#11

Merged
rhannequin merged 1 commit into
mainfrom
scales-tai-tt
Jul 16, 2026
Merged

Introduce time scales, with TAI and TT#11
rhannequin merged 1 commit into
mainfrom
scales-tai-tt

Conversation

@rhannequin

@rhannequin rhannequin commented Jul 14, 2026

Copy link
Copy Markdown
Owner

This adds the two seams the rest of the scale work needs: a registry of time scales, and a way to read an instant out in a representation. An instant can now be read in a scale, and taken out in a shape.

instant = Horologium::Instant.from_tai_julian_date(2_443_144.5, precision: :exact)

instant.to(:tt).as(:julian_date)
# => 2443144.5003725

instant.as(:julian_date, scale: :tt, as: :rational)
# => (977257800149/400000)

instant.to(:sundial)
# => raises Horologium::UnknownScaleError

Scales::Base is what a scale implements. It has two methods: one reads a TAI Julian Date in the scale, the other reads a Julian Date in the scale back in TAI. Every scale converts to and from TAI, so a scale does not need to know about the other scales, and a conversion from one scale to another goes through TAI. Scales::TAI returns the value the instant already holds. Scales::TT adds the 32.184 SI seconds TT is ahead of TAI. Both offsets are fixed by definition, so neither needs external data, and the exact precision keeps them exactly: JD 2443144.5 in TAI is JD 2443144.5003725 in TT, with nothing lost.

The scales live in a registry on the configuration. It holds the built-in ones, and register_scale adds more. A scale is refused there, at boot, unless it is a subclass of Scales::Base, under a Symbol name, implementing both halves of the contract, so a half-built scale cannot wait to fail on the first conversion. The registry freezes with the rest of the configuration, and configure freezes it even when the block raises, so a failed boot cannot leave it half open.

Horologium.configure do |c|
  c.register_scale(:my_scale, MyScale)
end

Instant#to returns a ScaleReading, which keeps the precision of the instant it came from and checks that its value matches it, the way Instant and Duration do. ScaleReading#as takes a representation out of the reading, and Representations::JulianDate is the first one. A representation is given the whole reading, not only the value: a Julian Date does not need the scale, but a civil date in UTC will, because it has to ask the scale whether the day it falls in holds a leap second.

A Float holds today's Julian Dates to a few tens of microseconds, so the type is chosen on the way out: :float by default, :rational for the whole value, and :two_part for the two parts, to pass to a foreign kernel that reads them. They are only guaranteed to add up to the Julian Date. Asking for a type a representation does not have raises UnknownOutputError. Instant#as is the shorthand for the two calls.

Numeric::Precision gains build, add, subtract, and validate_value!, so the precision rules the scales, Instant, and ScaleReading all need live in one place. It is public API now, because a scale cannot be written without it. Its arithmetic takes values, never plain numbers: promoting a bare Float would quietly move the result to :exact.

TwoPartFloat exposes its two parts, which the :two_part output needs, and both numeric cores gain to_f to collapse a value once the arithmetic is done. With to_f defined, * and / no longer refuse a two-part value by accident, so they now refuse it on purpose: multiplying two two-part floats would drop half the precision.

UTC, TDB, and the other representations come next.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces the foundational abstraction for reading instants in different time scales (via a configuration-backed scale registry) and then rendering those readings in representations (starting with Julian Date), enabling APIs like instant.to(:tt).as(:julian_date) and instant.as(:julian_date, scale: :tt).

Changes:

  • Add a scale registry to Horologium::Configuration with built-in :tai and :tt and support for register_scale.
  • Introduce Horologium::ScaleReading plus Representations::JulianDate to render readings as :float, :rational, or :two_part.
  • Centralize precision-aware arithmetic in Numeric::Precision and extend numeric cores (TwoPartFloat, Exact) to support to_f and stricter scalar ops.

Reviewed changes

Copilot reviewed 36 out of 36 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
test/test_scale_reading.rb New tests for ScaleReading immutability, precision carry-through, and output/representation errors.
test/test_instant.rb Tests for Instant#to and Instant#as shorthand behavior and unknown-scale handling.
test/test_error.rb Tests for new error types (UnknownScaleError, UnknownRepresentationError, UnknownOutputError).
test/test_configuration.rb Tests for built-in scale registry, registering/replacing scales, and freezing behavior.
test/scales/test_tt.rb New tests validating TT’s fixed offset and round-trip accuracy.
test/scales/test_tai.rb New tests validating TAI as identity scale and integration with Instant.
test/scales/test_base.rb New tests ensuring Scales::Base raises NotImplementedError by default.
test/representations/test_julian_date.rb New tests for Julian Date rendering across output types and precisions.
test/numeric/test_two_part_float.rb Tests for new high/low readers, to_f, and scalar-op rejection behavior.
test/numeric/test_precision.rb Tests for new Precision.build/add/subtract/validate_value! APIs.
test/numeric/test_exact.rb Tests for new to_f and scalar-op rejection behavior.
sig/horologium/scales/tt.rbs RBS for TT scale constants and conversion methods.
sig/horologium/scales/tai.rbs RBS for TAI scale identity conversions.
sig/horologium/scales/base.rbs RBS contract for scale base class.
sig/horologium/scale_reading.rbs RBS for ScaleReading fields and #as.
sig/horologium/representations/julian_date.rbs RBS for Julian Date rendering outputs.
sig/horologium/numeric/two_part_float.rbs RBS updates for to_f, high/low, and scalar checks.
sig/horologium/numeric/precision.rbs RBS updates for new precision arithmetic helpers.
sig/horologium/numeric/exact.rbs RBS updates for to_f and scalar checks.
sig/horologium/instant.rbs RBS updates for new Instant#to / #as APIs.
sig/horologium/configuration.rbs RBS updates for scale registry APIs and freezing.
sig/horologium.rbs RBS additions for new error classes.
lib/horologium/scales/tt.rb Implements TT scale conversion via fixed offset with cached precision-specific offsets.
lib/horologium/scales/tai.rb Implements TAI scale as identity (instants stored in TAI).
lib/horologium/scales/base.rb Defines the scale contract and default NotImplementedError behavior.
lib/horologium/scale_reading.rb Adds immutable ScaleReading and representation dispatch with typed errors.
lib/horologium/representations/julian_date.rb Adds Julian Date representation with :float/:rational/:two_part outputs.
lib/horologium/precise_value.rb Refactors precision/value validation to Numeric::Precision.validate_value!.
lib/horologium/numeric/two_part_float.rb Adds to_f, exposes parts, and rejects multiplying/dividing by non-scalar “values”.
lib/horologium/numeric/precision.rb Adds build/add/subtract/validate_value! and centralizes promotion rules.
lib/horologium/numeric/exact.rb Adds to_f and rejects multiplying/dividing by non-scalar “values”.
lib/horologium/instant.rb Switches arithmetic to Numeric::Precision and adds #to / #as APIs.
lib/horologium/error.rb Adds new error types for unknown scales/representations/outputs.
lib/horologium/duration.rb Uses Numeric::Precision.build for constructing duration values.
lib/horologium/configuration.rb Adds built-in scale registry, registration/validation, and freezing semantics.
lib/horologium.rb Updates require order to load scales/representations/reading before configuration/instant.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread lib/horologium/numeric/two_part_float.rb Outdated
Comment thread lib/horologium/numeric/two_part_float.rb
Comment thread lib/horologium/numeric/exact.rb Outdated
Comment thread lib/horologium/numeric/exact.rb Outdated
@rhannequin
rhannequin force-pushed the scales-tai-tt branch 2 times, most recently from a32aca1 to fb8eb02 Compare July 14, 2026 20:18
Base automatically changed from release-v0.0.2 to main July 14, 2026 20:21
This adds the two seams the rest of the scale work needs: a registry of time
scales, and a way to read an instant out in a representation. An instant can
now be read in a scale and taken out in a shape:
`instant.to(:tt).as(:julian_date)`.

`Scales::Base` is what a scale implements. It has two methods: one reads a TAI
Julian Date in the scale, the other reads a Julian Date in the scale back in
TAI. Every scale converts to and from TAI, so a scale does not need to know
about the other scales, and a conversion from one scale to another goes through
TAI. `Scales::TAI` returns the value the instant already holds. `Scales::TT`
adds the 32.184 SI seconds TT is ahead of TAI. Both offsets are fixed by
definition, so neither needs external data, and the exact precision keeps them
exactly: JD 2443144.5 in TAI is JD 2443144.5003725 in TT, with nothing lost.

The scales live in a registry on the configuration. It holds the built-in ones,
and `register_scale` adds more. A scale is refused there, at boot, unless it is
a subclass of `Scales::Base` under a Symbol name that implements both halves of
the contract, so a half-built scale cannot wait to fail on the first conversion.
The registry freezes with the rest of the configuration, and `configure` freezes
it even when the block raises, so a failed boot cannot leave it half open. An
unknown name raises `UnknownScaleError`, which carries the names that are
registered; `scale_names` lists them.

`Instant#to` returns a `ScaleReading`, which keeps the precision of the instant
it came from and checks that its value matches it, the way `Instant` and
`Duration` do. A scale that drops the precision it was given is caught where it
happens. `ScaleReading#as` takes a representation out of the reading, and
`Representations::JulianDate` is the first one. A representation is given the
whole reading, not only the value: a Julian Date does not need the scale, but a
civil date in UTC will, because it has to ask the scale whether the day it falls
in holds a leap second.

A Float holds today's Julian Dates to a few tens of microseconds, so the type is
chosen on the way out: `:float` by default, `:rational` for the whole value, and
`:two_part` for the two parts, to pass to a foreign kernel that reads them. They
are only guaranteed to add up to the Julian Date. Asking for a type a
representation does not have raises `UnknownOutputError`. `Instant#as` is the
shorthand for the two calls.

`Numeric::Precision` gains `build`, `add`, `subtract`, and `validate_value!`, so
the precision rules the scales, `Instant`, and `ScaleReading` all need live in
one place. It is public API now, because a scale cannot be written without it.
Its arithmetic takes values, never plain numbers: promoting a bare Float would
quietly move the result to `:exact`.

`TwoPartFloat` exposes its two parts, which the `:two_part` output needs, and
both numeric cores gain `to_f` to collapse a value once the arithmetic is done.
A Float addition already returns the Float nearest the exact sum, so adding the
two parts is the same answer as rounding the Rational, without building it. With
`to_f` defined, `*` and `/` no longer refuse a two-part value by accident, so
they now refuse it on purpose: multiplying two two-part floats would drop half
the precision.

UTC, TDB, and the other representations come next.
@rhannequin
rhannequin merged commit 41a8d95 into main Jul 16, 2026
44 checks passed
@rhannequin
rhannequin deleted the scales-tai-tt branch July 16, 2026 07:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants