読者です 読者をやめる 読者になる 読者になる

ロボットシミュレータ自作物語 - Day 2: arrayfireで線形代数 & Rustの生ポインタ

引っ越しの準備に追われてあまりコードを書いている時間がありません。

TL; DR

arrayfireでArrayの中身をRustの配列として取り出すのがちょっとめんどくさい

今日の目標

キーボード入力によってティーポットをぐるぐる動かせるようにします。

成果物

f:id:iTakeshi:20170221123803g:plain

gliumでキー入力

キー入力は、描画される各フレームごとにglium::glutin::Event::KeyboardInputとして取得できます。KeyboardInput

pub enum Event {
    // ...
    KeyboardInput(ElementState, ScanCode, Option<VirtualKeyCode>),
    // ...
}

として定義されているので、ここからVirtualKeyCodeを取り出してやるとどのキーが押されたか判定できます。

// application main loop
loop {
    for ev in display.poll_events() {
        use glium::glutin::{ElementState, Event, VirtualKeyCode};
        match ev {
            Event::Closed => return, // Windowが閉じられたらloopを抜ける→アプリケーション終了
            Event::KeyboardInput(ElementState::Pressed, _, Some(code)) => {
                match code {
                    VirtualKeyCode::S => model_z -= 1.0, // move backward
                    VirtualKeyCode::W => model_z += 1.0, // move forward
                    // other moves
                    _ => (), // 他のキーに対するfallbackを用意しておかないとコンパイルが通らない
                }
            },
            _ => ()
        }
    }
}

arrayfireでモデル変換行列を計算

※脚注※ この記事を書いている最中に気づいたことですが、gliumの依存crateとしてnalgeblaという線形代数ライブラリが入っており、普通はこっちを使うのだと思います。しかもnalgebraにはCGで使う基本的な変換行列(translation, rotation, transformation, …)を一発で生成できる関数も入っているようなので書き換える予定です。

前回の記事でも紹介したこの記事にあるような変換行列を用意し、Vertex shaderに渡してやるとティーポット動かしたり回したりできます。三次元空間を扱うのになぜ4x4の行列なのか?という疑問があるかもしれませんが、この4行目・4列目を加えることによって複数の変換行列に対して単純に内積を求めるだけですべての変換を適用できるというすばらしいメリットのためです(以下のコード中のpositionpitchについて、紙と鉛筆で内積を取ってみましょう!)

線形代数演算にはarrayfire-rustを使用します。これはCargoで入れる前にいくつかdependencyがありますので、READMEを参照して準備してください。 注意点として、OpenGLに渡す行列はすべてColumn-major orderです。要するに「行が横」ではなく「列が横」です*1。したがって、position行列で座標は4目ではなく4目になり、回転行列ではsinの符号が直感とは逆になっています。

extern crate arrayfire as af
fn model_matrix(pos: &[f32; 3], rot: &[f32; 3]) -> [[f32; 4]; 4] {
    use af::{Array, Dim4};

    // ティーポットモデルは -100 <= x, y, z <= 100 の座標空間で作られているので、1/100に縮小
    let base = Array::new(&[
        0.01, 0.0 , 0.0 , 0.0,
        0.0 , 0.01, 0.0 , 0.0,
        0.0 , 0.0 , 0.01, 0.0,
        0.0 , 0.0 , 0.0 , 1.0f32,
    ], Dim4::new(&[4, 4, 1, 1]));

    // 平行移動
    let position = Array::new(&[
        1.0, 0.0, 0.0, 0.0,
        0.0, 1.0, 0.0, 0.0,
        0.0, 0.0, 1.0, 0.0,
        pos[0], pos[1], pos[2], 1.0f32,
    ], Dim4::new(&[4, 4, 1, 1]));

    // x軸まわりの回転
    let pitch = Array::new(&[
        1.0, 0.0, 0.0, 0.0,
        0.0,  rot[0].cos(), rot[0].sin(), 0.0,
        0.0, -rot[0].sin(), rot[0].cos(), 0.0,
        0.0, 0.0, 0.0, 1.0f32,
    ], Dim4::new(&[4, 4, 1, 1]));

    // y軸まわりの回転
    let yaw = Array::new(&[
         rot[1].cos(), 0.0, rot[1].sin(), 0.0,
        0.0, 1.0, 0.0, 0.0,
        -rot[1].sin(), 0.0, rot[1].cos(), 0.0,
        0.0, 0.0, 0.0, 1.0f32,
    ], Dim4::new(&[4, 4, 1, 1]));

    // z軸まわりの回転
    let roll = Array::new(&[
         rot[2].cos(), rot[2].sin(), 0.0, 0.0,
        -rot[2].sin(), rot[2].cos(), 0.0, 0.0,
        0.0, 0.0, 1.0, 0.0,
        0.0, 0.0, 0.0, 1.0f32,
    ], Dim4::new(&[4, 4, 1, 1]));

    use af::matmul;
    use af::MatProp::NONE;
    let res = matmul(&matmul(&matmul(&matmul(&roll, &yaw, NONE, NONE), &pitch, NONE, NONE), &position, NONE, NONE), &base, NONE, NONE);

    // 生ポインタを利用してRust配列の取り出し。次節にて解説。
    unsafe {
        *(res.device_ptr() as *const [[f32; 4]; 4])
    }
}

※脚注※ この実装では各種変換行列をすべて内積しひとつにまとめてからOpenGLに渡していますが、それぞれの変換行列をOpenGLに渡してVertex shaderでかけざんしてもらってもいいです、というかそっちのほうが速いような気がするのでこのコードは消滅する予定です。

Rustの生ポインタ

arrayfireはRustのFFI(Foreign Fuction Interface)を利用しています。つまり、arrayfire (C++ Ver.)をbackendとして、arrayfire-rustがそのwrapperになっています。mutmulで行列の内積を計算すると、その結果はC++が確保したメモリの中にあり、Rustからは直接利用できません。af::Array::device_ptr()は、この計算結果に対するC++のポインタ(unsigned long long)を返します。Rustからこの値を取得するためには、

  1. まずポインタが指している型が何なのかを指定し: res.device_ptr() as *const [[f32; 4]; 4]
  2. dereferenceし: *(...)
  3. さらに、外部メモリを参照する行為は闇のパワーを利用する業なのでそのことを明示します: unsafe { ... }

Step1で型名[[f32; 4]; 4]の前についている*constは生ポインタの指す値を定数として利用するときに使い、逆にその値を書き換える場合には*mutとして参照します。その他詳しいことは公式docへ。

Day 3に向けて

よくわからないままコードを書き足してきたのでぐちゃぐちゃです(この記事にコードスニペットしか載っておらずgithubへのリンクがないのはそのためです)。 必要な範囲でリファクタリングしてGitHubに公開します。そしてまだBlenderがよくわかってないのでそっちの勉強もしていきます。

*1:なぜこんな紛らわしいことになっているのかと思わないでもないですが、おそらくGPU上で性能良く演算するためにはこっちのほうが良いのでしょう

ロボットシミュレータ自作物語 - Day 1: RustでOpenGL入門 & 3D CGの基礎

たいそうご無沙汰しております、ぼくたち宇宙人のお時間です。15ヶ月ぶりくらいみたいですね。

さて、4月から専門分野をガラっと変えてヒューマノイドロボットの運動制御について研究する修士の学生になるにあたり、春休み中に素振りとしてロボットシミュレータを自作してみようと思います(学部の卒業も無事に決まりましたしね)。需要があるのか不明ですが解説付きで開発日誌をつけてみようと思いますので三日坊主にならないことを祈ってくださいお付き合いくださいませ。

TL; DR

gliumの公式チュートリアルを丁寧に写経すべし。

今日の目標

とりあえず3D CGをある程度自由に描けるようになりたいので、OpenGL(Wikipedia)で簡単なモデルを表示できるようにしたいと思います。 ただし、OpenGLAPIを生で喋るのはちょっとつらいので避けたいところです。 そこで、今もっとも興味を持っている言語であるRustにもついでに入門してしまうことにして、OpenGL wrapperであるglium*1を使って開発していきます。

環境構築 & コーディング & コーディング & コーディング

RustおよびCargo(Rustのパッケージマネージャ)を適宜な方法でインストールしてください。OpenGLGPUのドライバと一緒にすでにインストールされている可能性が高いです。というか手元のArch Linuxではそうだったので手動で入れるやりかたがわかりません。

あとは上記チュートリアル参照。写経大事。

成果物

f:id:iTakeshi:20170218231952p:plain

OpenGL基礎の基礎

※脚注※ 当初はこの節以降で3D CGの基礎数学についても解説を試みる予定でしたが、参考資料を探している間に傑作を発見してしまいましたのでそちらに譲りたいと思います。6回構成でとても丁寧に解説されており読みやすいです → けんごのお屋敷。以下、5分で概要を知りたい方向けです。

3D CGの三要素(私が勝手に命名しました)は「モデル、光源、カメラ」です。仮想3D空間に粘土細工(モデル)を配置し、仮想光源で光を当てて、仮想空間内に設置したカメラで撮影したものが、私たちがテレビやモニタで見ている絵になります。アニメーションである場合はフレームごとにこの計算を繰り返します。

OpenGLに限らず多くの3Dグラフィックスライブラリでは、CPUが計算したモデルの現在位置をGPUが受け取ってから画面に描画するまでの一連の流れを「パイプライン」と呼んでいくつかのステップに分割し、順序よく仮想3D空間を2次元に落とし込んでいきます。各ステップには各々のライブラリに固有の機能を実現するものなどもありますが、最も原始的で絶対に必要になるものは Vertex shaderによる頂点位置の計算ラスタライズFragment shaderによる各ピクセルの色の計算 の3ステップです。順に見ていきましょう。

Vertex shaderによる頂点位置の計算

CGにおいて「滑らかな表面」というものは存在しません。滑らかに見えているものは、無数の小さな三角形の集合体です。三角形は「三次元空間内に任意の相異なる3点を取ると、かならずその3点を含む平面がただ一つ求まる」「3つの頂点の位置が与えられた場合、三角形の各辺の長さおよび3つの内角がすべて一意に求まる」などの便利な性質があるため、3D CGではあらゆるモデルを三角形に分解して表現します。例えばイルカならこんな感じ↓ f:id:iTakeshi:20170218232626p:plain

https://ja.wikipedia.org/wiki/%E3%83%9D%E3%83%AA%E3%82%B4%E3%83%B3%E3%83%A1%E3%83%83%E3%82%B7%E3%83%A5#/media/File:Dolphin_triangle_mesh.png - パブリックドメイン

Vertex shaderは、光も影もない灰色の世界でモデルをカメラで撮影したとき、モデルを構成する無数の小さな三角形のそれぞれの頂点の位置(三次元)が写真の上でどのような座標(二次元)にあるのかを計算します。

上で「CPUがモデルの現在位置を計算」と簡単に書きましたが、これはあくまでも「モデルの姿勢を規定するのに必要なパラメータを計算」するだけです。例えば下図のような単純なロボットアームであれば腕の長さと \theta_1 \theta_2の角度さえわかればモデルの形は決まってしまうので、CPUが計算するのはこれらの値だけであとはGPUに投げてしまって良いということになります*2f:id:iTakeshi:20170218222423p:plain

Vertex shaderは、腕の長さとθ1とθ2の角度をもとにロボットアームのモデルを空間内に配置し、そのモデルを構成するすべての三角形のすべての頂点の位置をまず三次元で計算します。その後、仮想空間にカメラを設置し、そのカメラでとった写真の上の座標に三次元の位置を対応させます。

※このとき、遠近法に基づき遠くのものを小さく、手前のものを大きく変形する必要がありますが、上で紹介した「けんごの部屋」の記事ではこの部分が解説されていませんので、この記事を補足としてリンクしておきます。

ラスタライズ

さて、Vertex shaderで各頂点の二次元座標がわかりましたが、まだモニタに表示することはできません。Vertex shaderで計算した座標は -1 \le x, y \le 1浮動小数点(画面の左端が x = -1)で表されており、これを例えば800x600pxのモニタのピクセルと対応させる必要があり、この工程をラスタライズと呼びます。ラスタライズはOpenGLが自動で行うため、プログラマがあえて命令する必要はありません(というか普通カスタマイズできません)。

Fragment shaderによる色の計算

ラスタライズによってモニタ上の各ピクセルがモデルのどの三角形と対応しているかが決定されましたが、まだ世界は灰色のままです。Fragment shaderでは、三角形そのものに指定されている色情報、またはテクスチャ(モデルの表面にはりつける質感画像)によりピクセルの色を計算します。さらに、三次元空間内に光源を仮定し、モデルの光のあたり具合によって色を明るくしたり影をつけたりします*3

ロボットシミュレータにおいてはロボットの「動き」そのものが大事でありあまり見た目にはこだわらないので、このプロジェクトではfragment shaderによる処理は最低限のものになるでしょう。

Day 2 に向けて

現状はただティーポットが画面上に浮いているだけなので、矢印キーでモデルを回したりカメラ位置を変えたりできるようにしていくつもりです。またBlenderを使ってロボットの3Dモデルを作り始めます。

*1:RustのOpenGL wrapperとしてはもうひとつglutinというのがありますが、これはGLFW相当のやや低いレイヤを扱うライブラリであり、glutinのREADMEにも「もう一段階高レベルな抽象化レイヤを導入すべきやで」との記載があります。gliumとglutinの作者は同一人物ですので、つまるところすなおにgliumを使えということでしょう

*2:物理演算をする場合などはもっとCPU側でいろいろな計算(衝突判定etc)をしますがこの記事の範囲外なので省略

*3:この説明には誤りを含みます。厳密には光源情報もVertex shaderで前処理されている必要があり、Fragment shaderではそこで加工された情報をもとにライティングを行います。

Neobundle.vimのload_cacheを複数ファイル対応にするPRを投げたらマージされた

2015年も年の瀬が押し迫ったところで本年ブログ初めです、ご無沙汰しております。

特に大きなPullReqでもないし記事にするほどでも無い気はするけど、多分同じ問題に当たったことのある人は多いだろう(ほぼ同じ内容のissueが過去にもあった)し、何よりShougowareに初コントリビュートできたので記念記事。

github.com

問題

Neobundle.vimに今年前半に実装された、TOMLファイルでプラグインを管理する機能とbundle設定をcacheする機能を同時に試していて、以下のようなvimrcを書きました。

set runtimepath+=~/.config/nvim/bundle/neobundle.vim

call neobundle#begin('~/.config/nvim/bundle/')

if neobundle#load_cache()
  call neobundle#load_toml('plugin.define.toml')
endif

call neobundle#end()

しかしこの状態でplugin.define.tomlを更新してもNeobundle.vimのcacheが更新されず、:NeoBundleInstallしても新しく登録したプラグインがインストールされません。TOMLファイルの扱いにバグがあるのかと思い、call neobundle#load_toml('plugin.define.toml')source 'plugin.define.vim'として素直にvimscriptで書いても状況は変わらず。

原因

Neobundle.vimのコードを読んでいると、neobundle#load_cache()$MYVIMRCの更新日時がcacheの更新日時よりも新しい場合に限りcacheを再構築することが判明。neobundle#begin()neobundle#end()に挟まれた部分で使っているファイルは適当に読み込みなおしてくれるだろうとかタカをくくっていたのが大間違いでした。

結論

ということで、load_cache()複数ファイル指定ができるような修正を加えてPullReqしました。幸い大きな問題もなくマージしていただけたのでNeobundle.vimを更新すれば使えます。

if neobundle#load_cache(
     \ $MYVIMRC,
     \ 'plugin.define.toml'
     \ )
  call neobundle#load_toml('plugin.define.toml')
endif

引数を省略した場合は従来通り$MYVIMRCのみチェックします。指定できるファイル数に制限はありません。

偉大なプラグインを生み出し続けている暗黒美夢王先生に感謝しつつ、何か引っかかった時は積極的にコントリビュートできるように頑張りたいと思いました。

Vaio Pro (2013 spring)でUbuntuとDual bootで運用している時にWindows updateした後の対処

大学のとある講義で「面白い論文とは、タイトルが短くて一般性の高いものが多い」という話がありましたが…

1. 状況

先月から、Vaio Pro (2013 spring, win8.1)をUbuntuとのDual boot(grub)で運用しています。 昨日Windows updateをしたところBootloader周りに手を入れられたらしく、grubを経由せずにWindowsが立ち上がるようになってしまいました。

2. 解決策

Vaio Proの起動順位トップは\EFI\Boot\bootmgfw.efiらしいので、これをgrubに入れ替えればOKでした。

sudo mv /boot/efi/EFI/Boot/bootmgfw.efi /boot/efi/EFI/Boot/bootmgfw.efi.orig
sudo cp /boot/efi/EFI/ubuntu/grubx64.efi /boot/efi/EFI/Boot/bootmgfw.efi

さらに、これだとgrubメニューからwindowsが立ちあげられなくなってしまうので、 VAIO Pro11 [red edition] SVP1121A2J をUEFIでトリプルブートにしてみた:shirokichi's hobby life:So-netブログ に従って

sudo blkid # check uuid of /dev/sda3
sudo vi /etc/grub.d/40_custom
#!/bin/sh
exec tail -n +3 $0
# This file provides an easy way to add custom menu entries.  Simply type the
# menu entries you want to add after this comment.  Be careful not to change
# the 'exec tail' line above.
menuentry "Windows Boot Manager (SONY Original)" {
        insmod part_gpt
        insmod fat
        set root='(hd0,gpt3)'
        search --no-floppy --fs-uuid --set=root <<< uuid of /dev/sda3 >>>
        chainloader /EFI/Microsoft/Boot/bootmgfw.efi.org
}
sudo update-grub

以上、自分用メモでした

「尤度」の説明の仕方

最近人から「尤度ってつまるところ何なのよ」と訊かれたことがありまして、その時はいい例えが思いつかず、条件付き確率がね…とか全くもって不親切な話をしてしまったわけですが、今閃いたのでメモ。

一般に「確率」と呼ばれるものは「パラメータを固定した上で、あるデータが出てくる確率(条件付き確率)」を表します。つまり\[p(X)=p(X|\beta)\] 一方「尤度」とは「データを固定した上で、特定のパラメータが出てくる確率」なので、↑の「確率」と逆になり\[L = p(\beta|X)\]

これはサイコロで例えるとわかりやすいです。「面の数」をパラメータ、「特定の目が出る確率」をデータだと思ってください。

「確率」とは、「正六面体のサイコロがあります。3の目が出る確からしさは?」と訊かれた時に答えられる数値。一方「とあるサイコロを何度か投げたら、3の目が出る確率が1/6でした。このサイコロが六面体である確率は?」と訊くのが「尤度」ですね。

1/6と聞くと当然六面体だろうと思ってしまいがちですが、もしかしたら八面体のサイコロを6回投げたら3が1回出たのかもしれないわけです。

数学的にはあまり厳密では無いかもしれませんが、イメージを掴むには便利だと思うので今度からこの喩えでやってみます。

いつNeovimに乗り換えるか?

今でしょ

はい、時代遅れのこれがやりたかっただけです。 久しぶりにneovimのHEADをbuildしてみたところ完璧に使えちゃったので皆さん乗り換えましょうという宣伝です。

7/8追記
※注意:半分深夜のテンションで書いた記事ですので適用はご自身の責任でおねがいします。特に末尾のNoticeのところに書いてあるようにいくつかのpluginが動かないので注意してください。

1. build

# Ubuntuの場合。その他OSの依存関係は  https://github.com/neovim/neovim/wiki/Installing#manual-install 参照
sudo apt-get install libtool autoconf automake cmake libncurses5-dev g++ pkg-config

cd ~/.src # お好きなところで
git clone https://github.com/neovim/neovim.git
cd neovim

make
sudo make install # Pacoとかその他お好みで

これで/usr/local/bin/nvimにバイナリがインストールされます。 /home以下に入れたいときは

cmake -DCMAKE_INSTALL_PREFIX:PATH=~/usr/
make install

とすると~/usr/binにインストールできます。

また、Mac OSとArch LinuxにはOS専用のパッケージが提供されています。詳しくはInstalling · neovim/neovim Wiki · GitHub

2. runtime

buildの時に、DCMAKE_INSTALL_PREFIXを指定してインストール先を変えた場合のみ必要な手順です。特に指定せず/usr/local/binにインストールした場合は不要です。

7/8訂正 本家VimのRuntimeが以下のような位置関係でインストールされている場合のみ不要な手順です。

/path/to/bin/nvim # バイナリ
/path/to/share/vim/vim74 # Runtime; このなかにautoloadとかindentとかsyntaxとか

このままでもnvimコマンドで動きますが、シンタックスハイライトとかインデントとかが働きません(NeoVim本体のレポジトリVim Runtimeファイル群が入っていないため)。neovim/vimscriptレポジトリが提供されているので自分で設定します。

cd ~/usr/share
git clone https://github.com/neovim/vimscript.git
alias nvim='VIM=~/usr/share/vimscript/runtime/ nvim'

参考: Installing · neovim/neovim Wiki · GitHub

3. .nvimrc.nvim/の準備

NeoVimの設定ファイルは~/.nvimrcです。存在しない時は.vimrcを読んでくれるとかそういう親切設計にはなっていないようなので、シンボリックリンクを貼りましょう。

ln -sf ~/.vimrc ~/.nvimrc
ln -sf ~/.vim/ ~/.nvim/

参考:Differences from Vim · neovim/neovim Wiki · GitHub

4. Try it!

これでnvimとコマンドを打つと今までのVimと全く同じ環境が再現できるはずです。 満足したらVimとお別れしてNeoVimと幸せに過ごしましょう。

# Uninstall Vim
sudo paco -r vim # 各自の環境に合わせて

# Use NeoVim instead of Vim
echo 'alias vim=nvim' >> ~/.bashrc

Notice

NeoVimはpluginの管理方法を変えると宣言しているので、将来的にこの記事の方法では動かなくなる可能性があります。 また、現状でもいくつかのpluginとは互換性が無いようです。

7/8追記 Shougoさんに解説をいただきました。

Rails 4.1で導入されるActiveRecord Enumsに隠された罠

TL;DR

ActiveRecord::Enumで、安易に値を追加・削除するのは危険。将来の変更に備えて、DBに登録される値をHashで指定しましょう。

class User < ActiveRecord::Base
  # This is BAD
  enum authority: [:registrant, :admin]
  # This is OK
  enum authority: { registrant: 10, admin: 20 }
end

本編

年度も変わりさて心機一転、という季節なのに私の地元は昨日大雪でしたが、皆様いかがお過ごしでしょうか。さて今日は表題の通り、Rails 4.1における目玉のひとつ、ActiveRecord Enumsについてです。

ActiveRecord Enumsとは

例えばUserモデルにauthority(権限)という属性を持たせたい時によくやるのは、DBにintegerのフィールドを用意して、0なら登録ユーザー、1なら管理者、...とする方法ですね。 この方法は便利ですが、0とか1とかいう値をそのまま数値で管理し続けるのは辛いので、scope :registrant, -> { where(authority: 0 }みたいなscopeとか、その他のユーティリティメソッドを自分で用意する必要がありました。

そこでActiveRecord Enumsを用いると、

class User < ActiveRecord::Base
  enum authority: [:registrant, :admin]
end

と定義してやることで、自動的に

User.registrant  # scope :registrant, -> { where(authority: 0) }

u = User.registrant.first
u.authority      # => "registrant"
u.admin!         # u.update_attribute(:authority, 1)

User.authorities # => { registrant: 0, admin: 1 }

みたいな便利メソッドがごそっとまとめて定義されます。

参考:ActiveRecord::Enum

問題点

enum authority: [:registrant, :admin]

と定義すると、DBに登録される値は自動的にregistrantは0、adminは1に設定されます。 この状態で、rails consoleからadminユーザーを一人登録しておきます。

u = User.new(name: "hoge", authority: User.authorities[:admin])
u.save!
u.authority # => "admin"

さてここで、サイトにゲスト権限を設定したくなった場合はどうなるでしょうか。自然に変更するとすれば仮にリストの先頭に加えてしまうと

enum authority: [:guest, :registrant, :admin]

となりますね。

2014-05-11追記

はてブコメントにて何件か「なぜ末尾ではなく先頭に足すのが『自然』なのか」というご指摘をいただきましたが、意図としてはauthorityの低い順に「ゲスト、登録ユーザ、管理者」と並んでいる方が「登録ユーザ、管理者、ゲスト」という並びよりも自然かなというものでした。
とはいえ、やはりenumの何たるかを考えると、後ろに足すのが普通だというご指摘は明らかに的を射ていますので、本文を修正してあります。
---追記ここまで---

この時、先ほど登録したhogeさんの状態を確認してみましょう。

u = User.find_by(name: "hoge")
u.authority # => "registrant"

なんと!hogeさんがadminからregistrantに格下げされてしまいました!

メカニズム

最初に

enum authority: [:registrant, :admin]

の状態でhogeさんをadminとして登録した時、DBには1という値が保存されています。決して"admin"という文字列が保存されているわけではありません。integerのカラムなのですから当然です。

先程は、この状態でmodel

enum authority: [:guest, :registrant, :admin]

と書き換えた結果、DBに保存されている1という値が示すauthorityregistrantになってしまっています。つまり

User.authorities # => { guest: 0, registrant: 1, admin: 2 }

というわけです。そのため、hogeさんのレコードが持っていた1という値により、hogeさんはregistrantであるとされてしまいました。

解決策

ActiveRecord Enumsには、DBに保存される値をHashで指定できます。

最初(guestがなかった頃)に

enum authority: { registrant: 10, admin: 20 }

としておけば、adminとして登録したhogeさんのレコードには20という値が保存されます。

これならば、guestを以下のように追加しても問題ありません。

enum authority: { guest: 5, registrant, 10, admin: 20 }

ユーザーの権限の種類なんて最初っからきちんと設計しろよ!という話も無くはありませんが、変更は常に起こりうるものです。今回のケースでは10文字くらい多くタイプするだけで変更耐性を付けられるのですから、ぜひとも採用しておきたいものだと思います。