decimal_checked の設計

設計目標

decimal_checked は、IEEE 754 の 10 進演算の列を、最後に 2 つの問い(結果は何か、途中で何が起きたか)に答える 1 つの値に変えます。IEEE の 10 進算術は、例外条件を定義済みの結果に添えたステータスフラグとして報告します。単一の演算は (value, flags) を返しますが、多数のステップからなる計算ではすべてのステップのフラグが必要です。DecimalChecked は 1 つのコンテキストをステップ間で受け渡してフラグを蓄積しつつ、例外的な結果も値であるという IEEE の規則を保ちます。演算の一覧は API ページに、監査型のパイプラインはチュートリアルにあります。

数学的背景

フラグのモノイド

DecimalFlags は 13 個のブール型フィールド(inexact、rounded、overflow、…)を持つので、フラグ集合は F={0,1}13\mathbb{F} = \{0, 1\}^{13} の元、あるいは同値なことに 13 個のシグナルの集合 Σ\Sigma の部分集合です。DecimalFlags::combine はフィールドごとの OR です。

(a∨b)i=ai∨bi(i=1,…,13),0=DecimalFlags::new().(a \lor b)_i = a_i \lor b_i \qquad (i = 1, \dots, 13), \qquad \mathbf{0} = \texttt{DecimalFlags::new()}.

ブール OR は結合的、可換、冪等で単位元 00 を持ち、演算はフィールドごとに作用するので、

(a∨b)∨c=a∨(b∨c),a∨0=0∨a=a,a∨b=b∨a,a∨a=a.\begin{aligned} (a \lor b) \lor c &= a \lor (b \lor c), & a \lor \mathbf{0} &= \mathbf{0} \lor a = a,\\ a \lor b &= b \lor a, & a \lor a &= a . \end{aligned}

したがって (F,∨,0)(\mathbb{F}, \lor, \mathbf{0}) は可換で冪等なモノイド、すなわち有界な結び半束です。部分集合として読めば ∨\lor は和集合です。DecimalFlags::contains(s) は座標 ss を読み出し、({0,1},∨,0)(\{0,1\}, \lor, 0) へのモノイド準同型になっています:contains(a∨b,s)=contains(a,s)∨contains(b,s)\texttt{contains}(a \lor b, s) = \texttt{contains}(a, s) \lor \texttt{contains}(b, s)。

writer の射としての演算

固定されたコンテキスト cc のもとでの IEEE 演算は、10 進値 DD 上の関数 opc:D→D×F\mathrm{op}_c : D \to D \times \mathbb{F} であり、丸められた結果と立てられたフラグを返します(Decimal::add_ctx、div_ctx、sqrt_ctx、…)。このような関数は F\mathbb{F} 上の writer モナドの Kleisli 射です。

η(v)=(v,0),(v,F)> ⁣ ⁣> ⁣ ⁣=f=(v′,F∨r)where (v′,r)=f(v).\begin{aligned} \eta(v) &= (v, \mathbf{0}),\\ (v, F) \mathbin{>\!\!>\!\!=} f &= (v', F \lor r) \quad\text{where } (v', r) = f(v). \end{aligned}

モナド則はモノイド則に帰着します。左単位則:η(v)> ⁣ ⁣> ⁣ ⁣=f=(v′,0∨r)=f(v)\eta(v) \mathbin{>\!\!>\!\!=} f = (v', \mathbf{0} \lor r) = f(v)。右単位則:(v,F)> ⁣ ⁣> ⁣ ⁣=η=(v,F∨0)=(v,F)(v, F) \mathbin{>\!\!>\!\!=} \eta = (v, F \lor \mathbf{0}) = (v, F)。結合則:f(v)=(v′,r)f(v) = (v', r)、g(v′)=(v′′,s)g(v') = (v'', s) とすると、((v,F)> ⁣ ⁣> ⁣ ⁣=f)> ⁣ ⁣> ⁣ ⁣=g((v, F) \mathbin{>\!\!>\!\!=} f) \mathbin{>\!\!>\!\!=} g と (v,F)> ⁣ ⁣> ⁣ ⁣=(x↦f(x)> ⁣ ⁣> ⁣ ⁣=g)(v, F) \mathbin{>\!\!>\!\!=} (x \mapsto f(x) \mathbin{>\!\!>\!\!=} g) はどちらも (v′′,(F∨r)∨s)=(v′′,F∨(r∨s))(v'', (F \lor r) \lor s) = (v'', F \lor (r \lor s)) に等しくなります。11 P. Wadler, “Monads for functional programming”, 1995(出力モナド)。任意のモノイドから writer モナドが得られ、その法則はちょうどモノイド則です。

パイプラインの状態

DecimalChecked は σ=(v,c,r,F,ε)\sigma = (v, c, r, F, \varepsilon)、すなわち値、コンテキスト、直近のステップのフラグ、蓄積されたフラグ、省略可能なエラーを保持します。プライベートな record ステップは writer の bind であり、直近のフラグで拡張され、エラーによってガードされています。

record⁡(σ,(v′,r′))={σε≠none,(v′,c,r′,F∨r′,none)ε=none.\operatorname{record}(\sigma, (v', r')) = \begin{cases} \sigma & \varepsilon \ne \text{none},\\ (v', c, r', F \lor r', \text{none}) & \varepsilon = \text{none}. \end{cases}

record_result は失敗しうる演算 opc:D→(D×F)+E\mathrm{op}_c : D \to (D \times \mathbb{F}) + E を扱います。Ok((v′,r′))\mathrm{Ok}((v', r')) のときは record と同じで、Err(e)\mathrm{Err}(e) のときは (v,c,r,F,Some(e))(v, c, r, F, \mathrm{Some}(e)) を返します。したがって完全なステップは、例外モナドの上に writer モナドを積み重ねたもので、エラーは吸収的です。

設計上の判断

フラグは蓄積され、例外的な結果は値のまま残る

問題。 長い 10 進計算では、いずれかのステップが丸めたか、オーバーフローしたか、ゼロ除算したかを報告しなければなりません。そして IEEE 754 はそのような各ステップに結果を定義しています。

選択肢。 (a) Luna-Flow/arithmetic のコンテキスト付きトレイトがゼロ除算と無効演算について行っているように、例外条件をエラーに変える。(b) 最後のステップのフラグだけを返す。(c) IEEE の値を保持し、フラグを蓄積する。

選択:(c)。 IEEE のモデルでは、計算は定義済みの結果で続行し、何が起きたかはステータスフラグが伝えます。監査は最後にフラグを検査します。22 IEEE 754-2019、7 節(デフォルトの例外処理)と 8 節(代替の例外処理):ステータスフラグは立てられると、明示的に下ろされるまで立ったままになります。 選択肢 (a) は IEEE が +∞+\infty を定義しているにもかかわらず 1/01/0 で停止してしまい、選択肢 (b) はそれ以前の条件を失います。エラーは、この実装で定義済みの IEEE の結果が存在しない唯一のケース、すなわち認証付きの初等関数が丸めを認証できない場合のために取っておかれます。rr(raised)と FF(flags)の両方を保持することで、呼び出し側は直近のステップと履歴全体の両方を見ることができます。

パイプラインごとに 1 つのコンテキスト、素のオペランド

問題。 二項演算が別のパイプラインをオペランドとして受け取ることも考えられます。

選択。 オペランドは素の Decimal 値です。2 つのパイプラインは 2 つのコンテキストと 2 つのフラグ履歴を持っています。それらを統合するにはどちらのコンテキストを優先するかの規則が必要で、履歴の和集合ではどちら側がフラグを立てたかが隠れてしまいます。素のオペランドなら、パイプラインは 1 つのコンテキストのもとでの単一の線形な履歴になり、コンテキストは with_context によってのみ変わります。このメソッドは現在の値を新しいコンテキストへ丸めたときのフラグを記録します。同じ理由で、この型は演算子を実装していません。

Luna-Flow/arithmetic のコンテキストの対応付け

パイプラインは DecimalContext を受け取ります。ArithmeticContext を持っている呼び出し側は DecimalContext::from_arithmetic_context でそれを変換します。この関数は次のように対応付けます。

ArithmeticContextDecimalContext
precisionprecision
roundingrounding、および decimal_rounding = DecimalRoundingMode::from_arithmetic(rounding)
e_min, e_max同じ値、存在しなければ ∓999 999 999\mp 999\,999\,999
clampclamp

extended = true で、丸め後の極小性判定を使います。したがって、あらかじめ定義された ArithmeticContext::decimal64() は DecimalContext::decimal64() にちょうど対応します。コンストラクタはコンテキストを DecimalContext::ieee754() に通します。これは IEEE プロファイルを選択するためのフックで、現在のブランチではコンテキストをそのまま返します。

数学関数は、General Decimal Arithmetic 仕様が exp、ln、log10、power について行っているのと同様に、コンテキストを制限します。精度と両方の指数の上下限は絶対値で 999 999999\,999 以内でなければならず、そうでなければ結果は invalid_context を伴う NaN になります(src/decimal のプライベートな検査 math_context_is_restricted)。33 M. Cowlishaw, General Decimal Arithmetic Specification, version 1.70, “Arithmetic operations: exp, ln, log10, power”(コンテキストに対する制限)。 したがってデフォルトの無制限の指数範囲ではこれらの関数は使えません。このことは API ページとチュートリアルでも指摘されています。

フラグから算術の診断情報へ

Luna-Flow/arithmetic における Decimal のコンテキスト付きトレイト実装は ArithmeticDiagnostics を報告します。これは 6 個のブール値で、ArithmeticDiagnostics::combine(やはりフィールドごとの OR)で組み合わされます。src/decimal/traits.mbt のプライベートなヘルパー contextual_diagnostics は、共通する 6 つの座標を残すことでフラグを診断情報に対応付けます。この射影を π:F→{0,1}6\pi : \mathbb{F} \to \{0,1\}^{6} と呼びます。座標射影はフィールドごとの OR と可換です。

π(a∨b)j=(a∨b)ij=aij∨bij=π(a)j∨π(b)j,π(0)=0,\pi(a \lor b)_j = (a \lor b)_{i_j} = a_{i_j} \lor b_{i_j} = \pi(a)_j \lor \pi(b)_j , \qquad \pi(\mathbf{0}) = \mathbf{0},

したがって π\pi はモノイド準同型であり、帰納法により

π(⋁i=0nri)=⋁i=0nπ(ri).\pi\Bigl(\bigvee_{i=0}^{n} r_i\Bigr) = \bigvee_{i=0}^{n} \pi(r_i).

パイプラインの蓄積されたフラグを最後に 1 回変換しても、各ステップを変換して組み合わせても、同じ診断情報が得られます。トレイト実装が ArithmeticError に変える条件(ゼロ除算と、DecimalFlags::has_error で判定される無効演算の系統)は π\pi が残す座標には含まれません。パイプラインはそれらを代わりにフラグとして保持します。

正しさ/不変条件

蓄積されたフラグはステップごとのフラグの和集合である

定理。 パイプラインがフラグ r0r_0 で構築され(from_outcome、from_decimal、parse、または数値コンストラクタによって)、その後、立てられたフラグ r1,…,rnr_1, \dots, r_n を伴う成功したステップ 1,…,n1, \dots, n(with_context と apply を含む)を、途中で clear_flags を挟まずに経たとします。このとき

Fn=r0∨r1∨⋯∨rn,r=rn.F_n = r_0 \lor r_1 \lor \dots \lor r_n, \qquad r = r_n .

証明。 すべてのコンストラクタは r=F=r0r = F = r_0 と設定し、これが n=0n = 0 の場合です。Fk=⋁i≤kriF_{k} = \bigvee_{i \le k} r_i ならば、成功したステップ k+1k+1 は record(または with_context 内の同一の更新)を通り、結合則により Fk+1=Fk∨rk+1=⋁i≤k+1riF_{k+1} = F_k \lor r_{k+1} = \bigvee_{i \le k+1} r_i と r=rk+1r = r_{k+1} を設定します。□\square

∨\lor の可換性と冪等性により、FnF_n はステップの順序やシグナルが何回立てられたかに依存しません。また contains の準同型性により、

contains(Fn,s)  ⟺  ∃ i≤n:contains(ri,s).\texttt{contains}(F_n, s) \iff \exists\, i \le n : \texttt{contains}(r_i, s).

次のテストは 3 ステップのパイプラインでこの定理を確かめます。蓄積されたフラグは、各ステップで立てられたフラグの OR に、組み合わせの順序によらず等しくなります。

///|
test "accumulated flags are the union of the raised flags" {
  let ctx = @decimal.DecimalContext::decimal64()
  let s0 = @decimal_checked.DecimalChecked::from_int(1, ctx)
  let s1 = s0.div(@decimal.Decimal::from_int(3))
  let s2 = s1.add(@decimal.Decimal::from_int(10))
  let s3 = s2.div(@decimal.Decimal::zero())
  let forward = s0.raised().combine(s1.raised()).combine(s2.raised()).combine(s3.raised())
  let backward = s3.raised().combine(s2.raised()).combine(s1.raised()).combine(s0.raised())
  inspect(s3.flags() == forward, content="true")
  inspect(forward == backward, content="true")
  inspect(s3.raised().inexact, content="false")
  inspect(s3.flags().inexact && s3.flags().division_by_zero, content="true")
}

clear_flags の後は、その時点から r0=0r_0 = \mathbf{0} として定理が再び成り立ちます。FF はパイプラインに沿って単調です:Fk⊆Fk+1F_k \subseteq F_{k+1}。

エラーは吸収的である

定理。 状態が ε=Some(e)\varepsilon = \mathrm{Some}(e) を持つとき、clear_flags 以外のすべての演算はその状態をそのまま返し、clear_flags は rr と FF だけを変更します。

証明。 record、record_result、with_context はガード self.error_ is None で始まり、そうでなければ self を返します。すべての算術メソッドは record または record_result を通ります。clear_flags は 2 つのフラグフィールドだけを更新します。□\square

したがって報告されるのは最初のエラーであり、値とフラグは失敗したステップの前のまま残り、result() は Err(e)\mathrm{Err}(e) を返します。エラーのないパイプラインでは、result() は writer モナドの出力 Ok((v,F))\mathrm{Ok}((v, F)) です。

コスト

1 ステップのコストは、委譲先の 10 進演算に、13 フィールドの OR 1 回と定数サイズの状態のコピーを加えたものです。

却下した代替案

  • IEEE の例外をエラーにすること。 IEEE のデフォルトの例外処理とこのパッケージの目的に反します。コンテキスト付きトレイトが、単一の演算についてはすでにそのモデルを提供しています。
  • パイプラインの統合。 2 つのコンテキストに対する標準的な規則がありません。上記を参照してください。
  • 可変なフラグ。 Luna-Flow はコンテキストとステータスを値として保持します。不変な状態は分岐させたり比較したりできます。
  • ステップごとの履歴の保存。 和集合は監査の問いに定数サイズで答えます。履歴が必要な呼び出し側は、関心のある状態を自分で保持します。

境界

  • 独自の算術はありません。すべての結果は decimal から得られます。
  • トラップや代替の例外処理はありません。IEEE のフラグがパイプラインを停止させることはありません。トラップ駆動の制御フローは decimal_gda_checked の契約です。
  • 演算子、パイプライン同士の演算、暗黙のコンテキスト変更はありません。
  • エラーになるのは初等関数の認証の失敗だけです。
  • コンテキストは decimal の IEEE 10 進コンテキストです。スティッキーなステータスを持つ GDA コンテキストは decimal_gda に属します。

Footnotes

  1. P. Wadler, “Monads for functional programming”, 1995(出力モナド)。任意のモノイドから writer モナドが得られ、その法則はちょうどモノイド則です。 ↩

  2. IEEE 754-2019、7 節(デフォルトの例外処理)と 8 節(代替の例外処理):ステータスフラグは立てられると、明示的に下ろされるまで立ったままになります。 ↩

  3. M. Cowlishaw, General Decimal Arithmetic Specification, version 1.70, “Arithmetic operations: exp, ln, log10, power”(コンテキストに対する制限)。 ↩