暦とルールと優先度

巷のBRMSが採用しているルールエンジンの動きとしては、大きく分けて2つの流れがあります。

  1. 通常の手続き型言語のIF文と同じように上から順に判断するエンジン。
  2. 現在保持しているデータの状態に対し、その状態を満足するルールを次々と処理していくReteアルゴリズムを代表とするデータ駆動のエンジン。

代表的なBRMSは、たいてい上記 2 の動きをサポートしていますが、通常の手続き型の動きとちょっと違うところがあるので、このブログでも折に触れこういった動きの特徴となるところを取り上げていきたいと思います。

今日は優先度(salience)について。通常の手続き型の言語ですと、上から順番に処理するだけで、優先度も何もない(強いて言えば上に行くほど優先度が高い)のですが、データ駆動のルールエンジンですと、優先度が時に重要な役割を果たします。ということで、本日はうるう年の判定を優先度を使って考えてみたいと思います。

ビッグベン
もっとも、うるう年の判定など、あえてルールでやらずとも普通の言語であればライブラリを使って一発なのですが、たまたま最近読んだ本(数量化革命の中にユリウス暦から現在のグレゴリオ暦を採用するまでの経緯を記したくだりがあり、うるう年の歴史的背景とからめて順にルールで実装していくというのもおもしろいかと思い、ちょっと手すさび程度に試してみたという次第。気楽にお付き合いください。

(ちなみに、この本はヨーロッパ帝国主義が成功をおさめた理由のひとつを、人々の世界観・思考様式が数量化・視覚化に依拠したものに変化したことによるとし、数字、機械時計、楽譜、遠近法、複式簿記などを例にとりながら説明していくという本。私は結構面白く読めました)

さて、グレゴリオ暦法では、うるう年を次のように決めています。

  1. 西暦年号が4で割り切れる年をうるう年とする。
  2. 【1】の例外として、西暦年号が100で割り切れて400で割り切れない年は平年とする。
    (国立天文台の質問ページから)

以上の規則は、通常の言語で手で記述しても大した話ではなく、たとえばJavaだったら yearを判定対象の年として条件を

year % 4 == 0 && year % 100 != 0 || year % 400 == 0

と書いてしまえばよい話なので、Droolsのルールでもそのまま

Leapyear.drl

rule "JuliusLeap"
     salience 100
     when
         $year : Year( leapYear == Year.NOT_YET
             && ((year % 4) == 0
             && (year % 100) != 0
             || (year % 400) == 0))
     then
         $year.setLeapYear(Year.LEAP_YEAR);
            // うるう年判定用の属性にうるう年をセットする
         update($year);
            // Droolsのワーキングメモリ内のファクトとして更新
         System.out.println("うるう年です");
end

rule "JuliusNotLeap"
     when
         $year : Year(leapYear == Year.NOT_YET)
     then
         $year.setLeapYear(Year.COMMON_YEAR);
         update($year);
         System.out.println("うるう年ではありません");
end

と書いてしまえば用が足りてしまいます。でも、これではあまりにもあっさりしすぎているので、以下では、もう少しルールらしさが表れる方法で実装してみましょう。

(なお、ここでは、以下の Year クラスを使いました。

public static class Year {
    public static final int NOT_YET = 0;       // 未定
    public static final int COMMON_YEAR = 1;   // 通常の年
    public static final int LEAP_YEAR = 2;     // うるう年

    private int year;                       // 西暦
    private int leapYear;                   // うるう年か否か

    ... (略)
}

また、上記ルールですでに優先度(salience)が出てきていますが、これはうるう年のYearインスタンスがあった場合、両方のルールにマッチするので、うるう年の条件にマッチするのであればそちらを優先して処理するということになります)

上で、うるう年の判定条件は、

  1. 西暦年号が4で割り切れる年をうるう年とする。
  2. 【1】の例外として、西暦年号が100で割り切れて400で割り切れない年は平年とする。

と書きましたが、現在のグレゴリオ暦の元となるユリウス暦では、うるう年といえば1の条件のみでした。ユリウス暦はご存じのとおりユリウス・カエサルが定めた暦法で、紀元前45年に始まり、1年を365.25日として暦が作られています。したがって4年に1回、1日分の補正が入るわけですね。

このユリウス暦によるうるう年の判定条件をルールで書くと

Leapyear1.drl

rule "JuliusLeap"
    salience 100
    when
        $year : Year( leapYear == Year.NOT_YET
                      && (year % 4) == 0)
    then
        $year.setLeapYear(Year.LEAP_YEAR);
        update($year);
        System.out.println("うるう年です");

end

rule "JuliusNotLeap"
    when
        $year : Year(leapYear == Year.NOT_YET)
    then
        $year.setLeapYear(Year.COMMON_YEAR);
        update($year);
        System.out.println("うるう年ではありません");

end

となります。

ユリウス暦はそのはじめから1600年以上も続いたわけですが、現在の技術による観測によれば1年は、365.242 189 572日(2013年年央値)。1年を365.25日とちょっと長めにとっているユリウス暦は長い間には誤差が累積していくわけで、グレゴリオ暦を採用することになった1582年には、誤差が11日に達していました。多くのアバウトな人にとっては、まあ別にいいのでは…ということでしたが、敬虔なキリスト教徒にとっては結構な大問題だったようです。

というのもキリストの復活を祝う復活祭。これは「春分の日以降の最初の満月のあと最初の日曜日」と定められていましたが、ユリウス暦上の春分の日である3月21日が天文学的にみた春分の日と10日以上もずれていると、今祝っている復活祭が誤った日に行われているのではないかという疑念がでてきます。

そこで、当時の法王グレゴリウス13世が、1582年に有識者を集めて暦の改革をします。(ちなみに1582年と言えば、日本では「本能寺の変」の年です。)

そのころの観測データでは、1年は365.2425日=365+97/400日という値が得られており、400年のうち97回、閏年があれば十分で、400年に100回の閏年を入れるユリウス暦では400年のうちに3日分あまってしまっていくというわけ。

では、どうしたかというと、4年に1回の閏年のうち、100年に1回は閏年にしない・・・これでは、400年に96回の閏年になってしまい、ちょっと削りすぎなので、100年に1回の閏年でない年のうち、400で割り切れる年は特別に閏年とする。

といういうことにして、400年に97回の閏年を実現したわけです。さて、まずは、この例外事項のみをルールに書いてみましょう。

rule "Gregorio1"
    when
        $year : Year( leapYear == Year.NOT_YET
                      && (year % 100) == 0
                      && (year % 400) != 0 )
    then
        $year.setLeapYear(Year.COMMON_YEAR);
        update($year);
        System.out.println("うるう年ではありませんよ");

end

ルールの書き方としては、400年の条件と100年の条件をさらに分けることもできますが、あまり分けすぎてもわかりにくくなるので、ここではひとまとめのままにしておきます。

上のルールに優先度を加え、例外事項のルールとして付け加えることで、もともとあったルールをそのままに判定条件の修正ができるようになります。上のルールは他のルールの条件に比べ、より厳しい例外的な条件になるので、もしこの条件に合うケースがあった場合には最優先で処理をするという意味で、もともとあった優先度(100)以上の優先度(200)をつけています)

Leapyear2.drl

rule "JuliusLeap"
    salience 100
    when
        $year : Year( leapYear == Year.NOT_YET
                      && (year % 4) == 0)
    then
        $year.setLeapYear(Year.LEAP_YEAR);
        update($year);
        System.out.println("うるう年です");

end

rule "Gregorio1"
    salience 200
    when
        $year : Year( leapYear == Year.NOT_YET
                      && (year % 100) == 0
                      && (year % 400) != 0 )
    then
        $year.setLeapYear(Year.COMMON_YEAR);
        update($year);
        System.out.println("うるう年ではありませんよ");

end

rule "JuliusNotLeap"
    when
        $year : Year(leapYear == Year.NOT_YET)
    then
        $year.setLeapYear(Year.COMMON_YEAR);
        update($year);
        System.out.println("うるう年ではありません");

end

これでグレゴリオ暦の閏年の判定条件ルールができました。このように優先度をうまく使うと、元々あるルールをそのままに、あとから比較的見通しよく例外ルールを付け加えることができます。

ところで、閏年の判定に関して、実はちょっと前におもしろいブログ記事を見つけました。

西暦1000年は閏年かそうじゃないのか?

グレゴリオ暦の判定条件からすれば閏年でないはずなのですが、Javaでは閏年になり、MySqlの判定では閏年になっていないとのこと。これは如何に。

西暦1000年と言えば、上にのべた歴史的背景から考えると、そもそもグレゴリオ暦などない時代。したがって上のルールの1のみが適用されればよいので、Javaでは閏年と判断しているのでしょう。またルールとしても、西暦1000年においては、2のルールは全く存在していないので、ルールの判定条件として2にあたるルールの条件に1582年以降という条件を加えればよいことになります。

では、MySqlの判定条件は間違っているということでしょうか。これについては、

先発グレゴリオ暦

という考え方もあり、国際規格(ISO8601)でも定められているようなので、一概に間違いとは言えず、単なる立場の違いと考えた方がよいのでしょう。

***

今回は、優先度を用いて、一般に適用されるルールと、その中で例外的に適用されるルールの振り分けを行ってみました。データ駆動の処理の動きは、このように手続き型とちょっと違う特徴を持っています。こういった特徴を表す実例はまだ他にもあるのですが、長くなるのでまた別の機会に記事にしようと思います。では、また次回に。

コメント

タイトルとURLをコピーしました