ガイド
Languages / 言語: English · 日本語 — the Japanese edition is a complete machine-assisted translation under review; corrections are welcome. 日本語版は機械翻訳を基にした全訳で、レビューを進めています。修正のご提案を歓迎します。
ナビゲーション: ドキュメントルート
新しいユーザーおよび組み込み開発者に向けた導入指向のドキュメントです。アーキテクチャ、仕様、リファレンスの各セクションが Keleusma とは何かを説明するのに対し、本セクションはその使い方を説明します。
プレイグラウンドでは、ブラウザ上で Keleusma をお試しいただけます。WebAssembly として動作するコンパイラがプログラムをコンパイルおよび検証し、その最悪実行時間およびメモリの上限をその場で報告します。インストールは不要です。
ガイドは 2 つの層から成ります。第 1 の層は、Keleusma を一から教える 40 章の直線的な講座であり、動画での提示に適したサイズで、単一の学習の流れとして順序づけられています。第 2 の層は、調べ物やより深い学習のためのリファレンスページ群です。講座とリファレンスページは意図的に重複しています。講座は最初の学習のためのものであり、リファレンスページは振り返るためのものです。
講座
10 部構成の 40 章です。各章は自己完結型でデモ駆動であり、おおよそ 1 本の短い動画に収まるサイズです。第 I 部から第 VIII 部までは言語を教え、第 IX 部は Rust ホストへの組み込みを教え、第 X 部はその先を指し示します。
第I部 — 出発
第2部 — 構成要素
第3部 — データを形作る
| 章 | タイトル |
|---|---|
| 10 | 構造体 |
| 11 | 列挙型 |
| 12 | タプルと配列 |
| 13 | パターンマッチを深く |
| 14 | 多頭関数とガード |
第4部 — Keleusmaの心臓部
| 章 | タイトル |
|---|---|
| 15 | 3つの関数カテゴリ |
| 16 | yield — ホストと話す |
| 17 | loop関数 |
| 18 | データセグメント |
第5部 — 検証器と保証
| 章 | タイトル |
|---|---|
| 19 | なぜプログラムは却下されたのか |
| 20 | 時間とメモリの予算 |
第6部 — さらに深く
| 章 | タイトル |
|---|---|
| 21 | ジェネリクスとトレイト |
| 22 | 新しい型と精製型 |
| 23 | 大きな数 — オーバーフロー構文 |
| 24 | 情報流ラベル |
第7部 — プログラムの出荷
| 章 | タイトル |
|---|---|
| 25 | ソースからバイトコードへ |
| 26 | 署名付きモジュールとホットコードスワップ |
第8部 — 集大成 — 音楽を作る
| 章 | タイトル |
|---|---|
| 27 | ピアノロールの仕組み |
| 28 | 自分の楽曲プレイグラウンドを用意する |
| 29 | 楽曲を書いて書き換える |
| 30 | 楽曲一覧の見学 |
第9部 — RustプログラムにKeleusmaを組み込む
| 章 | タイトル |
|---|---|
| 31 | 組み込みオリエンテーション |
| 32 | VMの構築とモジュールの実行 |
| 33 | ネイティブ関数の登録 |
| 34 | ホスト側のコルーチンプロトコル |
| 35 | アリーナのサイズと境界の読み取り |
| 36 | バイトコードと署名付きバイトコードの読み込み |
| 37 | ホスト側のホットコードスワップ |
| 38 | キャリブレートされたWCETとコストモデル |
| 39 | 完全なホスト、最初から最後まで |
第10部 — ここから先へ
| 章 | タイトル |
|---|---|
| 40 | さらに読み進めるために |
リファレンスページ
リファレンスページは、線形コースのトピック別の伴侶です。それぞれが独立したドキュメントで、対応するコース章が下敷きにしているもの、そして特定の質問を調べるときに読者が戻ってくるものです。
| ドキュメント | 想定読者 | 目的 |
|---|---|---|
| はじめに | 初めてのユーザー | CLIのインストール、最初のスクリプトの作成と実行、20行のRustホストへの組み込み。コース対応: 第2章、第31章。 |
| 組み込みリファレンス | Rustホスト作者 | ホスト側の完全なリファレンス: Vmの構築、ネイティブ関数の登録、アリーナのサイズ、call/resumeプロトコル、エラー回復、不透明型、Libraryトレイト、署名付きモジュール。コース対応: 第9部(第31〜39章)。 |
| ピアノロールの例 | 楽曲作者、ホスト持ち上げ者、ホスト設計者 | ピアノロール例の長文マニュアル: 楽曲の作曲、ホストループを別アプリケーションに持ち上げること、例を他の制御ループ領域への組み込みのアーキテクチャ参照として使うこと。コース対応: 第8部(第27〜30章)。 |
| ローグライクの例 | ゲーム作者、ホスト設計者 | ローグライク例の長文マニュアル: ゲームプレイのルール、ホストと多スクリプトのアーキテクチャ、ダンジョン生成、AIアーキタイプ、アイテム効果、演習。章での対応無し。第40章から指し示しています。 |
| なぜプログラムは拒否されるのか | 検証で失敗したプログラムを持っている人 | 検証器の却下メッセージの全カタログ、原因と提案される書き換えにマッピング。コース対応: 第19章が最もよくある3つを紹介。このドキュメントが全リストです。 |
| よくある質問 | 意外な振る舞いに遭遇した人 | V0.2.0でよくある粗削りな点 — 文字列処理、エスケープシーケンス、ローカル不変性の制約、V0.1.x系からの移行ノート。 |
| クックブック | 既知のパターンを手に入れたい組み込み開発者 | 組み込みパターンのレシピ集: データローダーパターン、モジュールのWCMUからアリーナを自動サイズ、狭いランタイム型エイリアシング、署名付きバイトコード配信、測定コストモデルでのキャリブレート済みWCET。コース対応: 第39章がパターン名を挙げ、このドキュメントがレシピです。 |
| 大きな数 | 多桁算術を必要とする作者 | オーバーフロー構文の完全な作業例: ハイハーフを使った64×64→128ビット乗算と、繰り上がりを連鎖させる加算。コース対応: 第23章が構文を導入、このドキュメントが完全なテクニックです。 |
| 言語モデル向けのガイダンス | AI コーディングアシスタントを使う運用者 | AI ツールが誤りがちなパターン、AGENTS を最初に読むセッションプロトコル、反復時間を削減するプロンプトパターン。 |
| 自動化とスクリプティング | Keleusma で DevOps またはシステム管理の自動化を書く運用者 | 統合された CLI スクリプティングの全体像です。スクリプトを実行する 3 つの方法、スクリプト引数、ワンショット・段階的・デーモンのデプロイ形態に対応づけられたエントリ種別、オーケストレーターとしてのシェルバンドル、署名付きおよび暗号化されたバイトコードの配布、run-tasks による複数スクリプトランナー、そして静的保証が成り立たない箇所を扱います。第 2 章、第 15 章から第 18 章、第 25 章、第 26 章を運用者向けリファレンスドキュメントと結びつけます。 |
| セキュリティポリシー | 制約のある環境で keleusma-cli をデプロイする運用者 | V0.2.1 で導入された厳格モードの署名および暗号化ポリシーです。鍵の生成、ポリシーの有効化、デプロイシナリオ、トラストモデル、デーモンデプロイメント、ティック間隔のケイデンスを扱います。 |
| メトリクス | 組み込みまたは制約のあるデプロイメントを計画するオペレーター | バイナリサイズ、RSS、ピークメモリフットプリント、CPUサイクル、および bash、Lua、Python、Ruby、Node.js との比較です。ティック間隔レートリミッターの下での定常状態デーモンのフットプリントを含みます。 |
| シェル監査 | stddsl::Shell が計画中のデーモンに十分な機能を備えているかどうかを評価するオペレーター | バンドルの現在の機能、解消済みのギャップ、および V0.2.x ポイントリリースに向けた未解決の推奨事項です。 |
関連資料
| パス | 内容 |
|---|---|
examples/scripts/ | 言語機能を示す独立した.kelファイル。keleusma run examples/scripts/<file>.kelで実行できます。 |
examples/ | Rust組み込みの例。cargo run --example <name>で実行します。 |
examples/piano_roll.rs | 楽曲一覧の上でホットコードスワップする、最初から最後までのSDL3オーディオホスト。cargo run --release --example piano_roll --features sdl3-exampleで実行します。 |
examples/rogue/ | ローグライクを駆動する、最初から最後までのSDL3ビデオホスト。cargo run --release --example rogue --features sdl3-exampleで実行します。 |
keleusma-cli/ | cargo install --path keleusma-cli --bin keleusmaでインストールする独立コマンドラインフロントエンド。 |
リファレンスクロスリンク
知らない言葉に出会ったとき:
- GLOSSARY.md は中核となる用語を定義します。
- LANGUAGE_DESIGN.md は関数のカテゴリ、五つの保証、および保守的検証の立場を説明します。
- GRAMMAR.md は形式的な構文リファレンスです。
- TYPE_SYSTEM.md は基本型、文字列の規律、および複合型を説明します。
- STANDARD_LIBRARY.md は
audio::およびmath::の名前空間にバンドルされたネイティブ関数を一覧します。
計画ドキュメント
OUTLINE.md は本コースを起草するにあたって作成された計画文書です。教育上の決定、音楽から Keleusma への概念対応図、およびいまだ検討中の未解決の問いを記録しています。ユーザー向けのドキュメントではなく作業用の資料です。
第1章. Keleusmaとは何か、そして何ではないか
この章の目的
この章を読み終える頃には、Keleusmaがどのような言語で、何のために作られていて、何を意図的に省いているのかが分かるようになります。この章ではコードは書きません。ガイド全体で唯一の純粋なオリエンテーションの章です。あとで読者が驚かされないように、ここで期待値を設定します。この章を読み終える頃には、Keleusmaがどのような言語で、何のために作られていて、何を意図的に省いているのかが分かるようになります。この章ではコードは書きません。ガイド全体で唯一の純粋なオリエンテーションの章です。あとで読者が驚かされないように、ここで期待値を設定します。
楽譜とオーケストラ
楽譜について考えてみてください。楽譜はオーケストラそのものではありません。楽譜は自分で音を出すこともありません。楽譜は正確で有限な指示の集まりであり、その指示を実行に移して会場を音で満たすのはオーケストラの方です。楽譜について考えてみてください。楽譜はオーケストラそのものではありません。楽譜は自分で音を出すこともありません。楽譜は正確で有限な指示の集まりであり、その指示を実行に移して会場を音で満たすのはオーケストラの方です。
Keleusmaは楽譜を書くための言語です。オーケストラの役を担うのは、別のもっと大きなプログラムで、これを「ホスト」と呼びます。ホストは音を出したり、ゲーム画面を描いたり、モーターを動かしたりという、大きくて複雑な仕事を担当します。Keleusmaのプログラムはそのホストの内側に住み、ホストに対して正確かつ予測可能な指示を伝えます。Keleusmaは楽譜を書くための言語です。オーケストラの役を担うのは、別のもっと大きなプログラムで、これを「ホスト」と呼びます。ホストは音を出したり、ゲーム画面を描いたり、モーターを動かしたりという、大きくて複雑な仕事を担当します。Keleusmaのプログラムはそのホストの内側に住み、ホストに対して正確かつ予測可能な指示を伝えます。
これがKeleusmaについて最初に理解すべきことです。Keleusmaは「組み込み」言語です。それ自体が一つのアプリケーションになることを意図したものではありません。むしろ、もっと大きなプログラムの内部にある、小さく、正確で、信頼できる部分になることを意図しています。本ガイドではその大きな方のプログラムを一貫してホストと呼びます。これがKeleusmaについて最初に理解すべきことです。Keleusmaは「組み込み」言語です。それ自体が一つのアプリケーションになることを意図したものではありません。むしろ、もっと大きなプログラムの内部にある、小さく、正確で、信頼できる部分になることを意図しています。本ガイドではその大きな方のプログラムを一貫してホストと呼びます。
一定の拍に合わせて動く
音楽には拍があります。指揮者がタクトを振り下ろし、奏者は一拍を演奏して、次の拍を待ちます。この拍は止まることも、つまずくこともありません。音楽には拍があります。指揮者がタクトを振り下ろし、奏者は一拍を演奏して、次の拍を待ちます。この拍は止まることも、つまずくこともありません。
Keleusmaのプログラムも同じ仕組みで動きます。少量の有限な仕事をして、制御をホストに返し、また呼び出されるのを待ちます。この一回ぶんの番のことを、本ガイドでは「ティック」と呼びます。オーディオのプログラムなら16分音符ごとに1ティック、ゲームなら1フレームごとに1ティック、というように、テンポはホストが決めます。Keleusmaは、各ティックで何が起こるかを担当します。Keleusmaのプログラムも同じ仕組みで動きます。少量の有限な仕事をして、制御をホストに返し、また呼び出されるのを待ちます。この一回ぶんの番のことを、本ガイドでは「ティック」と呼びます。オーディオのプログラムなら16分音符ごとに1ティック、ゲームなら1フレームごとに1ティック、というように、テンポはホストが決めます。Keleusmaは、各ティックで何が起こるかを担当します。
Keleusmaにないもの、そしてその理由
Keleusmaは、ほとんどのプログラミング言語に含まれているいくつかの機能を意図的に省いています。省いているものには、それぞれ理由があります。Keleusmaは、ほとんどのプログラミング言語に含まれているいくつかの機能を意図的に省いています。省いているものには、それぞれ理由があります。
- 無制限のループはありません。Keleusmaにおける繰り返しはすべて、開始する前にその回数が決まっています。楽譜の繰り返し記号は何小節繰り返すかを示しますが、「とりあえずしばらく繰り返してみる」とは決して言いません。無制限のループはありません。Keleusmaにおける繰り返しはすべて、開始する前にその回数が決まっています。楽譜の繰り返し記号は何小節繰り返すかを示しますが、「とりあえずしばらく繰り返してみる」とは決して言いません。
- 再帰はありません。Keleusmaの関数は、直接にも、他の関数を経由しても、自分自身を呼び出すことはできません。再帰はありません。Keleusmaの関数は、直接にも、他の関数を経由しても、自分自身を呼び出すことはできません。
- 自由形式の入力もありません。Keleusmaのプログラムは、コンソールで誰かが入力するのを待ったりはしません。入力はティックの境界で、ホストから構造化された形で渡されます。自由形式の入力もありません。Keleusmaのプログラムは、コンソールで誰かが入力するのを待ったりはしません。入力はティックの境界で、ホストから構造化された形で渡されます。
これらすべての省略には、たった一つの約束があります。Keleusmaは、プログラムが走り出す前から、各ティックが有限の時間と有限のメモリで完了することを保証します。省かれた機能はちょうど、無限に動き続ける可能性のあるものや、際限なくメモリを消費する可能性のあるものです。言語がこの約束を守ろうとすれば、これらの機能を残しておくわけにはいきません。だからKeleusmaは省くことで約束を守ります。これらすべての省略には、たった一つの約束があります。Keleusmaは、プログラムが走り出す前から、各ティックが有限の時間と有限のメモリで完了することを保証します。省かれた機能はちょうど、無限に動き続ける可能性のあるものや、際限なくメモリを消費する可能性のあるものです。言語がこの約束を守ろうとすれば、これらの機能を残しておくわけにはいきません。だからKeleusmaは省くことで約束を守ります。
約束を平易に述べる
これらの制限のおかげで、Keleusmaのプログラムについて、実行する前から次のことが分かります。これらの制限のおかげで、Keleusmaのプログラムについて、実行する前から次のことが分かります。
- フリーズしない。
- メモリを予期せず使い切らない。
- 拍を常に保ち続ける。
第5部で、言語がこれらの性質をどのように確かめるかを説明します。今のところ重要なのは、制限が恣意的なものではないという一点です。これらは約束を守るための代償であり、約束こそがKeleusmaの存在理由です。「次の小節を時間内に終わらせる」と約束できない演奏家はオーケストラの席に座らせてもらえません。Keleusmaはプログラムに対して同じ基準を適用します。第5部で、言語がこれらの性質をどのように確かめるかを説明します。今のところ重要なのは、制限が恣意的なものではないという一点です。これらは約束を守るための代償であり、約束こそがKeleusmaの存在理由です。「次の小節を時間内に終わらせる」と約束できない演奏家はオーケストラの席に座らせてもらえません。Keleusmaはプログラムに対して同じ基準を適用します。
このガイドの使い方
このガイドは音楽を入口として使います。プログラミングの多くの考え方は、音楽の世界に別の名前ですでに存在しています。本ガイドではまず音楽の言葉で名指し、次にプログラミングの考え方に翻訳し、それから正確なKeleusmaの形に落とし込みます。楽譜が読めなくても大丈夫です。楽器が弾けなくても大丈夫です。音楽を聴く習慣さえあれば、十分な直感は持っています。このガイドは音楽を入口として使います。プログラミングの多くの考え方は、音楽の世界に別の名前ですでに存在しています。本ガイドではまず音楽の言葉で名指し、次にプログラミングの考え方に翻訳し、それから正確なKeleusmaの形に落とし込みます。楽譜が読めなくても大丈夫です。楽器が弾けなくても大丈夫です。音楽を聴く習慣さえあれば、十分な直感は持っています。
この章のあとの全ての章では、一つの小さなプログラムを作って実行し、出力を見ます。プログラムは意図して短く保たれています。目標は、読者が自分でそれをタイプし、実行し、書き換えてみることです。この章のあとの全ての章では、一つの小さなプログラムを作って実行し、出力を見ます。プログラムは意図して短く保たれています。目標は、読者が自分でそれをタイプし、実行し、書き換えてみることです。
この章のまとめ
- Keleusmaは、より大きなホストプログラムの内側に組み込まれる小さな言語です。Keleusmaは、より大きなホストプログラムの内側に組み込まれる小さな言語です。
- Keleusmaのプログラムは「ティック」と呼ばれる有限の一巡で動きます。
- Keleusmaは無制限のループ、再帰、自由形式の入力を省く代わりに、各ティックが有限の時間とメモリで完了することを保証します。Keleusmaは無制限のループ、再帰、自由形式の入力を省く代わりに、各ティックが有限の時間とメモリで完了することを保証します。
次の章では、Keleusmaのツールをインストールして、最初のプログラムを動かします。次の章では、Keleusmaのツールをインストールして、最初のプログラムを動かします。
第2章. Keleusmaのインストールと対話プロンプト
この章の目的
この章を読み終える頃には、Keleusmaのツールがインストールされ、対話プロンプトでコードを動かし、最初のプログラムをファイルに保存して実行できるようになります。この章を読み終える頃には、Keleusmaのツールがインストールされ、対話プロンプトでコードを動かし、最初のプログラムをファイルに保存して実行できるようになります。
まず必要なもの
KeleusmaはRustのツールチェーンを使って作られているので、Keleusmaをインストールする前にツールチェーンが入っている必要があります。Rustツールチェーンの標準的なインストーラはrustupで、Rustの公式サイトから入手できます。お使いのOSに対応したものを入れて、動作を確認します。KeleusmaはRustのツールチェーンを使って作られているので、Keleusmaをインストールする前にツールチェーンが入っている必要があります。Rustツールチェーンの標準的なインストーラはrustupで、Rustの公式サイトから入手できます。お使いのOSに対応したものを入れて、動作を確認します。
cargo --version
このコマンドがバージョン番号を表示すれば、ツールチェーンの準備は完了です。このコマンドがバージョン番号を表示すれば、ツールチェーンの準備は完了です。
Keleusmaコマンドラインツールのインストール
Keleusmaはkeleusmaという名前のコマンドラインツールを提供しています。Keleusmaのソースリポジトリのクローンからインストールします。Keleusmaはkeleusmaという名前のコマンドラインツールを提供しています。Keleusmaのソースリポジトリのクローンからインストールします。
git clone https://github.com/sgeos/keleusma
cd keleusma
cargo install --path keleusma-cli --bin keleusma
インストールを確認します。
keleusma --help
「コマンドが見つかりません」と表示される場合は、インストール済みのRustプログラムが置かれるディレクトリがPATHに含まれていません。そのディレクトリは、ホームフォルダの中の.cargo/binです。PATHに追加してもう一度試してください。「コマンドが見つかりません」と表示される場合は、インストール済みのRustプログラムが置かれるディレクトリがPATHに含まれていません。そのディレクトリは、ホームフォルダの中の.cargo/binです。PATHに追加してもう一度試してください。
対話プロンプト
言語を試す最も速い方法は、対話プロンプト、REPLです。起動します。
keleusma repl
プロンプトは> です。式を入力してEnterを押すと、答えが次の行に現れます。ファイルを作る必要も、プログラムを組み立てる必要もありません。少し計算してみます。プロンプトは> です。式を入力してEnterを押すと、答えが次の行に現れます。ファイルを作る必要も、プログラムを組み立てる必要もありません。少し計算してみます。
> 7 + 5
12
> 12 * 2
24
ピアノの1オクターブには白鍵が7つ、黒鍵が5つあり、合わせて12個です。2オクターブだと半音は24個になります。小数の入った数字も使えます。ピアノの1オクターブには白鍵が7つ、黒鍵が5つあり、合わせて12個です。2オクターブだと半音は24個になります。小数の入った数字も使えます。
> 261.6
261.6
これはちょうど真ん中のC、いわゆるミドルCの周波数(ヘルツ)に近い値で、第3章で改めて登場する数字です。これはちょうど真ん中のC、いわゆるミドルCの周波数(ヘルツ)に近い値で、第3章で改めて登場する数字です。
プロンプトは、セッションの間ずっと関数を覚えておいてもらえます。関数を一つ定義してから呼んでみます。プロンプトは、セッションの間ずっと関数を覚えておいてもらえます。関数を一つ定義してから呼んでみます。
> fn semitones_in(octaves: Word) -> Word { octaves * 12 }
defined: semitones_in
> semitones_in(3)
36
fnは関数の宣言を始めます。semitones_inはその名前、octavesは入力、octaves * 12は計算する内容です。関数については後の章でじっくり扱います。今のところは、プロンプトが定義を受け付け、後で使えたという事実だけで十分です。fnは関数の宣言を始めます。semitones_inはその名前、octavesは入力、octaves * 12は計算する内容です。関数については後の章でじっくり扱います。今のところは、プロンプトが定義を受け付け、後で使えたという事実だけで十分です。
REPLのコマンド一覧は:help、終了は:quitです。
> :quit
対話プロンプトは小さなアイデアをすぐ試すのに向いています。一つだけ制限があります。終了するとすべて忘れてしまうことです。プログラムを残しておきたい場合は、ファイルに保存します。対話プロンプトは小さなアイデアをすぐ試すのに向いています。一つだけ制限があります。終了するとすべて忘れてしまうことです。プログラムを残しておきたい場合は、ファイルに保存します。
プログラムをファイルに保存する
任意のフォルダにoctave.kelという名前のファイルを作ります。.kelという拡張子は、Keleusmaのソースであることを示します。中身は一行だけです。任意のフォルダにoctave.kelという名前のファイルを作ります。.kelという拡張子は、Keleusmaのソースであることを示します。中身は一行だけです。
fn main() -> Word { 7 + 5 }
ファイルに保存するプログラムは、mainという名前の関数として書く必要があります。プログラムはmainから始まるからです。-> Wordは、この関数が整数を返すことを示しています。対話プロンプトでは式を自動でmainに包んでくれていましたが、ファイルではそれを明示します。ファイルに保存するプログラムは、mainという名前の関数として書く必要があります。プログラムはmainから始まるからです。-> Wordは、この関数が整数を返すことを示しています。対話プロンプトでは式を自動でmainに包んでくれていましたが、ファイルではそれを明示します。
ファイルを実行します。
keleusma run octave.kel
出力は次のとおりです。
12
Keleusmaのプログラムは自分でテキストを出力したりはしません。代わりに、ツールがmainの返した一つの値を表示します。関数が12を返したので、12と表示されました。Keleusmaのプログラムは自分でテキストを出力したりはしません。代わりに、ツールがmainの返した一つの値を表示します。関数が12を返したので、12と表示されました。
短縮形として、runという単語なしでもツールはファイルを受け付けます。keleusma octave.kelと書けば動きます。短縮形として、runという単語なしでもツールはファイルを受け付けます。keleusma octave.kelと書けば動きます。
macOSとLinuxで使えるオプションの一手間
#!/usr/bin/env keleusma
fn main() -> Word { 7 + 5 }
macOSとLinuxでは、ファイルを他のコマンドのように、それ自体で実行できるようにすることができます。octave.kelの最上部に一行付け加えて、ファイルを次のようにします。macOSとLinuxでは、ファイルを他のコマンドのように、それ自体で実行できるようにすることができます。octave.kelの最上部に一行付け加えて、ファイルを次のようにします。
chmod +x octave.kel
./octave.kel
この最初の行は「シェバン(shebang)」と呼ばれます。ファイルを実行可能としてマークしてから、直接動かしてみます。この最初の行は「シェバン(shebang)」と呼ばれます。ファイルを実行可能としてマークしてから、直接動かしてみます。
出力はやはり12です。これでファイル自体が小さな一つのプログラムとして振る舞います。出力はやはり12です。これでファイル自体が小さな一つのプログラムとして振る舞います。
この手順はmacOSとLinuxに特有のものです。Windowsにはシェバンの仕組みがないので、ファイルはkeleusma run octave.kelで実行します。このコマンドはすべてのOSで動きます。シェバン行はWindowsでも害はないので、シェバン付きのファイルはどのOSでも使えます。この手順はmacOSとLinuxに特有のものです。Windowsにはシェバンの仕組みがないので、ファイルはkeleusma run octave.kelで実行します。このコマンドはすべてのOSで動きます。シェバン行はWindowsでも害はないので、シェバン付きのファイルはどのOSでも使えます。
この章のまとめ
- Keleusmaのツールがインストールされ、コマンドラインから使えるようになりました。Keleusmaのツールがインストールされ、コマンドラインから使えるようになりました。
- 対話プロンプトは式を即座に評価し、セッション中は関数を覚えていてくれます。対話プロンプトは式を即座に評価し、セッション中は関数を覚えていてくれます。
- ファイルに保存したプログラムは
mainという名前の関数で、keleusma run <ファイル>で実行します。ファイルに保存したプログラムはmainという名前の関数で、keleusma run <ファイル>で実行します。 - Keleusmaのプログラムは値を返すことで出力します。
- macOSとLinuxでは、シェバン行と
chmod +xを使えば、スクリプトが単独で動くようになります。macOSとLinuxでは、シェバン行とchmod +xを使えば、スクリプトが単独で動くようになります。
次の章では、複数の関数を持つ完全なプログラムを書きます。音楽家が見覚えのある何かを計算します。次の章では、複数の関数を持つ完全なプログラムを書きます。音楽家が見覚えのある何かを計算します。
第3章. 最初の完全なプログラム — 長音階の一音
この章の目的
この章を読み終える頃には、これまでの一行プログラムよりも大きな、完全なプログラムを一つ書いて実行した状態になります。プログラムは長音階のある一音の周波数を計算します。すべての細部を理解する必要はまだありません。ここで使うすべての考え方には、後の章で独立した一章が用意されています。この章の目的は、一つの完全なプログラムが端から端まで動く様子を見ることです。この章を読み終える頃には、これまでの一行プログラムよりも大きな、完全なプログラムを一つ書いて実行した状態になります。プログラムは長音階のある一音の周波数を計算します。すべての細部を理解する必要はまだありません。ここで使うすべての考え方には、後の章で独立した一章が用意されています。この章の目的は、一つの完全なプログラムが端から端まで動く様子を見ることです。
考え方: 一つの音は一つの周波数
音楽のすべての音は、1秒あたりの振動回数、ヘルツという単位で測られる周波数を持っています。ミドルCの上のAは440ヘルツで振動しています。この章ではこの音を基準にして、他のすべての音を測ります。音楽のすべての音は、1秒あたりの振動回数、ヘルツという単位で測られる周波数を持っています。ミドルCの上のAは440ヘルツで振動しています。この章ではこの音を基準にして、他のすべての音を測ります。
音を数字に結びつけるのは、二つの事実です。
- 1オクターブ上がると、周波数は2倍になります。
- 1オクターブは、半音と呼ばれる12個の等しい段に分かれます。
12個の等しい段で周波数を2倍にしなければならないので、1段ごとに周波数は同じ一定の倍率で大きくなります。その倍率は「2の12乗根」です。半音n個上昇すると、周波数は「2のn / 12乗」倍になります。12個の等しい段で周波数を2倍にしなければならないので、1段ごとに周波数は同じ一定の倍率で大きくなります。その倍率は「2の12乗根」です。半音n個上昇すると、周波数は「2のn / 12乗」倍になります。
音楽家や楽器は、よく音をMIDIナンバーと呼ばれる整数で表します。440ヘルツの基準音であるA4はMIDIナンバー69で、ミドルCはMIDIナンバー60です。MIDIナンバーmの周波数は次のようになります。音楽家や楽器は、よく音をMIDIナンバーと呼ばれる整数で表します。440ヘルツの基準音であるA4はMIDIナンバー69で、ミドルCはMIDIナンバー60です。MIDIナンバーmの周波数は次のようになります。
frequency = 440 * 2 raised to the power ((m - 69) / 12)
考え方: 長音階はステップのパターン
長音階は12個の半音すべてを使うわけではありません。ある根音(ルート)から始まり、半音ステップの一定のパターンで上昇します。長音階は12個の半音すべてを使うわけではありません。ある根音(ルート)から始まり、半音ステップの一定のパターンで上昇します。
0, 2, 4, 5, 7, 9, 11, 12
最初の音は根音そのもので、ゼロ半音上です。最後の音はオクターブで、12半音上です。間のパターンが、長音階特有の響きを生み出します。最初の音は根音そのもので、ゼロ半音上です。最後の音はオクターブで、12半音上です。間のパターンが、長音階特有の響きを生み出します。
プログラムを組み立てる
プログラムは3つの関数からできています。まずプログラム全体を読んでみてから、そのあとの解説を見てください。プログラムは3つの関数からできています。まずプログラム全体を読んでみてから、そのあとの解説を見てください。
use math::pow
fn midi_to_hz(m: Word) -> Float {
440.0 * math::pow(2.0, ((m - 69) as Float) / 12.0)
}
fn scale_degree_hz(root: Word, degree: Word) -> Float {
let steps = [0, 2, 4, 5, 7, 9, 11, 12];
midi_to_hz(root + steps[degree])
}
fn main() -> Float {
scale_degree_hz(60, 4)
}
それぞれの部分について見ていきます。
use math::powは、ホストから関数を借りる宣言です。べき乗の計算はホストの数学ライブラリが提供してくれるので、useで名前を取り込んで使えるようにします。第6章で関数に戻り、第9部でホスト関数の出所を説明します。use math::powは、ホストから関数を借りる宣言です。べき乗の計算はホストの数学ライブラリが提供してくれるので、useで名前を取り込んで使えるようにします。第6章で関数に戻り、第9部でホスト関数の出所を説明します。fn midi_to_hz(m: Word) -> Floatは、上で書いた周波数の式そのものです。MIDIナンバーmをWordとして受け取り、Floatを返します。Floatは小数部分を持てる数値型で、周波数を表すのに必要です。(m - 69) as Floatという式は整数m - 69をFloatに変換します。これは12.0で割れるようにするためです。この変換を「キャスト」と呼びます。Word、Float、キャストは第4章で扱います。fn midi_to_hz(m: Word) -> Floatは、上で書いた周波数の式そのものです。MIDIナンバーmをWordとして受け取り、Floatを返します。Floatは小数部分を持てる数値型で、周波数を表すのに必要です。(m - 69) as Floatという式は整数m - 69をFloatに変換します。これは12.0で割れるようにするためです。この変換を「キャスト」と呼びます。Word、Float、キャストは第4章で扱います。fn scale_degree_hz(root: Word, degree: Word) -> Floatは、音階のパターンをstepsという「配列」に入れています。steps[degree]は、位置degreeにある要素を読みます。関数はその半音数を根音に加えて、midi_to_hzに周波数を尋ねます。配列は第12章で扱います。fn scale_degree_hz(root: Word, degree: Word) -> Floatは、音階のパターンをstepsという「配列」に入れています。steps[degree]は、位置degreeにある要素を読みます。関数はその半音数を根音に加えて、midi_to_hzに周波数を尋ねます。配列は第12章で扱います。fn mainがプログラムを動かします。MIDIナンバー60(ミドルC)を根音とする長音階の、ディグリー4の音を尋ねています。fn mainがプログラムを動かします。MIDIナンバー60(ミドルC)を根音とする長音階の、ディグリー4の音を尋ねています。
実行する
プログラムをscale.kelという名前で保存して、実行します。
keleusma run scale.kel
出力は次のとおりです。
391.99543598174927
配列0, 2, 4, 5, 7, 9, 11, 12の位置4の値は7なので、この音はミドルCから半音7個分上の音です。この音はG4、Cメジャースケールの第5音(ドミナント)で、周波数は392ヘルツの少し手前です。プログラムはそれを最初から計算してくれました。配列0, 2, 4, 5, 7, 9, 11, 12の位置4の値は7なので、この音はミドルCから半音7個分上の音です。この音はG4、Cメジャースケールの第5音(ドミナント)で、周波数は392ヘルツの少し手前です。プログラムはそれを最初から計算してくれました。
書き換えて実行する
配列stepsには8つの要素があり、0から7まで番号が付いています。mainの中のscale_degree_hzの2番目の引数を変えて、もう一度実行してみてください。配列stepsには8つの要素があり、0から7まで番号が付いています。mainの中のscale_degree_hzの2番目の引数を変えて、もう一度実行してみてください。
- ディグリー
0は根音、つまりミドルCそのものになり、約261.63ヘルツです。 - ディグリー
7は根音の1オクターブ上で、約523.25ヘルツです。 - 間のディグリーは音階の残りの音を返します。
1番目の引数を変えれば、音階全体を別の根音に移すことができます。MIDIナンバー69なら、音階はAの上に乗ります。1番目の引数を変えれば、音階全体を別の根音に移すことができます。MIDIナンバー69なら、音階はAの上に乗ります。
このプログラムは1回の実行で1つの周波数しか返しません。なぜなら、第2章で見たように、コマンドラインツールはmainが返した一つの値だけを表示するからです。音階全体を一度に計算してそれを耳で聴くというのは、第8部のピアノロールの仕事です。このプログラムは1回の実行で1つの周波数しか返しません。なぜなら、第2章で見たように、コマンドラインツールはmainが返した一つの値だけを表示するからです。音階全体を一度に計算してそれを耳で聴くというのは、第8部のピアノロールの仕事です。
この章のまとめ
このプログラム一つで、すでに言語の多くの部分を使いました。
- パラメータと戻り型を持つ関数
Word型とFloat型- ある型から別の型へのキャスト
- 配列と、そこから要素を読み出すこと
useを使ってホストから借りる関数
これらすべてに、第4章の値と型から始まる独立した章が割り当てられています。これで一つのKeleusmaの完全なプログラムが動く様子を見たことになります。これが第1部のゴールです。これらすべてに、第4章の値と型から始まる独立した章が割り当てられています。これで一つのKeleusmaの完全なプログラムが動く様子を見たことになります。これが第1部のゴールです。
第4章. 値と型
この章の目的
この章を読み終える頃には、Keleusmaが扱う値の種類と、それぞれが属する型の名前を知っていることになります。
型とは、意味のある値の集まり
たとえば261.6ヘルツのような周波数は小数を持つ数です。拍の数は整数です。今ある音が鳴っているかどうかは「はい・いいえ」で答えられる事柄です。これらはみな別の種類の値で、その「種類」のことを「型」と呼びます。型は、ある値にとって何が意味を持ち何が意味を持たないかを言語が判断する仕組みです。
この章は対話プロンプトを使います。keleusma replで起動して、一緒に入力してみてください。
Word, 整数
Wordは整数です。何かを数えるものはWordになります — 拍数、半音の数、MIDIナンバーなどです。
> 12
12
> 7 + 5
12
一つだけ意外な結果があります。Word同士の割り算は、余りを捨てます。
> 7 / 2
3
7を2で割ると3で余りが1ですが、余りは捨てられます。整数の割り算はいつもゼロの方向に切り捨てます。小数部分が欲しいときは、次の型に手を伸ばします。
Float, 小数を持つ数
Floatは小数部分を持てる数です。周波数はFloatです。Floatは小数点を付けて書きます。その小数点こそが、その値はWordではなくFloatであると言語に伝える印です。
> 3.5
3.5
> 7.0 / 2.0
3.5
7.0と2.0の.0に注目してください。Float同士の割り算は小数部分を保つので、7.0 / 2.0は3.5であって、3ではありません。
bool, 真と偽
boolは「はい・いいえ」の質問に対する答えです。値はちょうど2つ、trueとfalseです。比較式はboolを返します。
> 3 < 5
true
Text, 書かれた文字列
Textは書かれた文字列です。二重引用符で囲んで書きます。
> "middle C"
middle C
Unit、値がまったくないこと
Unit は () の型であり、「ユニット」と読み上げます。これは意味のある値がないことを表します。何か有用な処理を行うものの返すべきものがない関数は、() を返します。
> ()
()
後で登場するいくつかの数値型
Keleusma にはさらに3つの数値型があります。いずれも第II部では必要ないため、ここでは名前を挙げるにとどめます。
Byteは8ビットの整数であり、バイトレベルの作業に使用します。第23章で登場します。Fixedは完全に決定的で再現可能な算術を持つ小数であり、オーディオコードが毎回まったく同じ結果を生成しなければならない場面で使用します。ピアノロールがこれを使用します。Multiword<N, F>は固定幅の多倍長数値であり、幅がNワード、小数ビットがFで、単一のWordには大きすぎる値のために使用します。第23章で登場します。
型が重要である理由
Keleusma プログラム内のすべての値には型があり、言語はプログラムが実行される前に、値がその型が意味をなす場所でのみ使用されていることを検査します。拍数を期待する関数に周波数を渡すと、その検査の時点で捕捉され、後になって誤った音として発覚することはありません。型はプログラム全体の下に張られた安全網です。
この章のまとめ
Wordは整数であり、整数の除算は余りを切り捨てます。Floatは小数点を用いて書かれる小数です。boolはtrueまたはfalseです。Textは二重引用符で囲まれた文字列です。Unitは()と書き、値がないことを意味します。Byte、Fixed、Multiword<N, F>はさらなる数値型であり、後で登場します。
次章では値に名前を付けます。
第5章. 名前と束縛
この章の目的
この章を読み終える頃には、値に名前を付けられるようになり、その名前に関する大切なルールが分かるようになります。この章を読み終える頃には、値に名前を付けられるようになり、その名前に関する大切なルールが分かるようになります。
値に名前を付ける
作曲家がモチーフを書くとき、それに名前を付けます。すると楽譜の他の部分では、その音をもう一度書き出さずに、その名前を呼び戻すだけで参照できます。プログラムも値に対して同じことをします。値に名前を付けることを「束縛」と呼び、その名前そのものを「束縛」と呼びます。作曲家がモチーフを書くとき、それに名前を付けます。すると楽譜の他の部分では、その音をもう一度書き出さずに、その名前を呼び戻すだけで参照できます。プログラムも値に対して同じことをします。値に名前を付けることを「束縛」と呼び、その名前そのものを「束縛」と呼びます。
束縛はletという単語で作ります。
fn main() -> Word {
let beats_per_bar = 4;
let bars = 8;
beats_per_bar * bars
}
これをphrase.kelとして保存し、keleusma run phrase.kelで実行します。出力は次のとおりです。これをphrase.kelとして保存し、keleusma run phrase.kelで実行します。出力は次のとおりです。
32
このプログラムはbeats_per_barとbarsという2つの値に名前を付けて、最後の行で両方の名前を使っています。4分の4拍子で8小節の楽曲は32拍です。このプログラムはbeats_per_barとbarsという2つの値に名前を付けて、最後の行で両方の名前を使っています。4分の4拍子で8小節の楽曲は32拍です。
型を明示する
束縛の型は、言語が自動で推論してくれます。4は整数なので、beats_per_barはWordになります。型はコロンに続けて明示することもできます。束縛の型は、言語が自動で推論してくれます。4は整数なので、beats_per_barはWordになります。型はコロンに続けて明示することもできます。
let beats_per_bar: Word = 4;
型を明示するかどうかは任意です。値が複雑な場合や、型を書いておくと読み手にとってプログラムが分かりやすくなる場合に役立ちます。型を明示するかどうかは任意です。値が複雑な場合や、型を書いておくと読み手にとってプログラムが分かりやすくなる場合に役立ちます。
束縛は変わらない
ここで大切なルールがあります。一度値に名前が付くと、その名前はその値を持ち続けます。束縛は再代入できません。let total = 32;と書いておいて、あとからtotalを別の値にしようとすることは許されません。ここで大切なルールがあります。一度値に名前が付くと、その名前はその値を持ち続けます。束縛は再代入できません。let total = 32;と書いておいて、あとからtotalを別の値にしようとすることは許されません。
これは制限のように聞こえますし、ある一点においては確かに制限です。束縛はループが加算していく「合計用の変数」として使うことができません。なぜなら、それを使うには値を変更する必要があるからです。第8章でこの点に再び戻ります。値を実際に変更する場所は第4部で扱います。これは制限のように聞こえますし、ある一点においては確かに制限です。束縛はループが加算していく「合計用の変数」として使うことができません。なぜなら、それを使うには値を変更する必要があるからです。第8章でこの点に再び戻ります。値を実際に変更する場所は第4部で扱います。
利点は大きいです。プログラムを下の方で名前を読んだとき、その名前は付けたときに与えた値をそのまま持っています。途中で誰かが書き換えたりはしていません。読み手も、言語も、その名前を信頼できます。これは楽譜に書かれた、欄外でラベル付けされたモチーフが、楽譜の中でそのラベルが指されるたびに毎回同じ意味であるのと同じ規律です。利点は大きいです。プログラムを下の方で名前を読んだとき、その名前は付けたときに与えた値をそのまま持っています。途中で誰かが書き換えたりはしていません。読み手も、言語も、その名前を信頼できます。これは楽譜に書かれた、欄外でラベル付けされたモチーフが、楽譜の中でそのラベルが指されるたびに毎回同じ意味であるのと同じ規律です。
この章のまとめ
let name = value;で値に名前を付けます。- 型は
let name: Type = value;と明示できますが、言語が推論することもできます。型はlet name: Type = value;と明示できますが、言語が推論することもできます。 - 束縛は再代入できません。名前はその値を保ち続けます。
次の章では、文の集まりを関数にまとめます。
第6章. 関数
この章の目的
この章を読み終える頃には、自分で関数を一つ書いて、入力を与えて、その結果を使えるようになります。この章を読み終える頃には、自分で関数を一つ書いて、入力を与えて、その結果を使えるようになります。
関数は名前付きのフレーズ
音楽でいうフレーズとは、それだけで意味の通る小さくまとまった音楽的アイデアで、必要な所でどこでも演奏できるものです。プログラムにおける関数も同じ考え方です。関数は名前の付いた計算のひと固まりです。一度名前が付けば、必要な所でいつでも、何度でも、書き直すことなく使えます。音楽でいうフレーズとは、それだけで意味の通る小さくまとまった音楽的アイデアで、必要な所でどこでも演奏できるものです。プログラムにおける関数も同じ考え方です。関数は名前の付いた計算のひと固まりです。一度名前が付けば、必要な所でいつでも、何度でも、書き直すことなく使えます。
ここまでのプログラムには関数は一つ、mainしかありませんでした。プログラムは必要なだけ関数を持つことができます。ここまでのプログラムには関数は一つ、mainしかありませんでした。プログラムは必要なだけ関数を持つことができます。
関数を書く
これは一つの質問に答える関数です — 何オクターブの中に半音はいくつあるか。これは一つの質問に答える関数です — 何オクターブの中に半音はいくつあるか。
fn semitone_steps(octaves: Word) -> Word {
octaves * 12
}
fn main() -> Word {
semitone_steps(3)
}
keleusma runで実行します。出力は次のとおりです。
36
3オクターブは36個の半音にわたります。
関数の各部分
semitone_stepsを部品ごとに見ていきます。
fnが関数の始まりを表します。semitone_stepsはその名前です。名前は関数が何をするかを表すべきです。(octaves: Word)はパラメータリストです。パラメータは入力です。この関数は入力を一つ取り、それをoctavesという名前で、型はWordとしています。各パラメータは自分の型を明示します。(octaves: Word)はパラメータリストです。パラメータは入力です。この関数は入力を一つ取り、それをoctavesという名前で、型はWordとしています。各パラメータは自分の型を明示します。-> Wordは、関数が返す結果の型を表します。{ octaves * 12 }が本体です。本体が結果を計算します。
本体の最後の式が結果になります。「これを返す」という特別な単語はありません。semitone_stepsはoctaves * 12で終わっているので、それが返り値になります。本体の最後の式が結果になります。「これを返す」という特別な単語はありません。semitone_stepsはoctaves * 12で終わっているので、それが返り値になります。
関数を呼び出す
関数を使うことを「呼び出す」と言います。呼び出しとは、関数の名前のあとに括弧で囲んだ入力を並べたものです。semitone_steps(3)という呼び出しは、octavesを3にして関数を走らせます。関数を使うことを「呼び出す」と言います。呼び出しとは、関数の名前のあとに括弧で囲んだ入力を並べたものです。semitone_steps(3)という呼び出しは、octavesを3にして関数を走らせます。
関数は2つ以上の入力を取ることもできます。パラメータはカンマで区切ります。関数は2つ以上の入力を取ることもできます。パラメータはカンマで区切ります。
fn interval(low: Word, high: Word) -> Word {
high - low
}
fn main() -> Word {
interval(60, 67)
}
このプログラムは7を返します。MIDIナンバー60(ミドルC)からその上のG(MIDIナンバー67)までの距離は半音7個分、つまり完全五度です。このプログラムは7を返します。MIDIナンバー60(ミドルC)からその上のG(MIDIナンバー67)までの距離は半音7個分、つまり完全五度です。
この章のまとめ
- 関数は名前の付いた、再利用可能な計算のひと固まりです。
fn name(parameter: Type, ...) -> ResultType { body }で関数を宣言します。fn name(parameter: Type, ...) -> ResultType { body }で関数を宣言します。- 本体の最後の式が結果になります。
- 呼び出しは
name(inputs)という形です。
次の章では、プログラムが判断できるようになります。
第7章 判断を下す
この章の目的
この章を終えるころには、複数の可能性の中から選択するプログラムを書けるようになります。
問いを立てる、比較
判断は問いから始まり、プログラムにおける問いとは比較です。比較の結果は bool、すなわち true か false のいずれかになります。keleusma repl でプロンプトを開き、いくつか試してみてください。
> 3 < 5
true
> 7 == 7
true
比較演算子は < より小さい、> より大きい、<= 以下、>= 以上、== 等しい、!= 等しくない、です。2つの値が等しいかどうかを問うには二重の == を用いることに注意してください。単一の = はすでに名前を束縛するために使われているためです。
問いを組み合わせる、and、or、not
問いは組み合わせられます。Keleusma は組み合わせの語を記号ではなく単語として書きます。他の多くの言語がここで記号を用いるため、その習慣が誤って持ち込まれがちですので、今のうちに記憶に定着させておく価値があります。
andは両辺が真であるときに真になります。orは少なくとも一方の辺が真であるときに真になります。xorは両辺が異なるときに真になります。notは真と偽を反転させます。
> (3 < 5) and (7 == 7)
true
> not (3 < 5)
false
Keleusma には && も || もありません。用いる単語は and、or、xor、not です。
これら4つは両辺を評価します。さらに2つの単語 andalso と orelse は、それらの短絡形です。andalso は左辺が false になった時点で右辺を見ずに false を生成し、orelse は左辺が true になった時点で右辺を見ずに true を生成します。右辺が左辺の検査後にのみ意味を持つ場合にこれらを用い、日常的な場面では and と or を用いてください。
ビットを扱う
上記の単語は bool 全体に作用します。それらのビットレベルの仲間は、Word または Byte のすべてのビットに一度に作用します。band、bor、bxor は2つの値をビットごとに組み合わせ、bnot は1つの値のすべてのビットを反転させます。
> 12 band 10
8
> 12 bor 10
14
> 12 bxor 10
6
4つのシフトは、値のビットを指定した数だけ左または右に移動させ、そのアセンブリニーモニックにちなんで名付けられています。lsl と asl は左にシフトします。lsr は0で埋めながら右にシフトする符号なし形式であり、asr は符号ビットをコピーしながら右にシフトする符号付き形式です。シフト数は定数でも実行時の値でも構いません。
> 1 lsl 4
16
> 48 lsr 2
12
これらの演算子はここでは Word と Byte に作用し、第23章の多倍長 Multiword<N, F> 型にも作用します。この型も同じ名前を持ちます。
値を選ぶ、if と else
if 式は問いに基づいて2つの値のいずれかを選びます。
fn louder_of(a: Word, b: Word) -> Word {
if a > b { a } else { b }
}
fn main() -> Word {
louder_of(80, 100)
}
実行してください。出力は 100 です。この関数は2つの音の強さ、すなわち2つの音量の尺度を比較し、大きい方を返します。a > b が真であれば if は a を生成し、そうでなければ b を生成します。if 全体が1つの値であり、その値こそが louder_of が返すものです。
多数の中から選ぶ、match
可能性が2つより多い場合、match は1つの値を一連のケースと照合します。
fn third_quality(semitones: Word) -> Word {
match semitones {
3 => 1,
4 => 2,
_ => 0,
}
}
fn main() -> Word {
third_quality(4)
}
実行してください。出力は 2 です。三和音の性質を定める音程はその3度です。3半音の3度は短3度で、ここでは 1 と書きます。4半音の3度は長3度で、2 と書きます。アンダースコア _ はすべてを受け止めるケースであり、それより上に列挙されていないものすべてに合致し、0 を生成します。
すべての match はあらゆる可能性を網羅しなければなりません。_ のケースがそれを保証します。第13章では match を再び詳しく扱います。
前提を確認する、assert
bool の問いは、開発中に前提を守る役割も果たせます。assert 文は条件を検査し、それが偽である場合、アサーション失敗としてプログラムを停止させます。
assert count > 0;
assert index < length, "index past the end of the buffer";
assert はデバッグ補助であり、意図的な規則に従います。この検査は keleusma compile --debug で生成されるデバッグビルドにおいてのみ存在します。通常のビルドはアサーションを完全にコンパイル時に除去するため、出荷されるプログラムでは何のコストもかかりません。したがってデバッグビルドと通常のビルドは、1つの成果物ではなく別々のコンパイルであり、任意のメッセージは keleusma strip で取り除ける除去可能なデバッグ情報として記録されます。開発中に真であると信じることを表明するには assert を用い、出荷されるプログラムで保持されなければならない検査には型システムと第23章の部分演算構文に頼ってください。
assert は予約語ではありません。式の前に書けばアサーション文となり、assert(...) と書けば、たまたま assert という名前を付けた関数への通常の呼び出しとなります。
この章のまとめ
- 比較(
<、>、<=、>=、==、!=)はboolを生成します。 - 問いは記号ではなく
and、or、xor、notという単語で組み合わせます。andalsoとorelseは短絡形です。 - ビットレベルの演算子は
WordまたはByteのすべてのビットに作用します。band、bor、bxor、bnot、およびシフトlsl、asl、lsr、asrです。 if condition { ... } else { ... }は2つの値のいずれかを選びます。matchは多数のケースの中から選び、_はすべてを受け止めるケースです。assert conditionは開発時の前提を検査します。これは--debugビルドにおいてのみ存在し、それ以外ではコンパイル時に除去されます。
次章では、ある動作を固定回数繰り返します。
第8章. 有限の繰り返し
この章の目的
この章を読み終える頃には、ループを書けるようになり、Keleusmaのループが他のほとんどの言語のループと違う、たった一つのルールを理解しているはずです。この章を読み終える頃には、ループを書けるようになり、Keleusmaのループが他のほとんどの言語のループと違う、たった一つのルールを理解しているはずです。
回数の決まったループ
楽譜の繰り返し記号は、何小節繰り返すかを示します。「とりあえずしばらく繰り返してみる」とは決して書きません。Keleusmaのループも同じです。すべてのループは、開始する前に回数が決まっています。これがこの章のタイトルにある「有限の」という言葉の意味です。楽譜の繰り返し記号は、何小節繰り返すかを示します。「とりあえずしばらく繰り返してみる」とは決して書きません。Keleusmaのループも同じです。すべてのループは、開始する前に回数が決まっています。これがこの章のタイトルにある「有限の」という言葉の意味です。
ループはforで書きます。数の範囲か、配列の要素を一つずつたどります。
fn main() -> Word {
let durations = [4, 4, 8, 2];
for d in durations {
let _step = d * 2;
}
for beat in 0..4 {
let _tick = beat;
}
durations[2]
}
keleusma runで実行します。出力は次のとおりです。
8
最初のループは配列durationsの4つの要素を順に巡り、毎回dに束縛します。次のループは範囲0..4を巡ります。これは0、1、2、3という4つの数字です。どちらのループも、ループが始まる前に回数が決まっています。配列には決まった長さがあり、範囲は自分の上限を宣言しています。最初のループは配列durationsの4つの要素を順に巡り、毎回dに束縛します。次のループは範囲0..4を巡ります。これは0、1、2、3という4つの数字です。どちらのループも、ループが始まる前に回数が決まっています。配列には決まった長さがあり、範囲は自分の上限を宣言しています。
正直な制限
このプログラムをよく見てください。2つのループは走りますが、結果の8はそれに依存していません。8はdurations[2]、つまり配列の位置2にある値で、ループを使わずに計算しています。このプログラムをよく見てください。2つのループは走りますが、結果の8はそれに依存していません。8はdurations[2]、つまり配列の位置2にある値で、ループを使わずに計算しています。
これは意図的なもので、第5章から直接導かれます。束縛は再代入できません。だからループは合計を加算していくことができません。なぜなら、合計とは毎回の周回で変わる値だからです。fnという種類の宣言で書かれた、原子的な関数の中では、ループは値を巡ることはできても、結果を積み上げていくことはできません。これは意図的なもので、第5章から直接導かれます。束縛は再代入できません。だからループは合計を加算していくことができません。なぜなら、合計とは毎回の周回で変わる値だからです。fnという種類の宣言で書かれた、原子的な関数の中では、ループは値を巡ることはできても、結果を積み上げていくことはできません。
ではなぜループを今学ぶのかというと、ループは第4部で活躍するからです。そこではloopという別の種類の関数の中で、ループの各周回がホストに値を渡したり、保存された状態を更新したりして、繰り返しが本当の仕事をするようになります。この章ではループの形を学んでおきます。あとで本当に必要になったときには、形が見覚えあるものになっているはずです。ではなぜループを今学ぶのかというと、ループは第4部で活躍するからです。そこではloopという別の種類の関数の中で、ループの各周回がホストに値を渡したり、保存された状態を更新したりして、繰り返しが本当の仕事をするようになります。この章ではループの形を学んでおきます。あとで本当に必要になったときには、形が見覚えあるものになっているはずです。
ループを早めに抜ける
ループは回数に到達する前に止めることもできます。breakを使います。
for beat in 0..16 {
if beat == 4 {
break;
}
}
beatが4に到達すると、breakがループを即座に終わらせます。breakのあとにあるセミコロンに注意してください。breakでループを早めに止められたとしても、回数はやはり有限です。ループは最大でも宣言した回数まで(あるいはそれより少なく)しか走らず、それを超えることはありません。beatが4に到達すると、breakがループを即座に終わらせます。breakのあとにあるセミコロンに注意してください。breakでループを早めに止められたとしても、回数はやはり有限です。ループは最大でも宣言した回数まで(あるいはそれより少なく)しか走らず、それを超えることはありません。
この章のまとめ
for name in 0..n { ... }は数の範囲を巡ります。for name in array { ... }は配列の要素を巡ります。- すべてのループは、開始する前に回数が決まっています。
- 原子的な
fnの中では、ループは結果を積み上げることはできません。束縛が変わらないからです。ループが本当の仕事をするのは第4部です。原子的なfnの中では、ループは結果を積み上げることはできません。束縛が変わらないからです。ループが本当の仕事をするのは第4部です。 break;でループを早めに抜けます。
次の章では、値を関数の連なりに通します。
第9章. パイプライン演算子
この章の目的
この章を読み終える頃には、左から右に読める変換の連鎖を書けるようになります。この章を読み終える頃には、左から右に読める変換の連鎖を書けるようになります。
変換の連鎖
ギタリストは信号をエフェクターのチェーンに通します。音はギターを出て、最初のペダルに入り、変化して出て行き、次のペダルに入り、と続きます。チェーンは一つの方向に読み、各段が次の段に渡します。ギタリストは信号をエフェクターのチェーンに通します。音はギターを出て、最初のペダルに入り、変化して出て行き、次のペダルに入り、と続きます。チェーンは一つの方向に読み、各段が次の段に渡します。
プログラムはよく値に対して同じことをします — 出発点となる値を取り、ある関数に通し、その結果を次の関数に通す、というように。これを普通の関数呼び出しとして書くと、内側へ入れ子になっていき、読むときは真ん中から始めなければなりません。Keleusmaはより明快な書き方を提供しています。プログラムはよく値に対して同じことをします — 出発点となる値を取り、ある関数に通し、その結果を次の関数に通す、というように。これを普通の関数呼び出しとして書くと、内側へ入れ子になっていき、読むときは真ん中から始めなければなりません。Keleusmaはより明快な書き方を提供しています。
パイプライン演算子
パイプライン演算子は|>と書きます。式x |> f(args)の意味は、「xを最初の引数として、それに続けてargsを渡して、fを呼び出す」というものです。左側の値を取って、右側の呼び出しの最初の引数として通します。パイプライン演算子は|>と書きます。式x |> f(args)の意味は、「xを最初の引数として、それに続けてargsを渡して、fを呼び出す」というものです。左側の値を取って、右側の呼び出しの最初の引数として通します。
fn up(note: Word, semitones: Word) -> Word {
note + semitones
}
fn main() -> Word {
60
|> up(7)
|> up(5)
}
keleusma runで実行します。出力は次のとおりです。
72
連鎖を上から読んでみます。出発点の値は60、ミドルCのMIDIナンバーです。次の行|> up(7)はup(60, 7)を呼び、音を完全五度上げて67にします。さらに次の行|> up(5)はup(67, 5)を呼び、その音を完全四度上げて72にします。五度の上に四度を積むとオクターブになり、確かに72はミドルCの1オクターブ上です。連鎖を上から読んでみます。出発点の値は60、ミドルCのMIDIナンバーです。次の行|> up(7)はup(60, 7)を呼び、音を完全五度上げて67にします。さらに次の行|> up(5)はup(67, 5)を呼び、その音を完全四度上げて72にします。五度の上に四度を積むとオクターブになり、確かに72はミドルCの1オクターブ上です。
パイプラインの利点
パイプラインを使わずに同じプログラムを書くと、up(up(60, 7), 5)になります。これも正しく動きますが、内側から読むことになります。出発点の値60は真ん中に埋もれ、最初に適用するup(7)は2番目の中に入れ子になっています。パイプライン版では60が先頭に置かれ、各ステップが起こる順に並びます。音楽が動く順序のとおりに、変換が一つまた一つと進んでいくように読めます。パイプラインを使わずに同じプログラムを書くと、up(up(60, 7), 5)になります。これも正しく動きますが、内側から読むことになります。出発点の値60は真ん中に埋もれ、最初に適用するup(7)は2番目の中に入れ子になっています。パイプライン版では60が先頭に置かれ、各ステップが起こる順に並びます。音楽が動く順序のとおりに、変換が一つまた一つと進んでいくように読めます。
この章のまとめ
x |> f(args)は、xを最初の引数としてfを呼びます。- パイプラインは変換を連鎖させ、起こる順に上から下へ読めるようにします。
- パイプラインは、入れ子の関数呼び出しになるはずだったものをより明快に書き直す方法です。パイプラインは、入れ子の関数呼び出しになるはずだったものをより明快に書き直す方法です。
これで第2部は完了です。値に名前を付け、関数を書き、判断し、動作を繰り返し、変換を連鎖させられるようになりました。第3部では、より大きなデータの形を組み立てることに移ります。これで第2部は完了です。値に名前を付け、関数を書き、判断し、動作を繰り返し、変換を連鎖させられるようになりました。第3部では、より大きなデータの形を組み立てることに移ります。
第10章. 構造体
この章の目的
この章を読み終える頃には、関係し合う複数の値を一つの名前付きの形にまとめられるようになります。この章を読み終える頃には、関係し合う複数の値を一つの名前付きの形にまとめられるようになります。
音はピッチだけではない
一つの音は同時にいくつもの事実を持ち運びます。ピッチがあります。音量、いわゆるベロシティがあります。長さがあるかもしれません。これらの事実は一緒に属するものです。これらを別々の3つの値として、常に正しい順序で持ち回るのは、まちがいやすいやり方です。構造体はそれらを、名前の付いた部品を持つ一つの値にまとめます。一つの音は同時にいくつもの事実を持ち運びます。ピッチがあります。音量、いわゆるベロシティがあります。長さがあるかもしれません。これらの事実は一緒に属するものです。これらを別々の3つの値として、常に正しい順序で持ち回るのは、まちがいやすいやり方です。構造体はそれらを、名前の付いた部品を持つ一つの値にまとめます。
構造体を宣言する
構造体の宣言は、各部品にそれぞれ名前と型を付けて並べます。
struct Note {
pitch: Word,
velocity: Word,
}
これはNoteという新しい型を宣言しています。Noteの値は2つの部品、つまり「フィールド」を持ちます — pitchとvelocityで、それぞれWordです。これはNoteという新しい型を宣言しています。Noteの値は2つの部品、つまり「フィールド」を持ちます — pitchとvelocityで、それぞれWordです。
構造体を作って使う
これはNoteを作り、そのフィールドを読む完全なプログラムです。
struct Note {
pitch: Word,
velocity: Word,
}
fn brightness(n: Note) -> Word {
n.pitch + n.velocity
}
fn main() -> Word {
let middle_c = Note { pitch: 60, velocity: 90 };
brightness(middle_c)
}
keleusma runで実行します。出力は次のとおりです。
150
3つのことが起こっています。
Note { pitch: 60, velocity: 90 }がNoteを構築します。各フィールドに名前で値を与えています。これを「構築」と呼びます。Note { pitch: 60, velocity: 90 }がNoteを構築します。各フィールドに名前で値を与えています。これを「構築」と呼びます。brightnessは2つのバラバラな数ではなく、一つのNote全体をパラメータとして取ります。2つの事実が一緒に旅をします。brightnessは2つのバラバラな数ではなく、一つのNote全体をパラメータとして取ります。2つの事実が一緒に旅をします。n.pitchとn.velocityがフィールドを読みます。フィールドは「値、ドット、フィールド名」と書いて取り出します。n.pitchとn.velocityがフィールドを読みます。フィールドは「値、ドット、フィールド名」と書いて取り出します。
まとめる利点
構造体のおかげで、関数のシグネチャに本当に意図したことを書けます。brightnessはNoteを受け取るのであって、呼び出し側が正しい順で渡さなければならない2つの数を受け取るわけではありません。データの構造は宣言の一箇所に書かれ、プログラムのどの部分もそれに従って動きます。構造体のおかげで、関数のシグネチャに本当に意図したことを書けます。brightnessはNoteを受け取るのであって、呼び出し側が正しい順で渡さなければならない2つの数を受け取るわけではありません。データの構造は宣言の一箇所に書かれ、プログラムのどの部分もそれに従って動きます。
この章のまとめ
struct Name { field: Type, ... }は新しい型をまとめて宣言します。Name { field: value, ... }はその型の値を構築します。value.fieldでフィールドを読みます。
次の章では、決まった選択肢のうちの一つになる値を扱います。
第11章 列挙型
この章の目的
この章を終えるころには、固定された選択肢の集合のうち正確に1つである値を記述できるようになります。
固定された集合のうちの1つ
構造体は同時に存在する複数の事実を束ねます。一方で、値がある小さく固定された可能性の集合のうち正確に1つであることもあります。音のアーティキュレーションは、スタッカート、レガート、アクセントのうちの1つです。それは常に正確に1つです。そのような値は列挙型で記述されます。
列挙型の宣言とマッチ
列挙型における各選択肢はバリアントと呼ばれます。
enum Articulation {
Staccato,
Legato,
Accent,
}
fn length_percent(a: Articulation) -> Word {
match a {
Articulation::Staccato => 50,
Articulation::Legato => 100,
Articulation::Accent => 90,
}
}
fn main() -> Word {
length_percent(Articulation::Staccato)
}
実行してください。出力は 50 です。スタッカートの音は、記譜された長さの約半分だけ保持されます。
バリアントは、列挙型名、二重コロン、バリアント名の順で名付けられ、Articulation::Staccato のように書きます。match は値がどのバリアントであるかを検査し、合致するアームを選びます。
この match には _ のすべてを受け止めるケースがないことに注目してください。それは必要ありません。言語は Articulation のすべてのバリアントを把握しており、3つすべてが列挙されているため、この match は網羅的です。もしあるバリアントが省かれていれば、プログラムは実行される前に拒否されます。これは本物の安全網です。後で4つ目のアーティキュレーションを追加すると、それを処理し忘れたすべての match が直ちに捕捉されます。
値を保持するバリアント
バリアントは自身の値を保持することもできます。音程は、ユニゾン、あるいは何半音かの上昇、あるいは下降であるかもしれません。
enum Interval {
Unison,
Up(Word),
Down(Word),
}
fn semitone_shift(i: Interval) -> Word {
match i {
Interval::Unison => 0,
Interval::Up(n) => n,
Interval::Down(n) => 0 - n,
}
}
fn main() -> Word {
semitone_shift(Interval::Up(7))
}
実行してください。出力は 7 です。バリアント Up と Down はそれぞれ Word を保持します。Interval::Up(n) のように match アームがその保持された値に名前を付けると、その値はアーム内で n として利用可能になります。そのようなバリアントの構築は関数呼び出しのように見えます。Interval::Up(7) です。
この章のまとめ
enum Name { Variant, ... }は固定された集合のうちの1つである値を宣言します。Name::Variantはバリアントを名付け、matchはそれに基づいて選択します。- 列挙型に対する
matchはすべてのバリアントを網羅しなければならず、言語がこれを検査します。 - バリアントは
Variant(Type)と書いて値を保持することができ、matchアームはその保持された値に名前を付けて使用できます。 Wordは、列挙型をその判別子にキャストすることの逆である判別子から列挙型への構文d as Name { ... }を用いて、列挙型の値に戻すことができます。第23章を参照してください。
次章では、名前によってではなく位置によって値をまとめます。
第12章 タプルと配列
この章の目的
この章を終えるころには、タプルと配列という2つの異なる方法で、位置によって値をまとめられるようになります。
タプル、固定された値のグループ
構造体はその部分に名前を付けます。一方で、値の小さなグループには名前ではなく順序だけが必要な場合もあります。括弧で書かれた値の組はタプルです。
let event = (60, 4);
そのタプルは音高と拍数を、その順序で保持します。タプルの部分には位置によってアクセスし、0から始まります。event.0 は 60 であり、event.1 は 4 です。
タプルは一度の手順で名前付きの束縛へと分解することもできます。これを分解束縛(デストラクチャリング)と呼びます。
let (pitch, beats) = event;
その行の後、pitch は 60 であり、beats は 4 です。
配列、1つの型からなる固定長の並び
配列は、すべて同じ型で、プログラムを書く時点で長さが固定された値の並びです。角括弧で書きます。
let scale = [0, 2, 4, 5, 7, 9, 11, 12];
要素はその位置によって読み取られ、こちらも0から始まり、scale[2] のように書きます。その配列の型は [Word; 8] と書き、8個の Word 値を意味します。
長さは型の一部であり、決して変わりません。配列は伸縮しません。これがそのメモリ使用量を事前に既知にするものであり、第1章が言語の約束の1つとして述べた点です。
両方を使うプログラム
fn main() -> Word {
let event = (60, 4);
let (pitch, beats) = event;
let scale = [0, 2, 4, 5, 7, 9, 11, 12];
pitch + scale[2] + beats
}
keleusma runで実行します。出力は次のとおりです。
68
タプル event は pitch(これは 60)と beats(これは 4)に分解されます。配列 scale は第3章の長音階のステップパターンを保持し、scale[2] はその3番目の要素である 4 です。合計は 60 + 4 + 4 で、68 になります。
タプルか構造体か、配列か列挙型か
タプルと構造体はどちらも、同時に存在する値をまとめます。各部分に名前を付ける価値があるときは構造体を、短く順序のあるグループが名前なしの方が明快なときはタプルを用いてください。
配列は1つの型の多数の値を保持します。列挙型は固定された型の集合のうち1つの値を保持します。両者は互いの代替ではありません。それらは異なる問いに答えます。
この章のまとめ
- タプル
(a, b)は位置によって値をまとめ、.0、.1などで読み取るか、let (a, b) = ...で分解束縛します。 - 配列
[a, b, c]は1つの型からなる固定長の並びであり、array[index]で読み取ります。 - 配列の長さは固定されており、その型の一部です。
- 末尾を越えて指すインデックスは、インデックス構文
array[i] { ok(v) => ..., invalid_index(idx) => ... }で処理されます。第23章を参照してください。
次章では、値の形に基づいて選択するための道具である match を詳しく学びます。
第13章. パターンマッチを深く
この章の目的
この章を読み終える頃には、matchを一通り理解しているはずです — 受け付けるパターンの種類、網羅性が必要であるというルール、そしてアームを絞り込むガード、までを知ることになります。この章を読み終える頃には、matchを一通り理解しているはずです — 受け付けるパターンの種類、網羅性が必要であるというルール、そしてアームを絞り込むガード、までを知ることになります。
おさらい
前の章ですでにmatchを使ってケースの中から選びました。第7章ではWordをリテラルの数と照合しました。第11章では列挙型をそのヴァリアントと照合しました。この章ではその全体像をまとめます。前の章ですでにmatchを使ってケースの中から選びました。第7章ではWordをリテラルの数と照合しました。第11章では列挙型をそのヴァリアントと照合しました。この章ではその全体像をまとめます。
matchは1つの値とアームの並びを持ちます。各アームはパターン、=>、そして結果、という形です。値に最初に当てはまるパターンを持つアームが実行されます。matchは1つの値とアームの並びを持ちます。各アームはパターン、=>、そして結果、という形です。値に最初に当てはまるパターンを持つアームが実行されます。
パターンの種類
アームには3種類のパターンが出てきます。
- リテラル(たとえば
3)はその値だけに当てはまります。 - 束縛(たとえば
midi)は任意の値に当てはまり、アームの中でその名前を与えます。束縛(たとえばmidi)は任意の値に当てはまり、アームの中でその名前を与えます。 - ワイルドカード
_は任意の値に当てはまり、名前は付けません。これがキャッチオールです。ワイルドカード_は任意の値に当てはまり、名前は付けません。これがキャッチオールです。
列挙型のヴァリアントパターン、たとえばSignal::Note(midi)は、一つのヴァリアントに当てはまり、そのヴァリアントが持ち運ぶ値を束縛します。列挙型のヴァリアントパターン、たとえばSignal::Note(midi)は、一つのヴァリアントに当てはまり、そのヴァリアントが持ち運ぶ値を束縛します。
動く例
enum Signal {
Rest,
Note(Word),
}
fn loudness(s: Signal) -> Word {
match s {
Signal::Rest => 0,
Signal::Note(midi) when midi >= 60 => 2,
Signal::Note(midi) => 1,
}
}
fn main() -> Word {
loudness(Signal::Note(72))
}
keleusma runで実行します。出力は次のとおりです。
2
値はSignal::Note(72)です。最初のアームはSignal::Restを求めていて、当てはまりません。次のアームはSignal::Noteを求め、その持ち運んだ値をmidiとして束縛し、さらにwhen midi >= 60という条件で絞り込みます。アームに付いたwhenが「ガード」です。アームはパターンが当てはまり、かつガードがtrueのときだけ実行されます。ここでは72 >= 60がtrueなので、アームが実行されて結果は2になります。値はSignal::Note(72)です。最初のアームはSignal::Restを求めていて、当てはまりません。次のアームはSignal::Noteを求め、その持ち運んだ値をmidiとして束縛し、さらにwhen midi >= 60という条件で絞り込みます。アームに付いたwhenが「ガード」です。アームはパターンが当てはまり、かつガードがtrueのときだけ実行されます。ここでは72 >= 60がtrueなので、アームが実行されて結果は2になります。
もし音が60未満だったら、ガードがfalseになり、マッチは次のアームSignal::Note(midi)に進みます。これにはガードが無いので、結果は1になります。もし音が60未満だったら、ガードがfalseになり、マッチは次のアームSignal::Note(midi)に進みます。これにはガードが無いので、結果は1になります。
match は網羅されていなければならない
すべてのmatchはあらゆる可能な値に対応しなければなりません。列挙型に対するmatchは、すべてのヴァリアントがカバーされていれば網羅されていることになり、言語がそれを確認してくれます。Wordに対するmatchは、列挙するには値の数が多すぎるので、_のワイルドカードで網羅性を満たします。すべてのmatchはあらゆる可能な値に対応しなければなりません。列挙型に対するmatchは、すべてのヴァリアントがカバーされていれば網羅されていることになり、言語がそれを確認してくれます。Wordに対するmatchは、列挙するには値の数が多すぎるので、_のワイルドカードで網羅性を満たします。
網羅性は形式的なものではありません。それは、どんな値が来てもmatchが結果を生み出すという保証です。プログラムが忘れたケースは無い、ということです。網羅性は形式的なものではありません。それは、どんな値が来てもmatchが結果を生み出すという保証です。プログラムが忘れたケースは無い、ということです。
タプルや構造体を分解する
matchは列挙型に対して最も力を発揮します。そこでは言語が網羅性を正確に確認できます。この章で出てきた他の形については、もう少し単純な道具がすでに用意されています。タプルは第12章で示したようにlet (a, b) = ...で分解します。構造体のフィールドは第10章で示したようにvalue.fieldで読みます。まずこれらを使い、matchは列挙型のヴァリアントを選ぶときと、リテラルの値を選ぶときに使うのが良いです。matchは列挙型に対して最も力を発揮します。そこでは言語が網羅性を正確に確認できます。この章で出てきた他の形については、もう少し単純な道具がすでに用意されています。タプルは第12章で示したようにlet (a, b) = ...で分解します。構造体のフィールドは第10章で示したようにvalue.fieldで読みます。まずこれらを使い、matchは列挙型のヴァリアントを選ぶときと、リテラルの値を選ぶときに使うのが良いです。
この章のまとめ
matchのアームは、パターン、=>、結果、という形です。- パターンはリテラル、束縛、ワイルドカード
_、そして持ち運んだ値を束縛する列挙型ヴァリアントパターンです。パターンはリテラル、束縛、ワイルドカード_、そして持ち運んだ値を束縛する列挙型ヴァリアントパターンです。 whenガードはアームをさらに条件で絞り込みます。- すべての
matchは網羅されていなければならず、言語がそれを強制します。
次の章では、一つの関数を複数の頭部に広げます。
第14章. 多頭関数とガード
この章の目的
この章を読み終える頃には、関数を複数の「頭部」に分けて、各頭部が別のケースを担当するように書けるようになります。この章を読み終える頃には、関数を複数の「頭部」に分けて、各頭部が別のケースを担当するように書けるようになります。
キューごとに準備された応答
演奏者は、指揮者が出すかもしれないキューごとに、別々の応答をリハーサルします。応答は一つのもつれた指示としてではなく、キューごとに分けて書かれます。Keleusmaも関数を同じように書けます。一つの関数名にいくつかの頭部を持たせて、それぞれに独自のケースを与え、関数が呼ばれたときに正しい頭部が選ばれるようにできます。演奏者は、指揮者が出すかもしれないキューごとに、別々の応答をリハーサルします。応答は一つのもつれた指示としてではなく、キューごとに分けて書かれます。Keleusmaも関数を同じように書けます。一つの関数名にいくつかの頭部を持たせて、それぞれに独自のケースを与え、関数が呼ばれたときに正しい頭部が選ばれるようにできます。
値に当てはまる頭部
これはギターの一番細い弦(ハイE弦)のあるフレットのMIDIナンバーを返す関数です。開放弦(0フレット)は独立したケースとして扱っています。これはギターの一番細い弦(ハイE弦)のあるフレットのMIDIナンバーを返す関数です。開放弦(0フレット)は独立したケースとして扱っています。
fn fret_note(0) -> Word { 64 }
fn fret_note(n: Word) -> Word { 64 + n }
fn main() -> Word {
fret_note(5)
}
keleusma runで実行します。出力は69です。
fret_noteには2つの頭部があります。最初の頭部はちょうど0という引数だけに当てはまります。2番目の頭部は束縛nを使い、どの引数にも当てはまります。頭部は書かれた順に試され、最初に当てはまったものが使われます。呼び出しfret_note(5)は0に当てはまらないので、2番目の頭部に進み、64 + 5、つまり69(A4のMIDIナンバー)を返します。fret_noteには2つの頭部があります。最初の頭部はちょうど0という引数だけに当てはまります。2番目の頭部は束縛nを使い、どの引数にも当てはまります。頭部は書かれた順に試され、最初に当てはまったものが使われます。呼び出しfret_note(5)は0に当てはまらないので、2番目の頭部に進み、64 + 5、つまり69(A4のMIDIナンバー)を返します。
順序が大切です。具体的なケース0は、一般的なケースnより前に書きます。逆に書いてしまうと、一般的な頭部があらゆる呼び出しを受け取ってしまい、0の頭部にはたどり着けません。順序が大切です。具体的なケース0は、一般的なケースnより前に書きます。逆に書いてしまうと、一般的な頭部があらゆる呼び出しを受け取ってしまい、0の頭部にはたどり着けません。
ガード付きの頭部
頭部は代わりにwhenガードを持つこともできます。これは第13章のmatchアームに付いていたガードと同じものです。ガードがtrueのときだけ、その頭部が使われます。頭部は代わりにwhenガードを持つこともできます。これは第13章のmatchアームに付いていたガードと同じものです。ガードがtrueのときだけ、その頭部が使われます。
fn tempo_class(bpm: Word) -> Word when bpm < 60 { 0 }
fn tempo_class(bpm: Word) -> Word when bpm < 120 { 1 }
fn tempo_class(bpm: Word) -> Word { 2 }
fn main() -> Word {
tempo_class(90)
}
実行します。出力は1です。tempo_class(90)は最初の頭部を試し、ガード90 < 60がfalseなので、2番目に進みます。そのガード90 < 120はtrueなので、2番目の頭部が動き、中程度のテンポを表す1を返します。最後の頭部にはガードが無く、そこまで来たすべてのものを受け取ります。実行します。出力は1です。tempo_class(90)は最初の頭部を試し、ガード90 < 60がfalseなので、2番目に進みます。そのガード90 < 120はtrueなので、2番目の頭部が動き、中程度のテンポを表す1を返します。最後の頭部にはガードが無く、そこまで来たすべてのものを受け取ります。
多頭関数と match
多頭関数とmatchは関連したアイデアを表現します。matchは一つの関数本体の中で選びます。多頭関数はそもそもどの本体に入るかを選びます。ケース同士がそれぞれ独立した定義に値するくらい大きいときは多頭関数を、選択が一つの計算の中の小さな一歩であるときはmatchを使います。多頭関数とmatchは関連したアイデアを表現します。matchは一つの関数本体の中で選びます。多頭関数はそもそもどの本体に入るかを選びます。ケース同士がそれぞれ独立した定義に値するくらい大きいときは多頭関数を、選択が一つの計算の中の小さな一歩であるときはmatchを使います。
この章のまとめ
- 関数名はいくつかの頭部を持つことができ、各頭部が一つのケースを扱います。関数名はいくつかの頭部を持つことができ、各頭部が一つのケースを扱います。
- 頭部はリテラルの引数に当てはまることも、束縛で受け取ることもできます。
- 頭部は
whenガードを持てます。 - 頭部は書かれた順に試され、最初に当てはまったものが使われるので、具体的なケースを一般的なケースより前に書きます。頭部は書かれた順に試され、最初に当てはまったものが使われるので、具体的なケースを一般的なケースより前に書きます。
これで第3部は完了です。構造体、列挙型、タプル、配列、matchによるデータの分解、そして関数を複数の頭部に分ける手段が使えるようになりました。第4部では言語の中心 — 3種類の関数と、プログラムがホストと話す仕組み — に進みます。これで第3部は完了です。構造体、列挙型、タプル、配列、matchによるデータの分解、そして関数を複数の頭部に分ける手段が使えるようになりました。第4部では言語の中心 — 3種類の関数と、プログラムがホストと話す仕組み — に進みます。
第15章. 3つの関数カテゴリ
この章の目的
この章を読み終える頃には、Keleusmaが持つ3種類の関数と、それぞれの種類が何を許されているかが分かるようになります。この章を読み終える頃には、Keleusmaが持つ3種類の関数と、それぞれの種類が何を許されているかが分かるようになります。
3種類、3つの単語
Keleusmaのすべての関数は、ちょうど3種類のうちの一つです。種類は宣言の最初に置く単語 — fn、yield、loop — で決まります。本ガイドのこれまでの関数はすべてfnでした。この章で3種類すべてを紹介し、続く章で残り2つをそれぞれ深く掘り下げます。Keleusmaのすべての関数は、ちょうど3種類のうちの一つです。種類は宣言の最初に置く単語 — fn、yield、loop — で決まります。本ガイドのこれまでの関数はすべてfnでした。この章で3種類すべてを紹介し、続く章で残り2つをそれぞれ深く掘り下げます。
fn — 完了する計算
fnで宣言された関数は「原子的・全域(atomic total)」な関数です。原子的というのは、途中で止まらず一気に最後まで走る、という意味です。全域というのは、必ず終わる、という意味です。fnの関数は入力を取り、計算し、結果を返して、終わります。ホストと話すために止まることはできず、永遠に走り続けることもできず、自分自身を呼ぶこともできません。fnで宣言された関数は「原子的・全域(atomic total)」な関数です。原子的というのは、途中で止まらず一気に最後まで走る、という意味です。全域というのは、必ず終わる、という意味です。fnの関数は入力を取り、計算し、結果を返して、終わります。ホストと話すために止まることはできず、永遠に走り続けることもできず、自分自身を呼ぶこともできません。
fn perfect_fifth(root: Word) -> Word {
root + 7
}
fn main() -> Word {
perfect_fifth(60)
}
keleusma runで実行します。出力は67です。fnの関数は和音の音を計算するようなものです — 質問が明確で、答えが明確で、それで終わりです。keleusma runで実行します。出力は67です。fnの関数は和音の音を計算するようなものです — 質問が明確で、答えが明確で、それで終わりです。
yield — 一時停止するフレーズ
yieldで宣言された関数は「非原子的・全域(non-atomic total)」な関数です。非原子的というのは、途中で一時停止できるということです。yield関数はホストに値を渡して一時停止でき、あとで再開できます。何度でも一時停止できますが、最後には必ず終わらなければなりません。yieldで宣言された関数は「非原子的・全域(non-atomic total)」な関数です。非原子的というのは、途中で一時停止できるということです。yield関数はホストに値を渡して一時停止でき、あとで再開できます。何度でも一時停止できますが、最後には必ず終わらなければなりません。
yield main(input: Word) -> Word {
let reply = yield input;
reply
}
yield関数は、指揮者のキューで一時停止して、合図のたびに進み、何回かのキューのあとに最後にはきちんと終わる演奏フレーズのようなものです。第16章で一時停止そのものについて扱います。yield関数は、指揮者のキューで一時停止して、合図のたびに進み、何回かのキューのあとに最後にはきちんと終わる演奏フレーズのようなものです。第16章で一時停止そのものについて扱います。
loop — 終わらない楽曲
loopで宣言された関数は「生産的・発散(productive divergent)」な関数です。発散というのは、決して終わらない、ということです。loop関数は永遠に繰り返します。「生産的」というのは付いている条件で — 周回ごとに必ずホストに値を渡さなければならない、ということです。loopで宣言された関数は「生産的・発散(productive divergent)」な関数です。発散というのは、決して終わらない、ということです。loop関数は永遠に繰り返します。「生産的」というのは付いている条件で — 周回ごとに必ずホストに値を渡さなければならない、ということです。
loop main(input: Word) -> Word {
let _ = yield input;
0
}
loop関数は楽曲そのもの、オスティナート(繰り返しモチーフ)で、ホストが回し続ける限りいつまでもうねり続けます。第17章で扱います。loop関数は楽曲そのもの、オスティナート(繰り返しモチーフ)で、ホストが回し続ける限りいつまでもうねり続けます。第17章で扱います。
3つの間のルール
- プログラムは
loop関数を最大で1つしか持てません。1つあれば、それがプログラムのエントリーポイントになります。プログラムはloop関数を最大で1つしか持てません。1つあれば、それがプログラムのエントリーポイントになります。 yield関数はエントリーポイントにもなれますし、ヘルパーにもなれます。fn関数は純粋な計算で、3種類のどの関数からも使えます。
関数の種類は、最初の単語に書かれた約束です。fnは必ず終わる、loopは何かを出し続ける。言語はこれらの約束に依拠して第1章の保証を成り立たせています。関数の種類は、最初の単語に書かれた約束です。fnは必ず終わる、loopは何かを出し続ける。言語はこれらの約束に依拠して第1章の保証を成り立たせています。
yield と loop の実行について
上に挙げた2つのプログラムは見せただけで、実行はしませんでした。yieldやloopのプログラムはホストと話すもので、keleusma runはホスト役を演じてはくれないからです。第16章から第18章で、こうしたプログラムをkeleusma compileで確認する方法を示します。第8部では、本物のloopプログラム — 1曲の楽曲 — をピアノロールというホストの中で実際に走らせます。上に挙げた2つのプログラムは見せただけで、実行はしませんでした。yieldやloopのプログラムはホストと話すもので、keleusma runはホスト役を演じてはくれないからです。第16章から第18章で、こうしたプログラムをkeleusma compileで確認する方法を示します。第8部では、本物のloopプログラム — 1曲の楽曲 — をピアノロールというホストの中で実際に走らせます。
この章のまとめ
fnは原子的・全域な関数で、一気に最後まで走って終わります。yieldは非原子的・全域な関数で、一時停止と再開ができ、必ず最後に終わります。yieldは非原子的・全域な関数で、一時停止と再開ができ、必ず最後に終わります。loopは生産的・発散な関数で、決して終わらず、周回ごとに必ずyieldしなければなりません。loopは生産的・発散な関数で、決して終わらず、周回ごとに必ずyieldしなければなりません。- プログラムは
loop関数を最大1つ持ち、それがエントリーポイントになります。プログラムはloop関数を最大1つ持ち、それがエントリーポイントになります。
次の章では、一時停止そのもの — yield — を見ていきます。
第16章 yield、ホストとの対話
この章の目的
この章を終えるころには、プログラムとそのホストとの間のやり取り、およびそれを実行する yield 式を理解できるようになります。
プログラムは単独で実行されるのではない
第1章では、Keleusma プログラムを楽譜に、ホストをオーケストラにたとえました。この描像は今や正確なものとなります。プログラムは単に自身で最初から最後まで実行されるのではありません。それはホストとの対話の中で実行され、yield はその対話の1つのやり取りです。
やり取り
yield は単一のステップで2つのことを行います。値をホストに渡し、そしてプログラムを一時停止します。それからホストは自身の処理を行い、準備ができるとプログラムを再開し、値を返します。その返された値が yield の結果となります。
これはメトロノームのティックです。ティックの際、プログラムはホストに値を渡して停止します。ホストが動作します。次のティックの際、ホストはプログラムに値を渡し、プログラムは続行します。
プログラムでは、このやり取りは let の一部として書かれます。
let reply = yield question;
次のように読んでください。question をホストに渡し、一時停止し、ホストが再開したら、それが返す値を reply とします。
yield を使うプログラム
yield main(input: Word) -> Word {
let reply = yield input;
reply
}
このプログラムは値 input とともに開始されます。プログラムは input をホストに yield して一時停止します。ホストは何らかの値でそれを再開し、その値が reply となります。プログラムはその後 reply を返して終了します。
対話
すべての yield において2つの型が関わります。渡し出される値の型と、返される値の型です。これらが合わさって、プログラムとホストとの対話、すなわち合意された対話の形を形成します。上記のプログラムでは両方とも Word です。プログラムは Word を yield し、Word で再開されます。
yield するプログラムを実行する
上記のプログラムを echo.kel として保存し、実行してください。
keleusma run echo.kel
出力は次のとおりです。
1
コマンドラインツールは、ティックカウンタープロトコルを通じて yield するプログラムを駆動します。ツールは tick = 1 でスクリプトを呼び出し、スクリプトは input(これは 1)を yield し、ホストは次のティックである 2 で再開し、スクリプトは再開された値を返します。ツールは返された値を表示し、プログラムは終了します。yield するプログラムは、終了する前に何度も一時停止と再開を繰り返すことがあります。第VIII部では、より手の込んだもの、すなわち曲を、ピアノロールの中で実行します。
同じプログラムは、後で実行するためにバイトコードファイルにコンパイルすることもできます。
keleusma compile echo.kel -o echo.bin
このツールは wrote echo.bin (2316 bytes) のような行を出力します。その行は、プログラムが字句解析、構文解析、型検査を通過し、構造検証器を通過したことを意味します。
この章のまとめ
- Keleusma プログラムは、そのホストとの対話の中で実行されます。
yield valueはvalueをホストに渡し、プログラムを一時停止します。- ホストが再開すると、ホストが返す値が
yield式の結果になります。 - yield で出される型と再開時に入ってくる型のペアが、対話を構成します。
- コマンドラインツールは、ティックカウンタプロトコルを通じて
yield mainプログラムを駆動します。プログラムはエントリ関数から制御が戻ったときに終了します。 keleusma compileは、後で実行するためのバイトコードファイルを生成します。
次の章では、決して終わらない関数、すなわち loop を扱います。
第17章 loop 関数
この章の目的
この章の終わりまでに、決して終わらない関数と、それを正直に保つ規則を理解できるようになります。
ストリームのためのプログラム
yield 関数は一時停止と再開を行いますが、最終的には終了します。loop 関数は決して終了しません。これは、ホストが実行し続ける限り続くもの、すなわちオーディオエンジン、ゲーム、制御ループなど、あらゆるものにふさわしい形です。それは実行し、実行し、実行し続けます。
loop main(input: Word) -> Word {
let _ = yield input;
0
}
先頭への復帰
loop の本体の最後の文が実行されても、プログラムは停止しません。実行は本体の先頭に戻り、全体を再び実行します。この先頭への復帰は RESET と呼ばれます。RESET は、Keleusma プログラムにおいて実行が後方へジャンプする唯一の地点です。
本体を通過する各回が1サイクルです。上記のプログラムは input を yield し、再開時に渡される値を _ に束縛して無視し、最後の 0 に到達し、その後 RESET が次のサイクルのために先頭へと運びます。
パラメータは更新されます
パラメータ input は要求されるものではありません。各サイクルの先頭で、ホストがそのサイクルの input の値を渡します。ゲームは最新のコントローラ状態を渡すかもしれません。オーディオシーケンサは現在のティック番号を渡すかもしれません。プログラムは input を読み取り、毎サイクル、ホストからの新しいデータに応答します。
生産性規則
loop 関数は、毎サイクル yield でホストに値を渡さなければなりません。これは助言ではありません。強制されるものです。1サイクル全体を yield に到達せずに実行しうる loop は、実行される前に拒否されます。第19章でその拒否を示します。
音楽的な解釈は正確です。オスティナートを保持する演奏者は、毎小節何かを鳴らさなければなりません。復帰する計画もなく沈黙してしまう演奏者は、グルーヴを壊し、音楽を止めてしまったのです。生産性規則とは、言語がそれを起こさせないよう拒否することです。
プログラムごとに1つの loop
プログラムは最大でも1つの loop 関数を持ち、それを持つとき、その関数がエントリポイントになります。作品はその中心に1つのグルーヴを持ちます。
loop プログラムの実行
上記のプログラムを pulse.kel として保存し、実行してください。
keleusma run pulse.kel --tick-interval 1s
コマンドラインツールは、yield プログラムと同じティックカウンタプロトコルを通じて loop を駆動しますが、loop は決して終了しない点が異なります。ツールは tick = 1 でスクリプトを呼び出し、yield された各 Word を受け取り、次のティック間隔まで待機し、次のティック番号でスクリプトを再開します。--tick-interval フラグは 100ms、1s、1m、1h、1d、1w のような人間が読める形式の期間を受け付けます。このフラグがない場合、loop はスクリプトが yield する速度で実行されます。実行中の loop を停止するには Control-C を押してください。loop は shell::exit(code) を呼び出すことで自身を停止することもできます。
同じプログラムは、後で実行するためにバイトコードファイルにコンパイルすることができます。
keleusma compile pulse.kel -o pulse.bin
このツールは wrote pulse.bin (2372 bytes) を出力し、プログラムが有効であることを確認します。第VIII部では、ピアノロールの中でより手の込んだ loop プログラム、すなわち楽曲を実行します。
この章のまとめ
loop関数は決して終了しません。その本体を通過する各回が1サイクルです。- RESET は、各サイクルの終わりに本体の先頭へ復帰することです。
- パラメータは、毎サイクルの先頭でホストによって更新されます。
loopは毎サイクルyieldしなければなりません。この生産性規則は強制されます。- プログラムは
loop関数を最大1つ持ち、それがエントリーポイントになります。プログラムはloop関数を最大1つ持ち、それがエントリーポイントになります。
上記の loop は、あるサイクルから次のサイクルへ何も記憶しないため、毎サイクル同じことを行います。次の章では、それに記憶を与えます。
第18章 データセグメント
この章の目的
この章の終わりまでに、loop 関数のあるサイクルから次のサイクルへと生き残る状態を保持できるようになります。
記憶の問題
loop 関数は、サイクルごとに同じ本体を実行します。何かを記憶する必要があると仮定してください。小節のどの拍にいるか、次にどの音が来るか、などです。第5章では、束縛は変更できず、本体の内側で作られた束縛は次のサイクルが始まるまでには消えていることを確立しました。ここまでの言語の状態では、loop 関数は何も記憶できません。
データセグメント
その答えがデータセグメントです。それは、プログラムのメモリのうち変更しうる唯一の領域であり、その値はあるサイクルから次のサイクルへと生き残ります。これは data という語で宣言されます。
data state {
steps: [Word; 4],
}
loop main(input: Word) -> Word {
for i in 0..4 {
state.steps[i] = state.steps[i] + 1;
}
let _ = yield state.steps[0];
0
}
data ブロックは構造体のように見え、そのフィールドは state.steps のようにドットで読み取られます。違いは、そのフィールドが = で代入でき、代入されたものが次のサイクルでもまだそこにあることです。
データセグメントは、すべてのフィールドがゼロになった状態で始まります。上記のプログラムには4つのカウンタがあります。最初のサイクルで各カウンタは1になります。2回目のサイクルでは、RESET を生き延びたことにより、各カウンタは2になります。データセグメントが記憶するため、カウンタはサイクルごとに上昇していきます。
ここでループが本当の仕事をします
上記の for ループを再び見て、第8章を思い出してください。そこでは、アトミックな fn の中の for ループは、束縛が変化しないため結果を組み立てることができませんでした。ここでは同じ for ループが本当の仕事をします。各回が state.steps に書き込み、データセグメントは実際に変化します。まさに第8章が約束したとおり、ここがループがその存在意義を果たす場所です。
3種類の data ブロック
data ブロックには、その可視性を示す印を付けることができます。
- 上記のような素の
dataブロックは shared です。ホストもそれを読み書きできます。これは、ホストとプログラムがその間で状態をやり取りする方法です。 private dataブロックは、プログラム自身のメモリです。それはサイクルをまたいで持続しますが、ホストには見えません。const dataブロックは、読み取り専用の構成であり、プログラムの寿命の間固定され、決して代入されません。
shared は一般的なケースであり、最初に始めるべきものです。
プログラムの実行
プログラムを counters.kel として保存し、実行してください。
keleusma run counters.kel --tick-interval 1s
プログラムは1秒に1回 yield し、各サイクルでデータセグメントは前のサイクルからの状態を保持します。停止するには Control-C を押してください。
プログラムはバイトコードファイルにコンパイルすることもできます。
keleusma compile counters.kel -o counters.bin
このツールは wrote counters.bin (2716 bytes) のような行を出力します。第VIII部では、ピアノロールの中でデータセグメントを使ったより手の込んだプログラム、すなわち楽曲を実行します。
この章のまとめ
- データセグメントは、変更しうる唯一のメモリ領域であり、あるサイクルから次のサイクルへと生き残ります。
data name { field: Type, ... }はそれを宣言します。name.fieldはフィールドを読み取り、name.field = value;はフィールドに書き込みます。loop関数の中のforループは、データセグメントに書き込むことで本当の仕事をします。- data ブロックは、既定では
sharedであり、あるいはprivate、あるいはconstです。
これで、言語の中核である第IV部が完了しました。3種類の関数、ホストとの yield によるやり取り、loop 関数とそのサイクル、そしてそれに記憶を与えるデータセグメントを見てきました。第V部では、プログラムが実行を許可される前に通過しなければならない検査について説明します。
第19章. なぜプログラムは却下されたのか
この章の目的
この章を読み終える頃には、プログラムが却下されるとはどういうことかが分かり、それを実際に目にして、どう対応すればよいかが分かります。この章を読み終える頃には、プログラムが却下されるとはどういうことかが分かり、それを実際に目にして、どう対応すればよいかが分かります。
検証器
第1章はある約束をしました — プログラムが走り出す前から、言語は各ティックが有限の時間と有限のメモリで終わることを保証する、と。その約束を守る言語の部分が「検証器(verifier)」です。すべてのプログラムは検証器を通ります。検証器が有限と証明できなかったプログラムは、却下されてそのプログラムは走りません。第1章はある約束をしました — プログラムが走り出す前から、言語は各ティックが有限の時間と有限のメモリで終わることを保証する、と。その約束を守る言語の部分が「検証器(verifier)」です。すべてのプログラムは検証器を通ります。検証器が有限と証明できなかったプログラムは、却下されてそのプログラムは走りません。
却下は故障ではありません。約束が仕事をしている姿です。却下されたプログラムは、ただ言語がそれを「保証」できなかった、というだけのことです。却下は故障ではありません。約束が仕事をしている姿です。却下されたプログラムは、ただ言語がそれを「保証」できなかった、というだけのことです。
却下を見てみる: 再帰
少しでもプログラミングを見たことがある人は、遅かれ早かれ「自分自身を呼び出す関数」に手を伸ばします。「これをまたやれ」と表現する自然なやり方だからです。これは数えながら降りていく関数です。少しでもプログラミングを見たことがある人は、遅かれ早かれ「自分自身を呼び出す関数」に手を伸ばします。「これをまたやれ」と表現する自然なやり方だからです。これは数えながら降りていく関数です。
fn count_down(n: Word) -> Word {
if n <= 0 { 0 } else { count_down(n - 1) }
}
fn main() -> Word {
count_down(5)
}
keleusma runで実行します。結果は無く、エラーだけが出ます。
error: verify: VerifyError("count_down: recursive call detected during WCMU topological sort")
意味があるのは「recursive call detected(再帰呼び出しを検出)」という部分です。残りは検出した内部チェックの名前です。意味があるのは「recursive call detected(再帰呼び出しを検出)」という部分です。残りは検出した内部チェックの名前です。
再帰が却下される理由
自分自身を呼び出す関数は、何度でも自分を呼び出してしまえます。深さは入力に依存します。言語はプログラムが走る前に呼び出しがどこまで深くなるか見えないので、仕事量やメモリ量の上限を約束できません。第1章でKeleusmaに無いものとして「再帰」が挙がっていました。このエラーはそのルールが強制されている姿です。自分自身を呼び出す関数は、何度でも自分を呼び出してしまえます。深さは入力に依存します。言語はプログラムが走る前に呼び出しがどこまで深くなるか見えないので、仕事量やメモリ量の上限を約束できません。第1章でKeleusmaに無いものとして「再帰」が挙がっていました。このエラーはそのルールが強制されている姿です。
書き換え
再帰版のcount_downの裏にあった意図は「5回繰り返す」ということでした。Keleusmaでは決まった回数の繰り返しを、回数をリテラルで書いたforループで表現します。再帰版のcount_downの裏にあった意図は「5回繰り返す」ということでした。Keleusmaでは決まった回数の繰り返しを、回数をリテラルで書いたforループで表現します。
fn repeat_five() -> Word {
for _i in 0..5 {
let _step = 1;
}
0
}
fn main() -> Word {
repeat_five()
}
これは走り、0を返します。回数5はプログラムに書かれていて、検証器から見えるので、検証器はループが有限であることを証明できます。第8章で示したとおり、fnの中のこの種のループは結果を積み上げることはできません。実際に合計が必要な場合は、第18章で示したloop関数のデータセグメントが受け持ちます。これは走り、0を返します。回数5はプログラムに書かれていて、検証器から見えるので、検証器はループが有限であることを証明できます。第8章で示したとおり、fnの中のこの種のループは結果を積み上げることはできません。実際に合計が必要な場合は、第18章で示したloop関数のデータセグメントが受け持ちます。
もう2つの却下
回数が定数でないforループも却下されます。
fn process(n: Word) -> Word {
for i in 0..n {
let _step = i;
}
0
}
これはno statically extractable iteration boundという文字列を含むエラーを出します。回数nは実行時に来る値で、検証器は事前に見られません。直し方は同じです — 定数のリミットを使うか、長さが固定の配列を巡るようにします。これはno statically extractable iteration boundという文字列を含むエラーを出します。回数nは実行時に来る値で、検証器は事前に見られません。直し方は同じです — 定数のリミットを使うか、長さが固定の配列を巡るようにします。
yieldが無いloop関数はStream block must contain at least one Yieldで却下されます。第17章の生産性ルールが、検証器によって強制されている姿です。yieldが無いloop関数はStream block must contain at least one Yieldで却下されます。第17章の生産性ルールが、検証器によって強制されている姿です。
却下の2つの分類
却下は2つのグループに分かれます。
- ある種のプログラムは、そもそも上限が存在しないから却下されます。再帰がその一つです。言語の将来の改良でも、それを受け入れるようにはなりません。証明するものが無いからです。対応は、プログラムを書き換える、これしかありません。ある種のプログラムは、そもそも上限が存在しないから却下されます。再帰がその一つです。言語の将来の改良でも、それを受け入れるようにはなりません。証明するものが無いからです。対応は、プログラムを書き換える、これしかありません。
- 別の種のプログラムは、上限は存在するけれど、今の分析がまだそれを取り出せないから却下されます。実行時の値で回数が決まるループがその例です。将来、より鋭い検証器なら、そのまま受け入れるかもしれません。別の種のプログラムは、上限は存在するけれど、今の分析がまだそれを取り出せないから却下されます。実行時の値で回数が決まるループがその例です。将来、より鋭い検証器なら、そのまま受け入れるかもしれません。
どちらの場合でも、初心者にとって必要な対応は同じです — 検証器が受け入れる形にプログラムを書き換える、です。リポジトリ内のドキュメントWHY_REJECTED.mdに却下メッセージとその書き換え方の一覧があります。どちらの場合でも、初心者にとって必要な対応は同じです — 検証器が受け入れる形にプログラムを書き換える、です。リポジトリ内のドキュメントWHY_REJECTED.mdに却下メッセージとその書き換え方の一覧があります。
この章のまとめ
- 検証器はすべてのプログラムを確認し、有限であることを証明できないものを却下します。検証器はすべてのプログラムを確認し、有限であることを証明できないものを却下します。
- 再帰は、その深さが事前に分からないので却下されます。
- 回数が定数でないループも、同じ理由で却下されます。
yieldの無いloopは、生産性ルールによって却下されます。- 却下は故障ではなく、言語が約束を守っている姿です。
次の章ではその約束そのもの — プログラムが収まることを証明される「予算」 — を説明します。次の章ではその約束そのもの — プログラムが収まることを証明される「予算」 — を説明します。
第20章. 時間とメモリの予算
この章の目的
この章を読み終える頃には、すべてのKeleusmaプログラムが「収まる」ことを証明される2つの予算と、それに乗る約束を理解しているはずです。この章を読み終える頃には、すべてのKeleusmaプログラムが「収まる」ことを証明される2つの予算と、それに乗る約束を理解しているはずです。
ティックごとの2つの予算
第19章では検証器がプログラムを却下する姿を見ました。この章では検証器が何を守っているのかを説明します。検証器はすべてのプログラムを2つの予算に従わせ、プログラムが走り出す前にその両方を確認します。第19章では検証器がプログラムを却下する姿を見ました。この章では検証器が何を守っているのかを説明します。検証器はすべてのプログラムを2つの予算に従わせ、プログラムが走り出す前にその両方を確認します。
時間の予算
最初の予算は時間です。プログラムが走り出す前に、言語はプログラムが取りうるすべての経路を考えて、ある1ティックで起こりうる最大の仕事量を割り出し、その量が有限であることを証明します。これが「ワーストケース実行時間」、WCETです。最初の予算は時間です。プログラムが走り出す前に、言語はプログラムが取りうるすべての経路を考えて、ある1ティックで起こりうる最大の仕事量を割り出し、その量が有限であることを証明します。これが「ワーストケース実行時間」、WCETです。
音楽的に読むとそのままです。あるテンポでの一拍には収まる量しかありません。1拍の中に無限の音を詰め込むことはできません。検証器はプログラムの一番忙しいティック、つまり一番仕事量の多いティックでも、その拍に収まることを証明します。音楽的に読むとそのままです。あるテンポでの一拍には収まる量しかありません。1拍の中に無限の音を詰め込むことはできません。検証器はプログラムの一番忙しいティック、つまり一番仕事量の多いティックでも、その拍に収まることを証明します。
予算の単位は「パイプライン化サイクル」です。パイプライン化サイクルは仕事量の単位で、機械の小さなステップを数えるもので、秒数ではありません。言語はその単位での上限を証明します。それを実時間の秒数に変換するには、走る機械によります。言語が保証するのは、ティックあたりの仕事量が有限である、ということです。予算の単位は「パイプライン化サイクル」です。パイプライン化サイクルは仕事量の単位で、機械の小さなステップを数えるもので、秒数ではありません。言語はその単位での上限を証明します。それを実時間の秒数に変換するには、走る機械によります。言語が保証するのは、ティックあたりの仕事量が有限である、ということです。
メモリの予算
2番目の予算はメモリです。言語はあるティックで必要になる最大の作業メモリ量を割り出し、それも有限であることを証明します。これが「ワーストケースメモリ使用量」、WCMUです。それからホストは、その量ぴったりのメモリを「アリーナ」と呼ばれる固定の領域として確保します。2番目の予算はメモリです。言語はあるティックで必要になる最大の作業メモリ量を割り出し、それも有限であることを証明します。これが「ワーストケースメモリ使用量」、WCMUです。それからホストは、その量ぴったりのメモリを「アリーナ」と呼ばれる固定の領域として確保します。
アリーナは決まったサイズの譜面台です。検証器は、プログラムが譜面台に乗る紙より多くを必要とすることが決して無いことを証明します。必要メモリが有限と証明できないプログラム、あるいは証明された必要量がホストの確保したアリーナより大きいプログラムは、走りません。アリーナは決まったサイズの譜面台です。検証器は、プログラムが譜面台に乗る紙より多くを必要とすることが決して無いことを証明します。必要メモリが有限と証明できないプログラム、あるいは証明された必要量がホストの確保したアリーナより大きいプログラムは、走りません。
約束
第15章の関数カテゴリと合わせて、これらの2つの予算は、言語が受け入れるすべてのプログラムについていくつかの約束を支えています。第15章の関数カテゴリと合わせて、これらの2つの予算は、言語が受け入れるすべてのプログラムについていくつかの約束を支えています。
- 全域性 — すべての
fn関数は終わります。 - 生産性 — すべての
loop関数はサイクルごとに値を渡します。 - 時間の有界性 — すべてのティックが時間予算に収まります。
- メモリの有界性 — すべてのティックがメモリ予算に収まります。
- 安全な差し替え — プログラムのコードはRESETの境界で新しいコードに置き換えられて、ホストとの会話を切らずに続けられます。第26章で改めて扱います。安全な差し替え — プログラムのコードはRESETの境界で新しいコードに置き換えられて、ホストとの会話を切らずに続けられます。第26章で改めて扱います。
これらは希望ではありません。受け入れられたすべてのプログラムについて、走り出す前に証明されています。これらは希望ではありません。受け入れられたすべてのプログラムについて、走り出す前に証明されています。
受け入れは証明
このプログラムは受け入れられます。
fn main() -> Word {
for _b in 0..8 {
let _beat = 1;
}
0
}
keleusma runで実行すると0を返します。劇的なことは何も起こりません。けれど、その0が出てくる前に、検証器はループがちょうど8回走ること、そしてだからプログラムの時間とメモリが両方とも有限であることを証明していました。受け入れは静かですが、受け入れこそが証明です。走るすべてのプログラムがそれを通過しています。keleusma runで実行すると0を返します。劇的なことは何も起こりません。けれど、その0が出てくる前に、検証器はループがちょうど8回走ること、そしてだからプログラムの時間とメモリが両方とも有限であることを証明していました。受け入れは静かですが、受け入れこそが証明です。走るすべてのプログラムがそれを通過しています。
保守的なスタンス
検証器は、有限と証明できないプログラムは、実際には問題なく動いたであろうプログラムでも却下します。安全なプログラムを通さないことを選ぶ方が、危険なプログラムを通してしまうよりマシだ、という選択です。第19章の再帰版count_downが、実際にはゼロで止まっていたであろうにも関わらず却下されたのもそのためです。検証器はそれを事前には知り得ません — 「事前」が要点だからです。検証器は、有限と証明できないプログラムは、実際には問題なく動いたであろうプログラムでも却下します。安全なプログラムを通さないことを選ぶ方が、危険なプログラムを通してしまうよりマシだ、という選択です。第19章の再帰版count_downが、実際にはゼロで止まっていたであろうにも関わらず却下されたのもそのためです。検証器はそれを事前には知り得ません — 「事前」が要点だからです。
これがKeleusmaの取引です。言語は他の言語より小さなプログラムの集合しか受け入れません。その代わり、その集合に入っているすべてのプログラムについて、他の言語が一つのプログラムについても約束できないようなことを約束できます。音飛びしてはいけないオーディオエンジンや、必ず時間内に応答しなければならない制御装置にとって、その取引こそがKeleusmaを選ぶ理由になります。これがKeleusmaの取引です。言語は他の言語より小さなプログラムの集合しか受け入れません。その代わり、その集合に入っているすべてのプログラムについて、他の言語が一つのプログラムについても約束できないようなことを約束できます。音飛びしてはいけないオーディオエンジンや、必ず時間内に応答しなければならない制御装置にとって、その取引こそがKeleusmaを選ぶ理由になります。
この章のまとめ
- すべてのプログラムは時間予算とメモリ予算の両方に従わされ、両方とも実行前に確認されます。すべてのプログラムは時間予算とメモリ予算の両方に従わされ、両方とも実行前に確認されます。
- 時間予算はワーストケース実行時間で、単位はパイプライン化サイクルです。
- メモリ予算はワーストケースメモリ使用量で、アリーナの内側に収まります。
- 言語は、受け入れるすべてのプログラムについて、全域性、生産性、時間の有界性、メモリの有界性、安全な差し替えを約束します。言語は、受け入れるすべてのプログラムについて、全域性、生産性、時間の有界性、メモリの有界性、安全な差し替えを約束します。
- 検証器は証明できないものをすべて、設計上、却下します。
これで第5部は完了です。言語が何を保証して、引き換えにプログラムに何を求めるのか、両方が分かりました。第6部では言語そのものに戻り、ここまでの全てを土台にする機能をいくつか紹介します。これで第5部は完了です。言語が何を保証して、引き換えにプログラムに何を求めるのか、両方が分かりました。第6部では言語そのものに戻り、ここまでの全てを土台にする機能をいくつか紹介します。
第21章 ジェネリクスとトレイト
この章の目的
この章の終わりまでに、トレイトで型に振る舞いを付与し、ジェネリクスで多くの型に対して機能する関数を書けるようになります。
トレイト、すなわち名前の付いた役割
アンサンブルにおいて、「旋律を担う楽器」のような役割は、ある晩はフルートによって、次の晩はヴァイオリンによって満たされます。役割には名前が付いており、それを満たす楽器は変わります。トレイトとは、型に対する名前の付いた役割です。
トレイトは振る舞いを宣言します。impl ブロックは、その振る舞いを1つの特定の型に対して提供します。
trait Transpose {
fn up_octave(x: Word) -> Word;
}
impl Transpose for Word {
fn up_octave(x: Word) -> Word {
x + 12
}
}
fn main() -> Word {
let n: Word = 60;
n.up_octave()
}
keleusma run で実行してください。出力は 72 です。
trait Transpose は、この役割を満たす型が up_octave の振る舞いを持つことを宣言します。impl Transpose for Word はそれを提供します。Word にとって、1オクターブ上げることは12を加えることです。呼び出し n.up_octave() はそれを使います。ドットの前の値 n が、その振る舞いが作用する対象です。
n.up_octave().up_octave() のように、あるメソッドを呼び出してその結果に別のメソッドを呼び出すには、当面の間、その間に型付きの束縛が必要です。中間結果を let m: Word = n.up_octave(); で束縛し、次のメソッドを m に対して呼び出してください。
ジェネリクス、すなわち多くの型のための関数
ジェネリック関数は一度書かれ、多くの型に対して機能します。それが作用する型はパラメータとして、山括弧の中の代役の名前として残されます。
fn first<T>(a: T, b: T) -> T {
a
}
fn main() -> Word {
first(64, 67)
}
実行してください。出力は 64 です。
<T> は T という名前の型パラメータを導入します。first の内側では、両方のパラメータと結果が T であり、T が何であれそうなります。呼び出し first(64, 67) は Word 値を使うため、その呼び出しでは T は Word です。同じ関数は Float 値や他のどんな型にも役立ちます。ジェネリック関数とは、どんな楽器であっても機能するように書かれたフレーズです。
const ジェネリクス、すなわちコンパイル時の数
型パラメータは型の代役を務めます。const パラメータは、コンパイル時に固定された数の代役を務めます。それは山括弧の中に const n: Word と書かれ、本体の内側では n は通常の Word 値です。
fn plus<const n: Word>() -> Word {
n + 10
}
fn main() -> Word {
plus::<7>()
}
実行してください。出力は 17 です。名前の後の ::<7> はターボフィッシュであり、この呼び出しに const 値を供給します。const 値は常にこのように書き出され、決して推論されません。なぜなら、コンパイラがそれを読み取るための値引数が存在しないからです。
const パラメータは配列の長さを設定できるため、関数はサイズがそのシグネチャの一部である固定サイズのバッファを受け取ることができます。
fn first<const n: Word>(a: [Word; n]) -> Word {
a[0]
}
fn main() -> Word {
first::<3>([10, 20, 30])
}
出力は 10 です。構造体も const パラメータを取り、任意の型パラメータの後に混ぜて置かれ、構築時には同じターボフィッシュで const を供給します。
struct Buf<const n: Word> {
items: [Word; n],
}
fn get(b: Buf<3>) -> Word {
b.items[2]
}
fn main() -> Word {
get(Buf::<3> { items: [10, 20, 30] })
}
出力は 30 です。const 値は、Buf<n + 1> や Multiword<2 * n> のように、+、-、* を使って他の const 値から構築できます。const の除算は存在しないため、const 算術は常に全域です。
すべての const パラメータは、プログラムが特殊化されるとき、最悪ケースの境界が計算される前に、その具体的な数に置き換えられます。したがって検証器は決して記号的なサイズを見ることがありません。[Word; n] は、そのメモリが測定される時点までには [Word; 3] になっています。これが、const ジェネリクスが確定的な時間およびメモリの境界を保つ理由です。
この章のまとめ
traitは名前の付いた振る舞いを宣言し、implブロックはその振る舞いを1つの型に対して提供します。value.method()は、ドットの前の値に作用して振る舞いを呼び出します。- ジェネリック関数は、
<T>と書かれる型パラメータを使い、一度に多くの型に対して機能します。 <const n: Word>と書かれる const パラメータは、コンパイル時の数の代役を務め、ターボフィッシュf::<7>()によって供給され、配列の長さ、Multiwordの次元、またはWord値として使用できます。それは境界が計算される前に、その具体的な数に消去されます。
次の章では、型に独自の名前と規則を与えます。
第22章 ニュータイプと篩型
この章の目的
この章の終わりまでに、型に独自の名前を与え、そのすべての値が満たさなければならない規則を付与できるようになります。
見た目が似た数の問題
チャンネル番号は Word です。ノートのベロシティは Word です。MIDI ピッチは Word です。これらはすべて整数であり、そのため言語は、そのままにしておくと、そのうちのどれかを別のものが意図された場所で使わせてしまいます。チャンネルを期待する関数にベロシティを渡すことは実際の誤りであり、3つすべてが同じ型であるため、Word 型が捕捉できない誤りです。
ニュータイプ、すなわち独自の名前
ニュータイプは、基礎となる型に新しい独自の名前を与えます。
newtype Channel = Word;
fn raw(c: Channel) -> Word {
c as Word
}
fn main() -> Word {
raw(Channel(2))
}
keleusma run で実行してください。出力は 2 です。
内部的には、Channel は Word であり、まったく同じ速さで動作します。しかし型システムにとっては、Channel と Word は異なる型です。Channel は Channel(2) と書くことで構築されます。基礎となる Word は c as Word と書くことで取り戻されます。言語は、それらの明示的な手順のいずれかなしに、Channel が期待される場所で素の Word を使うこと、あるいはその逆を許しません。見た目が似た数は、これで別々に保たれます。
篩、すなわち規則を伴う名前
ニュータイプは、篩と呼ばれる規則を持つことができます。その規則は、基礎となる値を取り true または false を答える通常の関数です。
fn in_range(x: Word) -> bool {
x >= 0 and x <= 127
}
newtype Velocity = Word where in_range;
fn raw(v: Velocity) -> Word {
v as Word
}
fn main() -> Word {
let soft = Velocity(40);
raw(soft)
}
実行してください。出力は 40 です。MIDI ベロシティは 0 から 127 の間になければなりません。where in_range 節はその規則を Velocity に付与します。Velocity が構築されるたびに、その規則が検査されます。値 40 は通過するので、Velocity(40) は成功します。
壊れた値は捕捉されます
範囲外のベロシティを構築するようにプログラムを変更してください。
fn main() -> Word {
raw(Velocity(200))
}
実行すると、結果はなく、次のものだけが得られます。
error: compile: refinement check `in_range` provably fails for newtype `Velocity` at compile time on argument 200
値 200 はプログラムに書き込まれているため、言語はその場でその規則を検査し、プログラムが実行される前に、それを拒否します。Velocity は単に範囲外の数を保持できません。where 節に一度書かれたその規則は、すべての構築で強制されます。それは、誤った音符が演奏されえないように書かれたパートです。
この章のまとめ
newtype Name = Underlying;は、基礎となる型に独自の名前を与えます。Name(value)は1つを構築します。value as Underlyingは基礎となる値を取り戻します。newtype Name = Underlying where predicate;は規則を付与し、すべての構築で検査されます。- 規則を証明可能に破る構築は、プログラムが実行される前に拒否されます。
- 値が実行時にのみ規則を破りうる場合、ニュータイプ構築の構文
Name(value) { ok(v) => ..., invalid_newtype(x) => ... }がその場で拒否を処理します。第23章を参照してください。
次の章では、上記の構築の拒否を含め、失敗しうる操作を扱います。
第23章 部分操作の処理
この章の目的
この章の終わりまでに、失敗しうる操作、すなわち収まらない算術、ゼロ除算、配列の末尾を越えるインデックス、およびその他いくつかを処理し、プログラムが全域のままで、決して静かに失敗しないようにできるようになります。
全域操作と部分操作
ほとんどの操作は常に値を生成します。2つの小さな数を足すこと、構造体のフィールドを取ること、2つの値を比較すること、これらは全域であり、すべての入力に対して定義されています。いくつかの操作は異なります。それらは数学的に部分的であり、一部の入力で未定義であることを意味します。算術は Word の範囲をオーバーフローしうます。ゼロ除算には答えがありません。インデックスは配列の末尾を越えて指しうます。篩はその値を拒否しうます。これらの各々は、言語が何かをしなければならない実際の入力です。
Keleusma は、部分操作が静かに通り過ぎたりクラッシュしたりすることを許しません。それは各々に定義された結果と、操作を実行してどのケースが起きたかを報告する構文を与え、プログラムが何をするかを決められるようにします。この章では、その構文の一群を扱います。まず算術から始めます。
検査付き算術の構文
その構文は、算術式の後に波括弧で囲まれたアームが続くものです。
fn add_checked(a: Word, b: Word) -> Word {
a + b {
ok(v) => v,
overflow(_, _) => 0,
underflow(_, _) => 0,
}
}
fn main() -> Word {
add_checked(20, 22)
}
keleusma runで実行します。出力は42です。
式 a + b が実行され、その結果がアームのいずれかに振り分けられます。
ok(v)は、真の結果がWordに収まるときに実行されます。結果はvに束縛されます。overflowは、真の結果が大きすぎるときに実行されます。underflowは、真の結果がゼロを大きく下回るときに実行されます。
20 + 22 の場合、結果 42 は収まるので、ok アームが実行されます。
結果が収まらないとき
最大の Word にもう1を足すように main を変更してください。
fn main() -> Word {
add_checked(9223372036854775807, 1)
}
その和は最大の Word を1つ越えます。今度は代わりに overflow アームが実行され、関数は 0 を返します。算術は静かに失敗せず、静かに誤った答えを生成することもありませんでした。構文はオーバーフローを報告し、プログラムがそれについて何をするかを決めました。
上位半分と下位半分
上記では overflow と underflow のアームを overflow(_, _) と書き、これらが保持する値を無視していました。Word においてこれらは二つの値、すなわち真の結果の上位半分と下位半分を保持します。これらは Word の二倍の幅を持つ数の中で計算されます。
overflow(high, low) => ...
これら二つの半分は多倍長算術の基礎です。一つの Word には大きすぎる数は、上位半分と下位半分という一対として保持され、ある位からの桁上げが次の位へと連なっていきます。同梱された例 examples/scripts/09_big_numbers.kel とガイドページ BIG_NUMBERS.md は、この技法を余すところなく取り扱っています。
第一級の多倍長型
一般的な場合には、桁上げを手作業で連ねる必要はありません。Multiword<N, F> 型は固定幅の多倍長固定小数点値であり、N ワード幅で F 個の小数ビットを持ち、半分の値を代わりに保持してくれます。Multiword<N> という形式は整数の場合であり、Multiword<N, 0> と等価です。ワードのタプルから最下位を先頭にして構築し、そのワードをインデックスで取り出します。
fn main() -> Word {
let a = (9223372036854775807, 0) as Multiword<2>;
let b = (1, 0) as Multiword<2>;
let s = a + b;
s[1]
}
a の下位ワードは最大の Word です。1 を加えるとそのワードの最上位ビットが立ち、最小の Word へと変わりますが、下位ワードから桁上げは出ないため、上位ワード s[1] は 0 のままです。これは正しい符号なし多倍長の桁上げであり、上記の検査付き構文における符号付きオーバーフロー報告とは異なります。加算、減算、および六つの比較は、本章で説明する桁上げおよび桁借りのカスケードそのものへと下位変換されるため、これらの演算は新たな命令を追加しません。小数スケール F を適用する整数および固定小数点の乗算、除算、剰余に加えて、四つのシフト lsl、asl、lsr、asr、およびリムごとのビット単位演算子 band、bor、bxor、bnot も利用可能です。この型は B19 として提供されました。
省略可能なアームとラップアラウンドの既定動作
overflow と underflow のアームは省略可能です。これらを省略すると、範囲外の結果は二の補数でラップアラウンドし、素のマシン算術と同じ動作になります。したがって ok アームだけを持つ構文は、まさに通常のラップアラウンド演算であり、意図が見えるように書き出したものです。
let total = a + b { ok(v) => v };
扱いたい場合についてのみアームを追加します。ok アームは常に必ず書かなければならないものです。
ゼロ除算
除算と剰余には別の失敗、すなわちゼロの除数があり、この場合は結果がまったく存在しません。zero_divisor アームがこれを扱い、被除数を束縛します。
fn safe_div(a: Word, b: Word) -> Word {
a / b {
ok(q) => q,
zero_divisor(n) => 0,
}
}
fn main() -> Word {
safe_div(10, 0)
}
出力は 0 です。zero_divisor アームがなければ、ゼロによる除算は暗黙のうちに誤った答えを生成するのではなく、回復可能なエラーによってプログラムを停止します。
その他の数値型
この構文は Word だけでなく四つの数値型で機能します。Byte、Float、Fixed<N> においては、オーバーフローまたはアンダーフローのアームは二つの半分ではなく単一の結果を束縛します。これらの型は多倍長の上位半分を保持しないためです。
fn main() -> Byte {
200Byte + 100Byte {
ok(v) => v,
overflow(w) => w,
}
}
合計 300 は Byte に収まらないため、overflow アームが実行され、ラップされた結果 w、すなわち 44 を束縛します。Byte の結果はその型でタグ付けされた値として Byte(44) と出力されます。サポートされる演算子は +、-、*、/、%、算術左シフト asl、および単項の - であり、許容されるアームは型に依存します。たとえば符号なしの Byte は加算でオーバーフローし得ますが、減算でのみゼロを下回り得ます。算術左シフト asl は値 x * 2^k であるため、Word においては乗算とまったく同様にオーバーフローまたはゼロ以下になり得て、同じ overflow および underflow のアームを取ります。
端への飽和
アーム本体の内部では、キーワード saturate_max と saturate_min が構文の型の最大値および最小値を表します。これらは、手作業で数を選ぶのではなく、範囲外の結果を範囲の端へとクランプすることを可能にします。
fn main() -> Byte {
200Byte + 100Byte {
ok(v) => v,
overflow(_) => saturate_max,
}
}
出力は最大の Byte である Byte(255) です。Word においてこれらのキーワードはワードの境界であり、Float においては最大値と最も負の有限値、Fixed<N> においては固定小数点の極値です。結果の型が with saturate_max または with saturate_min の値を宣言した精緻化されたニュータイプである場合、キーワードはその宣言された境界へと解決されます。
構文の一族
同じ波括弧とアームの形式が、言語におけるあらゆる部分演算を、それぞれ固有のアームキーワードとともに扱います。
インデックス指定。 配列のインデックスは末尾を超えて指し示し得ます。invalid_index アームは問題のあるインデックスを束縛し、ok は要素を束縛します。
fn main() -> Word {
let a = [10, 20, 30];
a[9] {
ok(v) => v,
invalid_index(_) => 0,
}
}
インデックス 9 は範囲外であるため、結果は 0 です。
ニュータイプの構築。 精緻化されたニュータイプの構築は、値が規則に反する場合に失敗し得ます。invalid_newtype アームは述語が拒否した値を束縛します。
fn is_positive(x: Word) -> bool { x > 0 }
newtype Positive = Word where is_positive;
fn main() -> Word {
let p = Positive(0 - 4) {
ok(v) => v as Word,
invalid_newtype(_) => 1,
};
p
}
値 -4 は規則に不合格となるため、invalid_newtype アームが実行され、結果は 1 です。
判別子から列挙型へ。 Word は列挙型の値へと戻すことができます。これは列挙型をその判別子へキャストする逆の操作です。ユニットバリアントはそれ自身へと変換され、payload_discriminant アームはペイロードを持つバリアントのペイロードを供給し、invalid_discriminant はどのバリアントにも一致しない Word を捕捉します。
enum Signal { Stop = 0, Go = 1 }
fn main() -> Word {
let s = 1 as Signal {
invalid_discriminant(_) => Signal::Stop,
};
s as Word
}
判別子 1 は Go バリアントであるため、結果は 1 です。
ネイティブ呼び出し。 ホストが提供するネイティブ関数は失敗を報告し得ます。error アームはネイティブが報告する Word エラーコードを束縛し、ok は成功値を束縛します。
let row = host::lookup(id) {
ok(v) => v,
error(code) => code,
};
ネイティブ呼び出しは keleusma run からではなく、埋め込みホストから実行されます。第33章、ネイティブの登録 は、ホストがエラーコードを報告する方法を含めて、ホスト側を示します。
二つのバックエンド、一つの契約
ここに示すすべての構文は一つの契約を共有します。バイトコード仮想マシン、すなわち keleusma run で実行する検証付きインタプリタは、未処理の部分演算のいずれに対してもトラップします。トラップはホストが受け取る回復可能なエラーであり、クラッシュではありません。同じプログラムの将来のネイティブビルド(後のマイルストーンの主題です)は、代わりに定義された、クラッシュしない値を生成します。ハードウェアがフォールトしない場合はハードウェアの結果を用い、フォールトする場合は挿入された小さなチェックを用います。二つのビルドは、あなたが処理しなかった部分演算においてのみ差異が生じ得ます。これらの構文を通じてすべての結果を処理すれば、あなたのプログラムは全域的です。すなわち両方のバックエンドで同じ結果を生成し、決してトラップしません。各バックエンドが各演算に対して生成する値を含む完全な契約は、RUNTIME_FAULTS.md に規定されています。
この章のまとめ
- いくつかの演算は部分的であり、一部の入力に対して未定義です。この言語はそれぞれに定義された結果と、それを処理するための構文を与えます。
- 検査付き算術
a + b { ok(v) => ..., overflow(...) => ... }はオーバーフロー、アンダーフロー、およびゼロの除数を報告します。overflowとunderflowのアームは省略可能であり、既定ではラップアラウンドします。okは必須です。Wordにおいてアームは上位半分と下位半分を保持し、これが多倍長算術の基礎となります。Byte、Float、Fixed<N>においては単一の結果を保持します。 saturate_maxとsaturate_minは型の範囲の端へとクランプします。- 同じ形式がインデックス指定(
invalid_index)、ニュータイプの構築(invalid_newtype)、判別子から列挙型への変換(payload_discriminant、invalid_discriminant)、およびネイティブ呼び出し(error)を扱います。 - 処理されていない部分演算は仮想マシン上でトラップします。あらゆる結果を処理することがプログラムを全域的にします。
第VI部の最後となる次章では、データを機密として印付けし、それがどこへ流れるかを言語に追跡させます。
第24章. 情報流ラベル
この章の目的
この章を読み終える頃には、ある値に「機密」の印を付けて、その値が許されない場所に流れていないことを言語に確認させられるようになります。この章を読み終える頃には、ある値に「機密」の印を付けて、その値が許されない場所に流れていないことを言語に確認させられるようになります。
これは応用的な章で、第6部の最後の章です。ここで扱う機能は普段のプログラムには必要ありません。データを機密に保たなければならないプログラムのためにあります。これは応用的な章で、第6部の最後の章です。ここで扱う機能は普段のプログラムには必要ありません。データを機密に保たなければならないプログラムのためにあります。
ラベルは型に乗る
マスター録音はスタジオの外に出ない約束のものです。プログラムも同じ性質のデータを扱うことがあります — 公の出力に届いてはいけない値です。Keleusmaは型に「ラベル」を持たせることができます。ラベルは値に付く印で、値と一緒に旅をします。ラベルは型のあとに@を付けて書きます。マスター録音はスタジオの外に出ない約束のものです。プログラムも同じ性質のデータを扱うことがあります — 公の出力に届いてはいけない値です。Keleusmaは型に「ラベル」を持たせることができます。ラベルは値に付く印で、値と一緒に旅をします。ラベルは型のあとに@を付けて書きます。
fn main() -> Word@Master {
classify 42@Master
}
keleusma runで実行します。出力は42です。
Word@Masterは、Masterというラベルを持ったWordです。演算子classify 42@Masterは、ただの値42を取ってMasterラベルを付けます。ラベルの名前はプログラマが選びます。ここではMasterがその一つです。Word@Masterは、Masterというラベルを持ったWordです。演算子classify 42@Masterは、ただの値42を取ってMasterラベルを付けます。ラベルの名前はプログラマが選びます。ここではMasterがその一つです。
ラベルが存在するのは、プログラムが確認されている間だけです。プログラムが走り始めると、ラベルは消えていて、値はただの42になります。ラベルは実行時のコストがゼロです。事前に言語が行う確認のためだけのものです。ラベルが存在するのは、プログラムが確認されている間だけです。プログラムが走り始めると、ラベルは消えていて、値はただの42になります。ラベルは実行時のコストがゼロです。事前に言語が行う確認のためだけのものです。
ラベルは漏えいを防ぐ
ラベルが意味を持つのは、言語がそれを追跡するからです。ラベル付きの値は、そのラベルを受け入れない場所には流れられません。ここでは関数broadcastがただのWordを公の出力に送ります。ラベルが意味を持つのは、言語がそれを追跡するからです。ラベル付きの値は、そのラベルを受け入れない場所には流れられません。ここでは関数broadcastがただのWordを公の出力に送ります。
fn broadcast(x: Word) -> Word {
x
}
fn main() -> Word {
let take = classify 42@Master;
broadcast(take)
}
実行します。結果は無く、こう出ます。
error: compile: type error: argument to `broadcast` expects Word, got Word@Master
値takeはMasterラベルを持っています。broadcastのパラメータはただのWordで、ラベルは無く、つまりMasterラベル付きのデータを受け取れません。takeをbroadcastに渡すと、マスター録音が公の出力に届いてしまいます。言語はそれを「漏えい(leak)」と呼び、プログラムが走る前に却下します。値takeはMasterラベルを持っています。broadcastのパラメータはただのWordで、ラベルは無く、つまりMasterラベル付きのデータを受け取れません。takeをbroadcastに渡すと、マスター録音が公の出力に届いてしまいます。言語はそれを「漏えい(leak)」と呼び、プログラムが走る前に却下します。
declassify: 意図的な解放
ときには機密データを本当に解放するべき場合もあります — 明示的な決定として。declassifyという演算子はラベルを取り除きます。ときには機密データを本当に解放するべき場合もあります — 明示的な決定として。declassifyという演算子はラベルを取り除きます。
fn broadcast(x: Word) -> Word {
x
}
fn main() -> Word {
let take = classify 42@Master;
broadcast(declassify take@Master)
}
実行します。出力は42です。declassify take@MasterがMasterラベルを取り除き、ただのWordにして、broadcastが受け取れるようにしました。実行します。出力は42です。declassify take@MasterがMasterラベルを取り除き、ただのWordにして、broadcastが受け取れるようにしました。
2つの演算子は重みが同じではありません。classifyは制限を加えるだけで、常に安全です。declassifyは制限を取り除くので、プログラムの中で機密データが解放される、唯一の見える場所になります。プログラムを読むレビュー担当者は、declassifyを検索すれば解放の場所すべてを見つけられます。2つの演算子は重みが同じではありません。classifyは制限を加えるだけで、常に安全です。declassifyは制限を取り除くので、プログラムの中で機密データが解放される、唯一の見える場所になります。プログラムを読むレビュー担当者は、declassifyを検索すれば解放の場所すべてを見つけられます。
この章のまとめ
- 型はラベルを持つことができ、
T@Labelと書いて値に印を付けます。 classify expr@Labelはラベルを加え、declassify expr@Labelはラベルを取り除きます。classify expr@Labelはラベルを加え、declassify expr@Labelはラベルを取り除きます。- 言語はラベル付きの値を追跡し、ラベルを受け入れない場所への流れを、プログラムが走る前に却下します。言語はラベル付きの値を追跡し、ラベルを受け入れない場所への流れを、プログラムが走る前に却下します。
- ラベルはプログラムが走る前に消えて、実行時のコストはゼロです。
declassifyは機密データが解放される、意図的で見える場所です。
これで第6部は完了です。スクリプト作者が書く言語の全体をひと通り見たことになります。第7部では、書かれたあとのプログラムに何が起こるか — コンパイル、署名、差し替え — に進みます。これで第6部は完了です。スクリプト作者が書く言語の全体をひと通り見たことになります。第7部では、書かれたあとのプログラムに何が起こるか — コンパイル、署名、差し替え — に進みます。
第25章 ソースからバイトコードへ
この章の目的
本章の終わりまでに、あなたは keleusma run がこれまでずっと行ってきたことを理解し、プログラムを直接実行できるファイルへとコンパイルできるようになります。
ソースとバイトコード
.kel ファイルはソース、すなわち人が書き読むテキストを保持します。仮想マシンはソースを実行しません。仮想マシンは、コンパイラがソースから生成したコンパクトな形式であるバイトコードを実行します。本ガイドで keleusma run が使われるたびに、それは静かに四つのステップを順に行ってきました。すなわちソースを読み、それをバイトコードにコンパイルし、バイトコードを検証し、実行するというものです。
これらのステップは分離できます。コンパイルは事前に一度だけ行い、その結果を保存できます。
事前コンパイル
小さなプログラムを書き、tune.kel として保存します。
fn main() -> Word { 60 + 7 }
それをコンパイルします。
keleusma compile tune.kel -o tune.kel.bin
ツールは次を出力します。
wrote tune.kel.bin (2400 bytes)
tune.kel.bin はコンパイルされたバイトコードです。これを直接実行します。
keleusma run tune.kel.bin
出力は 67 です。ツールはこのファイルをバイトコードとして認識し、コンパイルはすでに行われていたため、コンパイルせずに実行しました。
バイトコードファイルの中身
バイトコードファイルは自己記述的なパッケージです。それは、ランタイムがそれを Keleusma のバイトコードとして認識できるように短いマーカーで始まります。マーカーの後にはプログラムの事実を保持するヘッダが続き、次いでプログラム本体、そして末尾にチェックサムが来ます。ランタイムは何よりも先にヘッダを読み、本物の Keleusma バイトコードでないファイル、あるいは非互換なマシン向けにビルドされたファイルを拒否します。チェックサムにより、保管中または転送中に破損したファイルを検出できます。
初学者はバイト単位のレイアウトを知る必要はありません。要点は、バイトコードファイルはその命令が一つでも実行される前に、ランタイムによって検査されるということです。
コンパイル済みファイルはシバンを持てる
第2章では、macOS と Linux においてソースファイルにシバン行を追加しました。コンパイル済みバイトコードファイルもシバンを持てるため、完成しコンパイルされたプログラムを、同じ方法で直接実行可能にできます。
なぜ事前にコンパイルするのか
一度コンパイルしてバイトコードを配布することには二つの利点があります。プログラムを実行するマシンはコンパイラを必要とせず、ランタイムのみを必要とします。そしてプログラムは、先立つコンパイルステップなしに即座に開始します。バイトコードは、演奏者に手渡す準備が整った、完成し刻み込まれた楽譜であり、ソースである作業中の手稿とは区別されます。
ターゲットの選択
既定のコンパイルは、コンパイラを実行しているのと同じマシンをターゲットとします。異なるマシン向けのバイトコード成果物をビルドするには --target を渡します。
keleusma compile tune.kel --target embedded_16 -o tune.kel.bin
認識されるターゲット名は host(既定)、wasm32、embedded_32、embedded_16、embedded_8 です。選択されたターゲットはワード、アドレス、浮動小数点の幅を制御し、その構成に対してプログラムを検証します。ターゲットの表現可能な範囲外のリテラルまたは定数を使用するプログラムは、コンパイル時に拒否されます。
この章のまとめ
- ソースはあなたが書くテキストであり、バイトコードはランタイムが実行するコンパクトな形式です。
keleusma runは一つのステップでコンパイルと実行を行い、keleusma compileは後で実行できるバイトコードファイルを生成します。- バイトコードファイルは自己記述的でバージョン検査され、チェックサムによって保護されています。
- 事前コンパイルは、実行するマシンがランタイムのみを必要とし、プログラムが即座に開始することを意味します。
ランタイムが実行する個々の命令をご覧になりたい場合、Instruction Set リファレンスがすべてのオペコードを、そのオペランド、コスト、および時間予算とメモリ予算への影響とともに一覧します。プレイグラウンドは、あなたが書いた任意のプログラムについて同じ逆アセンブルを表示します。
次章では、完成したプログラムに起こり得るさらに二つのこと、すなわち署名できることと差し替えできることを取り扱います。
第26章 署名付きモジュールとホットコードスワップ
この章の目的
本章の終わりまでに、あなたは完成したプログラムに起こり得る二つのこと、すなわちその出所が証明できるように署名できることと、実行中に新しいコードへと差し替えられることを理解します。
署名、プログラムの出所を証明する
バイトコードファイルは移動します。それは一つのマシンでコンパイルされ、遠く離れた別のマシンで実行され得ます。それを実行するマシンには一つの問いがあります。これは期待される作者による、改変されていない本物のプログラムか、というものです。署名がその問いに答えます。
意図された用途は多者間配信です。スタジオがプログラムをコンパイルし、スタジオのみが保持する秘密鍵でそれに署名します。現場のデバイスは対応する公開鍵を保持し、その鍵に対して署名が検証されないプログラムの実行を拒否します。
プログラムを署名付きとして印付けする
プログラムはそのエントリ関数に signed 修飾子を付けてオプトインします。
signed fn main() -> Word {
21 + 21
}
signed は、そのプログラムがロードされる前に有効な署名を持たなければならないものとして印付けします。これはエントリ関数においてのみ許され、三種類の関数のいずれにおいても機能します。すなわち signed fn main、signed yield main、signed loop main です。
署名のフロー
まず、鍵ペアを作成します。
keleusma keygen --seed studio.seed --public studio.pub
これは二つのファイルを書き出します。studio.seed は秘密鍵であり、スタジオが守る秘密です。studio.pub は公開鍵であり、検証を必要とする誰にでも渡されます。鍵は長命の秘密であるため、ツールは既存の鍵ファイルの上書きを拒否します。
上記のプログラムを app.kel として保存し、それをコンパイルして署名します。
keleusma compile app.kel --signing-key studio.seed -o app.kel.bin
署名付きバイトコードを、検証に用いる公開鍵を供給して実行します。
keleusma run app.kel.bin --verifying-key studio.pub
出力は 42 です。ランタイムは studio.pub に対して署名を検査し、有効であることを確認して、プログラムを実行しました。
鍵なしで実行すると、プログラムはロードされません。
error: verify_module_signature: InvalidSignature
signed プログラムは、その署名が検証されない限り実行されません。それは封をされ署名された楽譜であり、その封を信頼する演奏者です。署名方式は標準的で十分に信頼された Ed25519 です。
暗号化、転送中にプログラムを機密に保つ
署名付きバイトコード成果物は完全性の保証を持ちますが、その内容はそれを傍受する誰にとっても依然として読み取り可能です。機密性も必要とする配備のために、バイトコードはコンパイル時に特定の受信者宛てに暗号化できます。
受信者は暗号化鍵ペア(X25519)を生成し、その公開側を成果物を生成する者に渡します。
keleusma keygen --kind encryption --seed device.seed --public device.pub
コンパイルステップは署名鍵と受信者の公開暗号化鍵の両方を取ります。
keleusma compile app.kel \
--signing-key studio.seed \
--encryption-key device.pub \
-o app.kel.bin
受信者は検証鍵と自身の復号鍵の両方を用いて成果物を実行します。
keleusma run app.kel.bin \
--verifying-key studio.pub \
--decryption-key device.seed
暗号化は署名の上に層として重ねられます。署名は暗号化された本体を覆うため、敵対者は署名を無効にすることなく暗号化を剥がして平文に置き換えることはできません。暗号化は X25519 鍵交換と AES-256-GCM 認証付き暗号化を組み合わせて用います。
SECURITY_POLICY.md のリファレンスは、両方のゲートを配備ポリシーとして記述します。二つのポリシーは独立しており、いずれもプラットフォーム慣習的なディレクトリ内の登録された鍵ストアを通じて有効化できます。
ホットコードスワップ、実行中にプログラムを変更する
完成したプログラムに起こり得る二つ目のことは、実行中に新しいプログラムへと置き換えられることです。
第17章の RESET を思い出してください。すなわち loop 関数の各サイクルの先頭にある境界です。RESET はまた、プログラムを差し替えられる瞬間でもあります。実行中のプログラムがサイクルを終えます。RESET 境界において、ホストは古いコードの代わりに新しいコードをインストールします。次のサイクルは新しいプログラムを実行します。
対話は中断されません。スワップをまたいで同一のままでなければならない唯一のものは、第16章の対話、すなわち各 yield で交換される合意された型の対です。新しいプログラムが同じ対話を話す限り、ホストは途切れることなくそれと話し続けます。これは、バンドを止めることなく、次の拍頭で新しい編曲へと差し替えることです。
プログラムは自身を差し替えません。ホストが RESET 境界でスワップを実行します。第VIII部はそれを直接示します。ピアノロールにおいて、キーを押すと実行中の曲が別の曲へと差し替えられ、それがホットコードスワップです。
この章のまとめ
- バイトコードファイルは署名できるため、実行するマシンはその出所を証明できます。
- エントリ関数の
signed修飾子は、プログラムを有効な署名を必要とするものとして印付けします。 - フローは
keleusma keygen、次いでkeleusma compile --signing-key、次いでkeleusma run --verifying-keyです。 - 署名付きバイトコード成果物は、さらに
--encryption-keyで特定の受信者宛てに暗号化できます。受信者は--decryption-keyでそれを実行します。 - 厳格モードの配備ポリシーは
SECURITY_POLICY.mdにあります。 - ホットコードスワップは RESET 境界で実行中のプログラムを新しいコードへと置き換え、対話はスワップをまたいで同一のままでなければなりません。
これで第VII部が完了します。プログラムは書かれ、コンパイルされ、配布可能になりました。第VIII部は実際のプログラムを稼働させ、それを聞こえるようにします。
第27章. ピアノロールの仕組み
この章の目的
この章を読み終える頃には、ピアノロール例の仕組み — Keleusmaのloopプログラムがどのようにして音楽になるのか — が分かるようになります。この章を読み終える頃には、ピアノロール例の仕組み — Keleusmaのloopプログラムがどのようにして音楽になるのか — が分かるようになります。
本物の loop プログラム
第4部でloop関数を紹介し、決して終わらずにサイクルごとにホストに値を渡すプログラムだと述べて、本物のものは第8部で動かすと言いました。ピアノロールがその本物のプログラムです。1曲の楽曲がloop mainで、ピアノロールがそのホストです。第4部でloop関数を紹介し、決して終わらずにサイクルごとにホストに値を渡すプログラムだと述べて、本物のものは第8部で動かすと言いました。ピアノロールがその本物のプログラムです。1曲の楽曲がloop mainで、ピアノロールがそのホストです。
3つの部分
ピアノロールは3つの部分で構成されていて、3つの違う速度で動きます。
- 楽曲はKeleusmaの
loop main(input: Word) -> Wordです。ループの1サイクルが1つの16分音符ティックです。サイクルごとに楽曲はどの音を出すか、止めるかを決めます。楽曲はKeleusmaのloop main(input: Word) -> Wordです。ループの1サイクルが1つの16分音符ティックです。サイクルごとに楽曲はどの音を出すか、止めるかを決めます。 - ホストのメインスレッドが楽曲を動かします。ティックごとに楽曲を再開させます。120 BPMでは1ティックは125ミリ秒です。ホストのメインスレッドが楽曲を動かします。ティックごとに楽曲を再開させます。120 BPMでは1ティックは125ミリ秒です。
- ホストのオーディオスレッドが音を作ります。これははるかに速く、1秒あたり48000サンプルで走り、決してKeleusma仮想マシンに入りません。ホストのオーディオスレッドが音を作ります。これははるかに速く、1秒あたり48000サンプルで走り、決してKeleusma仮想マシンに入りません。
楽曲は段取り、ホストは音を作る
これが要点です。楽曲は音を出しません。楽曲はイベントを「段取り(スケジュール)」します。楽曲がチャンネル0にMIDIナンバー60を鳴らさせると決めたとき、ホストのネイティブを呼びます。これが要点です。楽曲は音を出しません。楽曲はイベントを「段取り(スケジュール)」します。楽曲がチャンネル0にMIDIナンバー60を鳴らさせると決めたとき、ホストのネイティブを呼びます。
host::play(0, 60)
その呼び出しがホストの音声状態に書き込みます。オーディオスレッドがそれを読んで音にします。楽曲の仕事はタイミングと音の選び方で、ホストの仕事は音の合成です。第16章で紹介した「ホストと話す」というアイデアの、ネイティブ関数呼び出しがその架け橋です。その呼び出しがホストの音声状態に書き込みます。オーディオスレッドがそれを読んで音にします。楽曲の仕事はタイミングと音の選び方で、ホストの仕事は音の合成です。第16章で紹介した「ホストと話す」というアイデアの、ネイティブ関数呼び出しがその架け橋です。
楽曲の記憶
楽曲は自分がどこにいるかを、第18章のデータセグメントを使って覚えています。バンドルされているすべての楽曲は同じ形のdata stateブロックを宣言しています — 1回切りの初期化フラグ、ループカウンター、セクションポインター、いくつかの汎用スロット、そして8つのカウンターの配列が2つ(各チャンネルがパターンのどこにいるかを記録するものと、各チャンネルの次の音まで残っているティック数を記録するもの)です。楽曲は自分がどこにいるかを、第18章のデータセグメントを使って覚えています。バンドルされているすべての楽曲は同じ形のdata stateブロックを宣言しています — 1回切りの初期化フラグ、ループカウンター、セクションポインター、いくつかの汎用スロット、そして8つのカウンターの配列が2つ(各チャンネルがパターンのどこにいるかを記録するものと、各チャンネルの次の音まで残っているティック数を記録するもの)です。
init ブロック
楽曲のloop main本体が最初のサイクルでまず行うのは、state.initフラグで守られた1回切りの初期化ブロックです。楽曲のloop main本体が最初のサイクルでまず行うのは、state.initフラグで守られた1回切りの初期化ブロックです。
if state.init == 0 {
host::song_name("C major progression, four-bar loop");
host::set_waveform(0, 0);
host::set_adsr(0, 5, 80, 700, 150);
host::set_enable(0, 1);
// ... 他のチャンネルも構成 ...
state.init = 1;
};
``````if state.init == 0 {
host::song_name("C major progression, four-bar loop");
host::set_waveform(0, 0);
host::set_adsr(0, 5, 80, 700, 150);
host::set_enable(0, 1);
// ... 他のチャンネルも構成 ...
state.init = 1;
};
データセグメントはゼロで始まるので、最初のサイクルではstate.initは0、ブロックが走り、最後にstate.initを1にします。それ以降のサイクルではブロックは飛ばされます。ここが各チャンネルの楽器を選ぶ場所です。データセグメントはゼロで始まるので、最初のサイクルではstate.initは0、ブロックが走り、最後にstate.initを1にします。それ以降のサイクルではブロックは飛ばされます。ここが各チャンネルの楽器を選ぶ場所です。
ティックごとの本体
initブロックのあと、楽曲は各チャンネルを同じやり方で処理します。あるチャンネルについて、残りティック数がゼロになったら、楽曲はそのチャンネルの次の音を調べ、host::playかhost::silenceを呼び、カウンターをその新しい音の長さに設定し、チャンネルの位置を進めます。そうでなければ、ただカウンターを1だけ減らします。それから楽曲はyieldして、サイクルが終わります。initブロックのあと、楽曲は各チャンネルを同じやり方で処理します。あるチャンネルについて、残りティック数がゼロになったら、楽曲はそのチャンネルの次の音を調べ、host::playかhost::silenceを呼び、カウンターをその新しい音の長さに設定し、チャンネルの位置を進めます。そうでなければ、ただカウンターを1だけ減らします。それから楽曲はyieldして、サイクルが終わります。
各ティックは決まった、小さな仕事量を行います。第20章の「有界ステップ」保証がここでも成り立っています — 楽曲はティックをはみ出すことができません。各ティックは決まった、小さな仕事量を行います。第20章の「有界ステップ」保証がここでも成り立っています — 楽曲はティックをはみ出すことができません。
楽曲の差し替え
ピアノロールは10曲の楽曲を持っています。キーを押すと、走っている楽曲が次の楽曲に差し替わります。その差し替えが、第26章のホットコードスワップ、RESETの境界で起こるもの、を耳で聴ける形にしたものです。ピアノロールは10曲の楽曲を持っています。キーを押すと、走っている楽曲が次の楽曲に差し替わります。その差し替えが、第26章のホットコードスワップ、RESETの境界で起こるもの、を耳で聴ける形にしたものです。
この章のまとめ
- 楽曲は
loop mainで、ピアノロールがそのホストです。 - 楽曲はネイティブ呼び出しで音のイベントを段取りし、ホストのオーディオスレッドが音を合成します。楽曲はネイティブ呼び出しで音のイベントを段取りし、ホストのオーディオスレッドが音を合成します。
- 楽曲は自分の位置をデータセグメントに保持します。
- 1回切りのinitブロックが最初のサイクルで楽器を構成します。
- 各ティックは有界な仕事量を行います。
次の章では、ピアノロールを自分の機械で動かします。
第28章. 自分の楽曲プレイグラウンドを用意する
この章の目的
この章を読み終える頃には、ピアノロールが自分の機械でビルドされて、走っている状態になります。この章を読み終える頃には、ピアノロールが自分の機械でビルドされて、走っている状態になります。
コードはすでに手元にある
第2章で、KeleusmaのソースリポジトリのクローンからKeleusmaのコマンドラインツールをインストールしました。その同じクローンの中にピアノロール例と10曲すべてが入っています。新しくダウンロードするものはありません。この章の作業はそのリポジトリのフォルダの中で行います。第2章で、KeleusmaのソースリポジトリのクローンからKeleusmaのコマンドラインツールをインストールしました。その同じクローンの中にピアノロール例と10曲すべてが入っています。新しくダウンロードするものはありません。この章の作業はそのリポジトリのフォルダの中で行います。
もう一つ必要なもの: CMake
ピアノロールは音を出します。そのためにSDL3 (Simple DirectMedia Layer 3)というオーディオライブラリを使います。最初のビルドのときに、SDL3はソースからコンパイルされます。それをコンパイルするにはCMakeというツールが必要です。ピアノロールは音を出します。そのためにSDL3 (Simple DirectMedia Layer 3)というオーディオライブラリを使います。最初のビルドのときに、SDL3はソースからコンパイルされます。それをコンパイルするにはCMakeというツールが必要です。
続ける前に、お使いのOS用のCMakeをインストールしてください。これは本ガイド全体で唯一の本物の準備コストです。Windowsで一番重く、そこではCMakeもCのビルドツールチェーンもデフォルトで入っていません。macOSとLinuxではCのツールチェーンは普段すでに入っていて、CMakeだけ追加すれば済みます。続ける前に、お使いのOS用のCMakeをインストールしてください。これは本ガイド全体で唯一の本物の準備コストです。Windowsで一番重く、そこではCMakeもCのビルドツールチェーンもデフォルトで入っていません。macOSとLinuxではCのツールチェーンは普段すでに入っていて、CMakeだけ追加すれば済みます。
ビルドと実行
Keleusmaリポジトリのフォルダの中から実行します。
cargo run --release --example piano_roll --features sdl3-example
コマンドを部分ごとに読みます。cargo runはビルドして走らせます。--releaseは速い版をビルドします。オーディオに必要です。--example piano_rollはピアノロールを選びます。--features sdl3-exampleはSDL3オーディオサポートを有効にします。コマンドを部分ごとに読みます。cargo runはビルドして走らせます。--releaseは速い版をビルドします。オーディオに必要です。--example piano_rollはピアノロールを選びます。--features sdl3-exampleはSDL3オーディオサポートを有効にします。
最初の1回は数分かかります。SDL3がソースからビルドされているからです。これは1回きりです。それ以降の実行は、ビルド済みのSDL3を再利用してすぐに始まります。最初の1回は数分かかります。SDL3がソースからビルドされているからです。これは1回きりです。それ以降の実行は、ビルド済みのSDL3を再利用してすぐに始まります。
何が見えて聴こえるか
ピアノロールが起動するとコマンドを表示して、最初の楽曲を再生し始め、ターミナルに入力された1文字のコマンドを聞きます。ピアノロールが起動するとコマンドを表示して、最初の楽曲を再生し始め、ターミナルに入力された1文字のコマンドを聞きます。
sで次の楽曲に切り替えます。rで現在の楽曲を最初からやり直します。pで一時停止、もう一度pで再開します。- 数字キーでその番号の楽曲に直接ジャンプします。
- Enterだけ押すと終了します。
スピーカーから音が出ているはずです。ビルドが成功したのに何も聴こえない場合は、ターミナルプログラムがオーディオデバイスを使う許可を持っているか、システムの音量が上がっているか確認してください。スピーカーから音が出ているはずです。ビルドが成功したのに何も聴こえない場合は、ターミナルプログラムがオーディオデバイスを使う許可を持っているか、システムの音量が上がっているか確認してください。
楽曲はすぐそこにある
10曲の楽曲はリポジトリのexamples/scripts/piano_roll/にあり、piano_roll_0.kelからpiano_roll_9.kelの名前です。普通のKeleusmaのソースファイルです。次の章で1曲開いて書き換えます。10曲の楽曲はリポジトリのexamples/scripts/piano_roll/にあり、piano_roll_0.kelからpiano_roll_9.kelの名前です。普通のKeleusmaのソースファイルです。次の章で1曲開いて書き換えます。
この章のまとめ
- ピアノロール例は第2章のリポジトリクローンの一部です。
- ビルドにはCMakeが必要です。最初のビルドでSDL3がソースからコンパイルされるからです。ビルドにはCMakeが必要です。最初のビルドでSDL3がソースからコンパイルされるからです。
cargo run --release --example piano_roll --features sdl3-exampleでビルドして走らせます。cargo run --release --example piano_roll --features sdl3-exampleでビルドして走らせます。- 楽曲は
examples/scripts/piano_roll/の中の.kelファイルです。
次の章では楽曲を書き換えて、その変化を耳で聴きます。
第29章. 楽曲を書いて書き換える
この章の目的
この章を読み終える頃には、楽曲を書き換えて、その変化を耳で聴いた状態になります。この章を読み終える頃には、楽曲を書き換えて、その変化を耳で聴いた状態になります。
楽曲はあなたが学んだもの全て
examples/scripts/piano_roll/piano_roll_0.kelをテキストエディタで開いてください。一覧の中で最も単純な楽曲で、その中のすべての部分はガイドですでに扱ってきたものです。examples/scripts/piano_roll/piano_roll_0.kelをテキストエディタで開いてください。一覧の中で最も単純な楽曲で、その中のすべての部分はガイドですでに扱ってきたものです。
- 先頭の
useの行は、第16章で導入したホストのネイティブをインポートしています。先頭のuseの行は、第16章で導入したホストのネイティブをインポートしています。 enum Pitchを宣言しています — これは第11章の列挙型です。12の音名とRest(休符)が並んでいます。enum Pitchを宣言しています — これは第11章の列挙型です。12の音名とRest(休符)が並んでいます。- 普通の
fnヘルパー — 第6章の関数 — が音を調べる役を果たします。それぞれが第13章のmatchを使っています。普通のfnヘルパー — 第6章の関数 — が音を調べる役を果たします。それぞれが第13章のmatchを使っています。 data stateブロック — 第18章のものです。- エントリーポイントは
loop main— 第17章のもので、initブロックと第27章で説明したティックごとの本体を持っています。エントリーポイントはloop main— 第17章のもので、initブロックと第27章で説明したティックごとの本体を持っています。
楽曲は特別な種類のファイルではありません。第1部から第7部までのすべての部品で組み立てられたKeleusmaのプログラムです。楽曲は特別な種類のファイルではありません。第1部から第7部までのすべての部品で組み立てられたKeleusmaのプログラムです。
音符の表
あるチャンネルが演奏する音の並びは、ヘルパー関数channel_noteの中に書かれています。チャンネル0(メロディ)については、音の位置に対するmatchを持っています。その最初のアームがメロディの第1音です。あるチャンネルが演奏する音の並びは、ヘルパー関数channel_noteの中に書かれています。チャンネル0(メロディ)については、音の位置に対するmatchを持っています。その最初のアームがメロディの第1音です。
0 => (Pitch::C, 5, 4), // C major: C E G E
このタプルは、ピッチがC、オクターブが5、長さは16分音符4つぶん(つまり4分音符)、という意味です。これがメロディが始める音です。このタプルは、ピッチがC、オクターブが5、長さは16分音符4つぶん(つまり4分音符)、という意味です。これがメロディが始める音です。
書き換えてみる
その第1音を変えてみます。アームを書き換えて、メロディをCの代わりにEで始まるようにします。その第1音を変えてみます。アームを書き換えて、メロディをCの代わりにEで始まるようにします。
0 => (Pitch::E, 5, 4), // C major: C E G E
実行する前に、楽曲がまだコンパイルできるかを確認します。
keleusma compile examples/scripts/piano_roll/piano_roll_0.kel -o /tmp/song.bin
ツールはwrote ... bytesの行を表示します。Pitch::EはPitch列挙型の本物のヴァリアントで、タプルの形も変わらないので、変更は妥当なKeleusmaです。もし書き換えがプログラムを壊していたら、このステップが音を試す前にエラーを報告してくれていたはずです。ツールはwrote ... bytesの行を表示します。Pitch::EはPitch列挙型の本物のヴァリアントで、タプルの形も変わらないので、変更は妥当なKeleusmaです。もし書き換えがプログラムを壊していたら、このステップが音を試す前にエラーを報告してくれていたはずです。
聴いてみる
ピアノロールをもう一度走らせます。
cargo run --release --example piano_roll --features sdl3-example
楽曲0のメロディがEで始まるようになりました。ホストはビルドのときに楽曲ファイルを新しく読み直すので、ファイルを編集して走らせ直すこと自体が、繰り返しのループになっています。これが作曲のループです — 楽譜を変えて、結果を聴いて、また変えて。楽曲0のメロディがEで始まるようになりました。ホストはビルドのときに楽曲ファイルを新しく読み直すので、ファイルを編集して走らせ直すこと自体が、繰り返しのループになっています。これが作曲のループです — 楽譜を変えて、結果を聴いて、また変えて。
さらに先へ
同じchannel_note関数の中に、チャンネル1のベースラインと、チャンネル2のハーモニーがあります。楽曲0のすべての音は、これらのmatchブロックのどれかのアームです。ピッチを変えて、オクターブを変えて、長さを変えてみてください。initブロックのhost::set_waveformの呼び出しを変えれば、チャンネルに別の楽器を持たせられます。各変更はkeleusma compileが確認して、ピアノロールを走らせれば聴けます。同じchannel_note関数の中に、チャンネル1のベースラインと、チャンネル2のハーモニーがあります。楽曲0のすべての音は、これらのmatchブロックのどれかのアームです。ピッチを変えて、オクターブを変えて、長さを変えてみてください。initブロックのhost::set_waveformの呼び出しを変えれば、チャンネルに別の楽器を持たせられます。各変更はkeleusma compileが確認して、ピアノロールを走らせれば聴けます。
この章のまとめ
- 楽曲は普通のKeleusmaプログラムで、ガイド全体の機能で組み立てられています。楽曲は普通のKeleusmaプログラムで、ガイド全体の機能で組み立てられています。
- 楽曲の音符は、音符表関数の
matchアームの中のタプルです。 - 編集ループは、
.kelファイルを変えて、keleusma compileで確認して、ピアノロールを走らせて、聴く、です。編集ループは、.kelファイルを変えて、keleusma compileで確認して、ピアノロールを走らせて、聴く、です。
次の章では10曲すべての一覧を見学します。
第30章. 楽曲一覧の見学
この章の目的
この章を読み終える頃には、Keleusmaの楽曲ができることの全体的な幅を聴いて、その幅がなぜそれほど広いのかが分かるようになります。この章を読み終える頃には、Keleusmaの楽曲ができることの全体的な幅を聴いて、その幅がなぜそれほど広いのかが分かるようになります。
楽曲はプログラムである
一覧が見学する価値があるのは、本ガイド全体で確認してきた一つの事実があるからです。楽曲は音符のリストではありません。楽曲はプログラムです。計算し、分岐し、数え、時間とともに自分自身の振る舞いを変えられます。関数、match、データセグメント、そしてloopで組み立てられているからです。有界なプログラムができることは何でも、楽曲もできます。バンドルされている10曲は、その幅を見せるために選ばれています。一覧が見学する価値があるのは、本ガイド全体で確認してきた一つの事実があるからです。楽曲は音符のリストではありません。楽曲はプログラムです。計算し、分岐し、数え、時間とともに自分自身の振る舞いを変えられます。関数、match、データセグメント、そしてloopで組み立てられているからです。有界なプログラムができることは何でも、楽曲もできます。バンドルされている10曲は、その幅を見せるために選ばれています。
第28章のピアノロールを走らせて、sか数字キーで切り替えて聴いてみてください。第28章のピアノロールを走らせて、sか数字キーで切り替えて聴いてみてください。
一覧
- 楽曲0と1は導入用の曲です。3チャンネル、4小節のコード進行。最初に読むべき曲で、第29章で書き換えたのもこれらです。楽曲0と1は導入用の曲です。3チャンネル、4小節のコード進行。最初に読むべき曲で、第29章で書き換えたのもこれらです。
- 楽曲2はバッハの前奏曲の5チャンネル編曲です。本物のレパートリーに対する、ホストのエンベロープとリトリガー処理を見せます。楽曲2はバッハの前奏曲の5チャンネル編曲です。本物のレパートリーに対する、ホストのエンベロープとリトリガー処理を見せます。
- 楽曲3はDマイナーの8チャンネル楽曲で、すべてのホストネイティブを使い切り、途中で7拍子に切り替わり、テンポを連続的にランプさせます。楽曲3はDマイナーの8チャンネル楽曲で、すべてのホストネイティブを使い切り、途中で7拍子に切り替わり、テンポを連続的にランプさせます。
- 楽曲4は60〜300 BPMの間でゆっくり正弦波の形にテンポが揺れます。曲はループカウンターが進むたびに素材が変わり、アルゴリズミックなヴァリエーション形式のように振る舞います。楽曲4は60〜300 BPMの間でゆっくり正弦波の形にテンポが揺れます。曲はループカウンターが進むたびに素材が変わり、アルゴリズミックなヴァリエーション形式のように振る舞います。
- 楽曲5はプロセス音楽です。8つのヴォイスが同じ12音のパターンを弾きますが、それぞれ違う速度で進むので、ヴォイスは数分から数時間のスケールで離合集散します。楽曲5はプロセス音楽です。8つのヴォイスが同じ12音のパターンを弾きますが、それぞれ違う速度で進むので、ヴォイスは数分から数時間のスケールで離合集散します。
- 楽曲6はカノンで、4つのヴォイスが1つの旋律を共有しながら4つの違う速度で同時に進み、本物の4声対位法を生みます。楽曲6はカノンで、4つのヴォイスが1つの旋律を共有しながら4つの違う速度で同時に進み、本物の4声対位法を生みます。
- 楽曲7はマイクロトナルなドローン作品です。8つのヴォイスが自然倍音列に合わせて調律されています。ホストが直接サポートしている細かなピッチ調整を使っています。楽曲7はマイクロトナルなドローン作品です。8つのヴォイスが自然倍音列に合わせて調律されています。ホストが直接サポートしている細かなピッチ調整を使っています。
- 楽曲8は標準的なポップ曲で、ブリッジと転調があります。実験的な曲を走らせる同じエンジンが、普通の作曲も扱えることを見せるために入っています。楽曲8は標準的なポップ曲で、ブリッジと転調があります。実験的な曲を走らせる同じエンジンが、普通の作曲も扱えることを見せるために入っています。
- 楽曲9は長い実験的なループで、スケールと楽器の16通りの組み合わせを巡って、繰り返すまでに約50分かかります。楽曲9は長い実験的なループで、スケールと楽器の16通りの組み合わせを巡って、繰り返すまでに約50分かかります。
なぜこれが大切か
これらの楽曲のうちいくつかは、従来の音楽ソフトウェア — DAW (Digital Audio Workstation) — では難しいことをします。連続的な正弦波で形作られたテンポ、同時に走る4つの拍子、自然倍音列から取った調律、カウンター値で自分を作り直す曲、などです。これらはメニューの選択肢ではありません。楽曲がプログラムであることから来る結果です。これらの楽曲のうちいくつかは、従来の音楽ソフトウェア — DAW (Digital Audio Workstation) — では難しいことをします。連続的な正弦波で形作られたテンポ、同時に走る4つの拍子、自然倍音列から取った調律、カウンター値で自分を作り直す曲、などです。これらはメニューの選択肢ではありません。楽曲がプログラムであることから来る結果です。
それが見せている事です。実験的な楽曲が並んでいるのは、それが心地よいからではありません。これらは他のやり方ではほとんど成り立たないことで、それが楽曲0と同じ「証明された時間とメモリの上限」の中で走るという事実は、言語が何のためにあるのかを示しています。それが見せている事です。実験的な楽曲が並んでいるのは、それが心地よいからではありません。これらは他のやり方ではほとんど成り立たないことで、それが楽曲0と同じ「証明された時間とメモリの上限」の中で走るという事実は、言語が何のためにあるのかを示しています。
この章のまとめ
- 一覧の各楽曲がプログラムだから、一覧はこれほど幅広く広がっています。
- バンドルされている楽曲は、導入用の進行、バッハの編曲、プロセス・調律系の実験曲、そして1曲の標準的なポップ曲を含みます。バンドルされている楽曲は、導入用の進行、バッハの編曲、プロセス・調律系の実験曲、そして1曲の標準的なポップ曲を含みます。
- 普通の音楽ソフトウェアでは難しい、あるいは不可能なテクニックが、楽曲がプログラムだという事実から自然に出てきます。普通の音楽ソフトウェアでは難しい、あるいは不可能なテクニックが、楽曲がプログラムだという事実から自然に出てきます。
これで第8部、そして言語を学ぶ人に向けたガイドの部分が完了です。第9部は別のトラック — Keleusmaを自分のRustプログラムの中に組み込みたい開発者向け、です。これで第8部、そして言語を学ぶ人に向けたガイドの部分が完了です。第9部は別のトラック — Keleusmaを自分のRustプログラムの中に組み込みたい開発者向け、です。
第31章. 組み込みオリエンテーション
この章の目的
この章を読み終える頃には、この第9部が何を扱うのか、誰のためのものなのかが分かり、最小限のKeleusmaホストをコンパイルして走らせた状態になります。この章を読み終える頃には、この第9部が何を扱うのか、誰のためのものなのかが分かり、最小限のKeleusmaホストをコンパイルして走らせた状態になります。
誰のためのものか
第1部から第8部はKeleusmaの言語を学ぶ人のために書かれました。この部はそれとは別です。これは、Rustプログラムの中にKeleusmaを組み込みたいRust開発者のためのものです。Rustの実用的な知識を前提にしています — cargo、依存関係、トレイト、クロージャ、Result、です。前の部までの音楽というフレームはここでは脇に置きます。ここの文章は淡々とした技術的なものです。第1部から第8部はKeleusmaの言語を学ぶ人のために書かれました。この部はそれとは別です。これは、Rustプログラムの中にKeleusmaを組み込みたいRust開発者のためのものです。Rustの実用的な知識を前提にしています — cargo、依存関係、トレイト、クロージャ、Result、です。前の部までの音楽というフレームはここでは脇に置きます。ここの文章は淡々とした技術的なものです。
この部の作業例はピアノロールです。第8部ではピアノロールを楽曲の側から見ました。この部はその裏のホストを組み立てます。この部の作業例はピアノロールです。第8部ではピアノロールを楽曲の側から見ました。この部はその裏のホストを組み立てます。
ホストとスクリプト
Keleusmaのデプロイメントは2つの半分でできています。
- スクリプトは
.kelプログラムです。有界で、検証済みのロジックを担当します。スクリプトは.kelプログラムです。有界で、検証済みのロジックを担当します。 - ホストはRustプログラムです。スクリプトをコンパイルし、検証し、仮想マシンを構築し、その仮想マシンを動かし、スクリプトが呼ぶネイティブ関数を提供します。ホストはRustプログラムです。スクリプトをコンパイルし、検証し、仮想マシンを構築し、その仮想マシンを動かし、スクリプトが呼ぶネイティブ関数を提供します。
ホストがKeleusmaを組み込んでいる場所です。この部のすべてはホスト側のコードです。ホストがKeleusmaを組み込んでいる場所です。この部のすべてはホスト側のコードです。
最小限のホスト
ホストはkeleusmaを依存に持つ普通のRustバイナリプロジェクトです。Cargo.tomlには1行必要です。ホストはkeleusmaを依存に持つ普通のRustバイナリプロジェクトです。Cargo.tomlには1行必要です。
[dependencies]
keleusma = "0.2"
下のsrc/main.rsが完全なホストです。1行のスクリプトをコンパイルし、VMを構築し、走らせて、結果を表示します。下のsrc/main.rsが完全なホストです。1行のスクリプトをコンパイルし、VMを構築し、走らせて、結果を表示します。
use keleusma::compiler::compile;
use keleusma::lexer::tokenize;
use keleusma::parser::parse;
use keleusma::vm::{DEFAULT_ARENA_CAPACITY, Vm, VmState};
use keleusma::{Arena, Value};
const SCRIPT: &str = "fn main() -> Word { 60 + 7 }";
fn main() {
let tokens = tokenize(SCRIPT).expect("lex");
let program = parse(&tokens).expect("parse");
let module = compile(&program).expect("compile");
let arena = Arena::with_capacity(DEFAULT_ARENA_CAPACITY);
let mut vm = Vm::new(module, &arena).expect("verify");
match vm.call(&[]).expect("call") {
VmState::Finished(Value::Int(n)) => println!("result: {}", n),
other => panic!("unexpected: {:?}", other),
}
}
cargo runで実行します。出力は次のとおりです。
result: 67
この部のあとの各章はこの骨組みの上に積み上がります。次の章で構築の段階を詳しく見ます。残りの章ではネイティブ関数、再開プロトコル、アリーナのサイズ、バイトコードの読み込み、ホットスワップ、コストモデルを足していき、最後にピアノロールホストの全体を見て終わります。この部のあとの各章はこの骨組みの上に積み上がります。次の章で構築の段階を詳しく見ます。残りの章ではネイティブ関数、再開プロトコル、アリーナのサイズ、バイトコードの読み込み、ホットスワップ、コストモデルを足していき、最後にピアノロールホストの全体を見て終わります。
この章のまとめ
- この部は、ホストプログラムにKeleusmaを組み込むRust開発者向けです。
- Keleusmaのデプロイメントはホストとスクリプトです。ホストがRustの側です。Keleusmaのデプロイメントはホストとスクリプトです。ホストがRustの側です。
- 最小限のホストは
keleusmaを依存として加え、字句解析、構文解析、コンパイル、構築、呼び出しの順を実行します。最小限のホストはkeleusmaを依存として加え、字句解析、構文解析、コンパイル、構築、呼び出しの順を実行します。
次の章ではその順を分解します。
第32章. VMの構築とモジュールの実行
この章の目的
この章を読み終える頃には、VM構築の各段階と、VMとアリーナのライフタイムの関係を理解しているはずです。この章を読み終える頃には、VM構築の各段階と、VMとアリーナのライフタイムの関係を理解しているはずです。
4つの段階
ソースからVMを構築するのは4段階で、各段階で別の型ができます。
#![allow(unused)]
fn main() {
let tokens = tokenize(SOURCE)?; // Vec<Token>
let program = parse(&tokens)?; // Program
let module = compile(&program)?; // Module
let arena = Arena::with_capacity(DEFAULT_ARENA_CAPACITY);
let mut vm = Vm::new(module, &arena)?;
}
tokenizeがソーステキストをVec<Token>に変えます。parseがそれをProgram、つまり構文木に変えます。compileがそれをModule、つまりバイトコードオブジェクトに変えます。Vm::newがモジュールを消費し、アリーナを借りて、構造検証と資源境界検証を行い、呼び出せる状態のVMを返します。Vm::newがモジュールを消費し、アリーナを借りて、構造検証と資源境界検証を行い、呼び出せる状態のVMを返します。
各段階はResultを返します。どこかの段階で失敗すると、型付きのエラーになります — LexError、ParseError、CompileError、またはVm::newからのVmError::VerifyError、です。各段階はResultを返します。どこかの段階で失敗すると、型付きのエラーになります — LexError、ParseError、CompileError、またはVm::newからのVmError::VerifyError、です。
検証は構築のときに行われる
Vm::newが、第5部の保証が強制される場所です。返す前に構造検証器と資源境界検証器を走らせます。検証器が却下するプログラムは、VMを得ることはありません。Vm::newがErr(VmError::VerifyError(message))を返し、メッセージはWHY_REJECTED.mdに書いてあるとおりのものです。その時点でスクリプトコードは1命令も走っていません。ホストはプログラムが受け入れられないことを、実行の途中ではなく、構築の時点で知ります。Vm::newが、第5部の保証が強制される場所です。返す前に構造検証器と資源境界検証器を走らせます。検証器が却下するプログラムは、VMを得ることはありません。Vm::newがErr(VmError::VerifyError(message))を返し、メッセージはWHY_REJECTED.mdに書いてあるとおりのものです。その時点でスクリプトコードは1命令も走っていません。ホストはプログラムが受け入れられないことを、実行の途中ではなく、構築の時点で知ります。
アリーナとそのライフタイム
アリーナはVMがオペランドスタックと動的文字列に使う、有界の作業メモリです。ホストが作って、VMが借ります。アリーナはVMがオペランドスタックと動的文字列に使う、有界の作業メモリです。ホストが作って、VMが借ります。
借りはRustの借用検査器が強制します。Vmは'arenaライフタイムパラメータを持っていて、アリーナはVMより長く生きなければなりません。実際には、アリーナをVMより前に宣言してVMより後にドロップする、ということになります。普通のブロックスコープがそれを自動でやってくれます。借りはRustの借用検査器が強制します。Vmは'arenaライフタイムパラメータを持っていて、アリーナはVMより長く生きなければなりません。実際には、アリーナをVMより前に宣言してVMより後にドロップする、ということになります。普通のブロックスコープがそれを自動でやってくれます。
#![allow(unused)]
fn main() {
let arena = Arena::with_capacity(DEFAULT_ARENA_CAPACITY);
let mut vm = Vm::new(module, &arena)?;
// vmはここで使われる; arenaはそれより長く生きる
}
第35章でアリーナの容量の選び方を扱います。今のところはDEFAULT_ARENA_CAPACITY、64キロバイトで足ります。第35章でアリーナの容量の選び方を扱います。今のところはDEFAULT_ARENA_CAPACITY、64キロバイトで足ります。
原子的モジュールを動かす
構築されると、原子的なfn mainモジュールはcallで動かします。
#![allow(unused)]
fn main() {
match vm.call(&[])? {
VmState::Finished(value) => { /* valueを使う */ }
other => panic!("expected Finished, got {:?}", other),
}
}
callは引数のスライスを取ります。パラメータを取らないfn mainは&[]で呼びます。原子的モジュールは最後まで走り、callがVmState::Finished(value)を返して、戻り値を運びます。callは引数のスライスを取ります。パラメータを取らないfn mainは&[]で呼びます。原子的モジュールは最後まで走り、callがVmState::Finished(value)を返して、戻り値を運びます。
yieldやloopモジュールは最初のcallでは終わりません。代わりにVmState::Yieldedを返し、動かすには再開プロトコルが必要です。第34章でそれを扱います。多くの本物のスクリプトに必要なネイティブ関数を先に扱います、次の章で。yieldやloopモジュールは最初のcallでは終わりません。代わりにVmState::Yieldedを返し、動かすには再開プロトコルが必要です。第34章でそれを扱います。多くの本物のスクリプトに必要なネイティブ関数を先に扱います、次の章で。
この章のまとめ
- VMの構築は4段階 —
tokenize、parse、compile、Vm::new、です。 Vm::newが検証を行います。却下されるプログラムはここで失敗し、コードは走りません。Vm::newが検証を行います。却下されるプログラムはここで失敗し、コードは走りません。- VMはアリーナを借り、アリーナはVMより長く生きなければなりません。
call(&[])は原子的モジュールを動かしてVmState::Finishedを返します。
次の章ではスクリプトが呼ぶホスト関数を登録します。
第33章. ネイティブ関数の登録
この章の目的
この章を読み終える頃には、スクリプトが呼ぶホスト関数を、人間工学的なルートと低レベルなルートの両方で登録できるようになります。
ネイティブ関数とは
ネイティブ関数とは、ホストが名前を付けてVMに登録するRustの関数のことです。スクリプトはuse宣言のあと、その名前で呼びます。ネイティブ関数は橋渡しです — スクリプトは何が起こるべきかを決め、ネイティブがそれを行います。ピアノロールのhost::playはネイティブです。スクリプトがそれを呼び、Rust側がオーディオ音声状態を更新します。
ネイティブ関数はVm::newのあと、スクリプトが走る前に登録します。
人間工学的なルート: register_fn
推奨されるのがregister_fnです。これは引数0個から4個までの任意のRust関数やクロージャを受け付けます。条件として、その引数と戻り値の型がKeleusmaTypeマーシャリングトレイトを実装している必要があります。プリミティブ型はすでに実装しています。
#![allow(unused)]
fn main() {
vm.register_fn("math::add", |a: i64, b: i64| -> i64 { a + b });
vm.register_fn("math::sin", |x: f64| -> f64 { libm::sin(x) });
}
引数の取り出し、引数数のチェック、戻り値の包装は自動的に行われます。失敗するかもしれない関数には、register_fn_fallibleがResult<R, VmError>の戻り値を受け付けます。
#![allow(unused)]
fn main() {
vm.register_fn_fallible("io::read_setting", |key: String| -> Result<String, VmError> {
fetch(&key).map_err(|e| VmError::NativeError(format!("{}", e)))
});
}
ホストの構造体や列挙型はKeleusmaTypeを派生(derive)させることで、境界を超えられます。
#![allow(unused)]
fn main() {
#[derive(KeleusmaType, Debug, Clone)]
struct Point { x: f64, y: f64 }
}
低レベルなルート: register_native_closure
register_fnはホストの状態を取り込めません。その引数は素のシグネチャだからです。ネイティブがホストの所有する状態を読んだり書いたりする必要があるときは、register_native_closureを使います。これは生の&[Value]引数を受け取ってResult<Value, VmError>を返す、ボックスされたクロージャを取ります。
#![allow(unused)]
fn main() {
let voices = shared_voices.clone();
vm.register_native_closure("host::silence", move |args: &[Value]| {
let channel = match args[0] {
Value::Int(n) => n as usize,
ref other => return Err(VmError::TypeError(
format!("expected Int, got {:?}", other))),
};
voices.lock().unwrap()[channel].gate = false;
Ok(Value::Unit)
});
}
これがピアノロールがすべてのネイティブに対して使うルートです。各ネイティブが共有のArc<Mutex<[Voice; 8]>>音声テーブルを取り込んでいるからです。クロージャは取り込んだクローンを所有し、生のValueを見ることで引数を明示的に検証できます。状態を取り込まない普通の関数ポインタは、代わりにregister_nativeを使えます。
バンドルされているライブラリ
keleusma::stddslモジュールは3つのネイティブ関数のバンドルを提供しています。それぞれ1回の呼び出しで登録します。
#![allow(unused)]
fn main() {
vm.register_library(stddsl::Math); // math::sqrt, math::pow, ...
vm.register_library(stddsl::Audio); // audio::midi_to_freq, ...
vm.register_library(stddsl::Shell); // shell::getenv, shell::run, shell::sleep_ms,
// shell::read_file, shell::write_file, ...
}
V0.2.1 の Shell バンドルは、環境変数アクセス(getenv、has_env、setenv)、サブプロセス実行(run、run_full、run_checked、run_timeout)、プロセス終了(exit)、タイミング(sleep_ms、now_unix_ms)、ファイル入出力(read_file、write_file、append_file、file_exists)、標準エラー出力(write_err、writeln_err)、およびホストメタデータ(pid、hostname、arg_count、arg、pwd、cd)を扱います。完全な一覧については STANDARD_LIBRARY.md を参照してください。
各バンドルは、ソース形式の use 宣言を含む公開の SIGNATURES 定数を公開します。コンパイル時の型およびアリティの検証を望むホストは、解析の前にこの定数をスクリプトソースの先頭に付加します。同梱の keleusma-cli は三つのバンドルすべてに対してこれを行います。カスタムバンドルは Library トレイトを実装し、並行する SIGNATURES 定数を公開することで、同じ検証フローに参加できます。
この章のまとめ
- ネイティブ関数は、スクリプトが名前で呼び出すホストの Rust 関数です。
register_fnおよびregister_fn_fallibleは、型がKeleusmaTypeを実装する関数に対する人間工学的な経路です。register_native_closureは、すべてのピアノロールのネイティブがそうであるように、ホストの状態をキャプチャするネイティブ向けの経路です。register_libraryは、バンドルされた、またはカスタムのネイティブ一式をインストールします。- 失敗し得るネイティブの失敗は、ネイティブエラー構文
native(args) { ok(v) => ..., error(code) => ... }を用いてスクリプト側で処理されます。ホストは、フィールドを持たない列挙型のバリアントをそれらの判別子へマッピングするKeleusmaErrorderive で構築したエラーを返すことにより、Wordエラーコードを報告します。第23章を参照してください。
次章では、yield を行うスクリプトを駆動します。
第34章. ホスト側のコルーチンプロトコル
この章の目的
この章を読み終える頃には、ホストからyieldやloopスクリプトを動かせるようになり、実行時エラーから回復できるようになります。この章を読み終える頃には、ホストからyieldやloopスクリプトを動かせるようになり、実行時エラーから回復できるようになります。
call, resume, VmState
ホストはVMに対して2つの入口を持ちます。call(&[Value])が実行を始め、resume(Value)がyieldのあとに続けます。両方ともResult<VmState, VmError>を返し、VmStateは3つのヴァリアントを持ちます。ホストはVMに対して2つの入口を持ちます。call(&[Value])が実行を始め、resume(Value)がyieldのあとに続けます。両方ともResult<VmState, VmError>を返し、VmStateは3つのヴァリアントを持ちます。
#![allow(unused)]
fn main() {
pub enum VmState {
Finished(Value),
Yielded(Value),
Reset,
}
}
これらは第15章の3つの関数カテゴリに対応します。
- 原子的な
fnスクリプトは最後まで走ります —callがFinished(value)を返します。原子的なfnスクリプトは最後まで走ります —callがFinished(value)を返します。 yieldスクリプトは値を渡します —callがYielded(value)を返し、ホストはresume(input)で続けます。yieldスクリプトは値を渡します —callがYielded(value)を返し、ホストはresume(input)で続けます。loopスクリプトはサイクルごとにyieldし、本体の終わりでリセットします。callがYieldedを返し、resumeが次のyieldまで動かし、本体が1周完了すると次の呼び出しがResetを返します。loopスクリプトはサイクルごとにyieldし、本体の終わりでリセットします。callがYieldedを返し、resumeが次のyieldまで動かし、本体が1周完了すると次の呼び出しがResetを返します。
ドライブループ
ホストはVmStateに対するmatchでyieldするスクリプトを動かします。
#![allow(unused)]
fn main() {
let mut state = vm.call(&[Value::Int(seed)])?;
loop {
match state {
VmState::Yielded(out) => {
let reply = host_response(&out);
state = vm.resume(reply)?;
}
VmState::Reset => {
state = vm.resume(Value::Int(next_input))?;
}
VmState::Finished(value) => {
handle_result(value);
break;
}
}
}
}
ピアノロールのティックループはこのパターンです。16分音符ティックごとに1回、現在のティック番号でresumeを呼び、スクリプトが1サイクル走ってyieldし、ホストは次のティック境界まで眠ります。スクリプトの本体が1サイクル完了すると、状態はResetになります。これがホットスワップが起こりうる境界、第37章の話題です。ピアノロールのティックループはこのパターンです。16分音符ティックごとに1回、現在のティック番号でresumeを呼び、スクリプトが1サイクル走ってyieldし、ホストは次のティック境界まで眠ります。スクリプトの本体が1サイクル完了すると、状態はResetになります。これがホットスワップが起こりうる境界、第37章の話題です。
対話の型
resumeに渡す値とYieldedが運ぶ値は、第16章で導入したスクリプトの「対話」の両半分です。ホストとスクリプトはこれら2つの型に同意していなければなりません。同意はRustコンパイラには確認されません。どちらも実行時のValue列挙型として運ばれるからです。スクリプトが期待する型の再開値を渡すのは、ホスト作者の責任です。resumeに渡す値とYieldedが運ぶ値は、第16章で導入したスクリプトの「対話」の両半分です。ホストとスクリプトはこれら2つの型に同意していなければなりません。同意はRustコンパイラには確認されません。どちらも実行時のValue列挙型として運ばれるからです。スクリプトが期待する型の再開値を渡すのは、ホスト作者の責任です。
エラーからの回復
callやresumeからの実行時エラーはErr(VmError)を返します。VMは中間状態に残されます。ホストには2つの選択肢があります。callやresumeからの実行時エラーはErr(VmError)を返します。VMは中間状態に残されます。ホストには2つの選択肢があります。
- VMを捨てて作り直す。アリーナに対して新しいVMを構築すると、アリーナがリセットされます。VMを捨てて作り直す。アリーナに対して新しいVMを構築すると、アリーナがリセットされます。
vm.reset_after_error()を呼ぶ。これがオペランドスタック、コールフレーム、アリーナをクリアしつつ、データセグメントは保持します。vm.reset_after_error()を呼ぶ。これがオペランドスタック、コールフレーム、アリーナをクリアしつつ、データセグメントは保持します。
#![allow(unused)]
fn main() {
match vm.call(&[arg]) {
Ok(state) => handle_state(state),
Err(VmError::TypeError(msg)) => {
eprintln!("script error: {}", msg);
vm.reset_after_error();
}
Err(other) => return Err(other.into()),
}
}
VmErrorは実行時の状態を列挙しています — StackUnderflow、TypeError、DivisionByZero、IndexOutOfBounds、FieldNotFound、NoMatch、NativeError、InvalidBytecode、Trap、VerifyError、LoadError。このうち、VerifyErrorとLoadErrorは構築のとき、スクリプトコードが走る前に発生します。それ以外は実行中に発生します。VmErrorは実行時の状態を列挙しています — StackUnderflow、TypeError、DivisionByZero、IndexOutOfBounds、FieldNotFound、NoMatch、NativeError、InvalidBytecode、Trap、VerifyError、LoadError。このうち、VerifyErrorとLoadErrorは構築のとき、スクリプトコードが走る前に発生します。それ以外は実行中に発生します。
この章のまとめ
callがVMを始動し、resumeがyieldのあとに続けます。VmStateはFinished、Yielded、Resetで、3つの関数カテゴリに対応します。VmStateはFinished、Yielded、Resetで、3つの関数カテゴリに対応します。- ホストは
yieldスクリプトをmatch-then-resumeループで動かします。 - 対話の型を守るのはホストの責任です。
reset_after_errorはデータセグメントを保持しながらVMを回復します。
次の章ではアリーナの大きさを決めます。
第35章. アリーナのサイズと境界の読み取り
この章の目的
この章を読み終える頃には、デフォルトに頼るのではなく、意図して選んだアリーナ容量を使えるようになります。この章を読み終える頃には、デフォルトに頼るのではなく、意図して選んだアリーナ容量を使えるようになります。
アリーナが保持するもの
アリーナはVMが借りる、一塊の連続したメモリのブロックです。一方の端からオペランドスタックが伸び、もう一方の端から動的文字列が伸びます。1回のStream-to-Reset反復で使われる合計は、第20章のワーストケースメモリ使用量、WCMU解析によって有界です。ホストは構築時にアリーナの容量を選びます。選択肢は3つあります。アリーナはVMが借りる、一塊の連続したメモリのブロックです。一方の端からオペランドスタックが伸び、もう一方の端から動的文字列が伸びます。1回のStream-to-Reset反復で使われる合計は、第20章のワーストケースメモリ使用量、WCMU解析によって有界です。ホストは構築時にアリーナの容量を選びます。選択肢は3つあります。
選択肢A: デフォルト容量
DEFAULT_ARENA_CAPACITYは64キロバイトで、ほとんどのスクリプトに十分です。DEFAULT_ARENA_CAPACITYは64キロバイトで、ほとんどのスクリプトに十分です。
#![allow(unused)]
fn main() {
let arena = Arena::with_capacity(DEFAULT_ARENA_CAPACITY);
}
ここから始めるのが正解です。計算された値や固定値に移るのは、何か理由があるときだけです。ここから始めるのが正解です。計算された値や固定値に移るのは、何か理由があるときだけです。
選択肢B: モジュールから容量を計算する
auto_arena_capacity_forは、モジュールを歩いて、そのWCMU境界が必要とする容量を返します。auto_arena_capacity_forは、モジュールを歩いて、そのWCMU境界が必要とする容量を返します。
#![allow(unused)]
fn main() {
let cap = keleusma::vm::auto_arena_capacity_for(&module, &[])?;
let arena = Arena::with_capacity(cap);
let vm = Vm::new(module, &arena)?;
}
2番目の引数はネイティブごとのヒープ計上のスライスです。スクリプトのネイティブがアリーナに割り当てない場合は空のスライスを渡し、割り当てる場合は計上値を渡します。この選択肢はアリーナをプログラムが必要とする厳密な量にサイズします、それ以上ではなく。2番目の引数はネイティブごとのヒープ計上のスライスです。スクリプトのネイティブがアリーナに割り当てない場合は空のスライスを渡し、割り当てる場合は計上値を渡します。この選択肢はアリーナをプログラムが必要とする厳密な量にサイズします、それ以上ではなく。
選択肢C: 静的バッファ
ヒープが無い組み込みターゲットでは、アリーナをホストが所有する静的メモリの中のバッファから動かせます。ヒープが無い組み込みターゲットでは、アリーナをホストが所有する静的メモリの中のバッファから動かせます。
#![allow(unused)]
fn main() {
static mut ARENA_BUFFER: [u8; 16 * 1024] = [0; 16 * 1024];
let arena = unsafe {
Arena::from_static_buffer(core::ptr::addr_of_mut!(ARENA_BUFFER))
};
}
これはno_stdデプロイメントのパターンで、アリーナはヒープ確保ではなく.bssの固定領域になります。これはno_stdデプロイメントのパターンで、アリーナはヒープ確保ではなく.bssの固定領域になります。
アリーナが小さすぎるとき
選んだ容量がモジュールの解析されたWCMUより小さい場合、Vm::newはVmError::VerifyErrorを返します。エラーは構築のとき、コードが1つも走る前に表面化します。サイズ不足のアリーナは実行時のハザードではありません。構築時の却下、検証器が出すその他の却下と同じ種類のものです。選んだ容量がモジュールの解析されたWCMUより小さい場合、Vm::newはVmError::VerifyErrorを返します。エラーは構築のとき、コードが1つも走る前に表面化します。サイズ不足のアリーナは実行時のハザードではありません。構築時の却下、検証器が出すその他の却下と同じ種類のものです。
これは第20章のメモリ予算をホスト側から見たものです。検証器はWCMU境界を証明し、ホストは少なくともそれだけの大きさのアリーナを提供しなければならず、両者が一致することの確認はVm::newで行われます。これは第20章のメモリ予算をホスト側から見たものです。検証器はWCMU境界を証明し、ホストは少なくともそれだけの大きさのアリーナを提供しなければならず、両者が一致することの確認はVm::newで行われます。
この章のまとめ
- アリーナは有界の作業メモリで、サイズはホストの選択です。
DEFAULT_ARENA_CAPACITYがデフォルト、auto_arena_capacity_forが厳密なサイズを計算、Arena::from_static_bufferが静的メモリから動かします。DEFAULT_ARENA_CAPACITYがデフォルト、auto_arena_capacity_forが厳密なサイズを計算、Arena::from_static_bufferが静的メモリから動かします。- モジュールのWCMUより小さいアリーナは
Vm::newで却下されます — コードが1つも走る前に。モジュールのWCMUより小さいアリーナはVm::newで却下されます — コードが1つも走る前に。
次の章では事前にコンパイルされたバイトコードを読み込みます。
第36章. バイトコードと署名付きバイトコードの読み込み
この章の目的
この章を読み終える頃には、事前にコンパイルされたバイトコードを読み込み、信頼マトリクスに対して署名付きバイトコードを読み込み、そしてトラストスキップコンストラクタを理解しているはずです。この章を読み終える頃には、事前にコンパイルされたバイトコードを読み込み、信頼マトリクスに対して署名付きバイトコードを読み込み、そしてトラストスキップコンストラクタを理解しているはずです。
事前にコンパイルされたバイトコードを読み込む
keleusma compileやビルドパイプラインから出てきた.kel.binファイルをホストが持っている場合、字句解析、構文解析、コンパイルの各段階を飛ばせます。keleusma compileやビルドパイプラインから出てきた.kel.binファイルをホストが持っている場合、字句解析、構文解析、コンパイルの各段階を飛ばせます。
#![allow(unused)]
fn main() {
let bytes = std::fs::read("script.kel.bin")?;
let mut vm = Vm::load_bytes(&bytes, &arena)?;
}
Vm::load_bytesはワイヤフォーマットのフレーミングを検証し、構造検証を行い、資源境界検証を行って、VMを返します。フレーミングの失敗はVmError::LoadError、解析の失敗はVmError::VerifyErrorです。検証はソースからコンパイルされたモジュールに対するものと同じです。バイトコードの読み込みは安全性のチェックを飛ばすわけではなく、コンパイルを飛ばすだけです。Vm::load_bytesはワイヤフォーマットのフレーミングを検証し、構造検証を行い、資源境界検証を行って、VMを返します。フレーミングの失敗はVmError::LoadError、解析の失敗はVmError::VerifyErrorです。検証はソースからコンパイルされたモジュールに対するものと同じです。バイトコードの読み込みは安全性のチェックを飛ばすわけではなく、コンパイルを飛ばすだけです。
署名付きバイトコードを読み込む
signedのエントリー関数からコンパイルされたモジュールはFLAG_REQUIRES_SIGNATUREビットを運び、Vm::load_bytesはそれを拒否して、署名付きの経路に呼び出し側を案内します。署名付きモジュールはVm::load_signed_bytesで読み込みます。これは信頼する公開鍵のスライスを取ります。signedのエントリー関数からコンパイルされたモジュールはFLAG_REQUIRES_SIGNATUREビットを運び、Vm::load_bytesはそれを拒否して、署名付きの経路に呼び出し側を案内します。署名付きモジュールはVm::load_signed_bytesで読み込みます。これは信頼する公開鍵のスライスを取ります。
#![allow(unused)]
fn main() {
let pub_bytes: [u8; 32] = std::fs::read("pub.bin")?.try_into().unwrap();
let key = ed25519_dalek::VerifyingKey::from_bytes(&pub_bytes)?;
let mut vm = Vm::load_signed_bytes(&signed_bytes, &arena, &[key])?;
}
このスライスが信頼マトリクスです。モジュールは、署名がスライス内のどれかの鍵に対して通れば読み込まれます。空のスライスはすべての署名付きモジュールを拒否します。マトリクスは構築されたVMにコピーされるので、その後の署名付きホットスワップ読み込みも同じ鍵を継承します。このスライスが信頼マトリクスです。モジュールは、署名がスライス内のどれかの鍵に対して通れば読み込まれます。空のスライスはすべての署名付きモジュールを拒否します。マトリクスは構築されたVMにコピーされるので、その後の署名付きホットスワップ読み込みも同じ鍵を継承します。
署名されていないベースラインから起動して、その後だけ署名付きの更新を受け入れるホストは、構築のあとに鍵を登録します。署名されていないベースラインから起動して、その後だけ署名付きの更新を受け入れるホストは、構築のあとに鍵を登録します。
#![allow(unused)]
fn main() {
let mut vm = Vm::new(unsigned_baseline_module, &arena)?;
vm.register_verifying_key(operator_key);
}
トラストスキップコンストラクタ
Vm::new_uncheckedは資源境界検証を飛ばします。実行ループはメモリ安全のために構造検証に依存しているので、構造検証は依然として走ります。Vm::new_uncheckedは資源境界検証を飛ばします。実行ループはメモリ安全のために構造検証に依存しているので、構造検証は依然として走ります。
#![allow(unused)]
fn main() {
let vm = unsafe { Vm::new_unchecked(module, &arena) };
}
unsafeが付いているのは信頼契約を表すためです — 呼び出し側は、バイトコードの資源境界が以前にビルド時に検証されたことを誓います。意図される用途は、1回検証してバイトコードを配布し、毎回の読み込みで再検証しなくて済むビルドパイプラインです。安全な検証器が却下するプログラムを通すために使うのは、言語の保証の外側での意図的な誤用で、そう文書化されています。第5部の有界な時間とメモリの保証は、Vm::newの下では成り立ち、Vm::new_uncheckedの下ではホストの誓いに弱まります。unsafeが付いているのは信頼契約を表すためです — 呼び出し側は、バイトコードの資源境界が以前にビルド時に検証されたことを誓います。意図される用途は、1回検証してバイトコードを配布し、毎回の読み込みで再検証しなくて済むビルドパイプラインです。安全な検証器が却下するプログラムを通すために使うのは、言語の保証の外側での意図的な誤用で、そう文書化されています。第5部の有界な時間とメモリの保証は、Vm::newの下では成り立ち、Vm::new_uncheckedの下ではホストの誓いに弱まります。
この章のまとめ
Vm::load_bytesは事前にコンパイルされた.kel.binを読み込み、ソースからコンパイルされたモジュールと同じく検証します。Vm::load_bytesは事前にコンパイルされた.kel.binを読み込み、ソースからコンパイルされたモジュールと同じく検証します。Vm::load_signed_bytesは信頼する公開鍵のスライスに対して署名付きバイトコードを読み込みます。Vm::load_signed_bytesは信頼する公開鍵のスライスに対して署名付きバイトコードを読み込みます。register_verifying_keyは構築後のVMに鍵を加えます。Vm::new_uncheckedは、明示的なunsafeの信頼契約のもとで、資源境界チェックを飛ばします。Vm::new_uncheckedは、明示的なunsafeの信頼契約のもとで、資源境界チェックを飛ばします。
次の章では走っているモジュールを新しいものに置き換えます。
第37章. ホスト側のホットコードスワップ
この章の目的
この章を読み終える頃には、走っているモジュールをホストから、リセット境界で新しいモジュールに置き換えられるようになります。この章を読み終える頃には、走っているモジュールをホストから、リセット境界で新しいモジュールに置き換えられるようになります。
スワップする場所
ホットコードスワップはホスト主導です。loopスクリプトは自分を置き換えません。ホストが置き換え、しかも1つの地点 — VmState::Reset、ループ本体の2つの反復の間の境界 — でだけです。その境界ではスクリプトのオペランドスタックは空で、データセグメントだけがスクリプトの所有する生きている状態です。これがスワップを安全にしている要因です。ホットコードスワップはホスト主導です。loopスクリプトは自分を置き換えません。ホストが置き換え、しかも1つの地点 — VmState::Reset、ループ本体の2つの反復の間の境界 — でだけです。その境界ではスクリプトのオペランドスタックは空で、データセグメントだけがスクリプトの所有する生きている状態です。これがスワップを安全にしている要因です。
ホストはドライブループの中でResetを見張ってVm::replace_moduleを呼びます。ホストはドライブループの中でResetを見張ってVm::replace_moduleを呼びます。
#![allow(unused)]
fn main() {
match vm.resume(input)? {
VmState::Reset => {
let new_module = load_new_version()?;
let slot_count = new_module_private_slot_count;
let initial_data = vec![Value::Int(0); slot_count];
vm.replace_module(new_module, initial_data)?;
vm.call(&[Value::Int(next_input)])?;
}
other => { /* ... */ }
}
}
replace_moduleは新しいモジュールと、初期のデータセグメントベクタを取ります。長さは新しいモジュールが宣言したdataブロックと一致しなければなりません。スワップのあと、VMのコルーチン状態はクリアされるので、新しいモジュールはresumeではなくcallでエントリーポイントから動かします。replace_moduleは新しいモジュールと、初期のデータセグメントベクタを取ります。長さは新しいモジュールが宣言したdataブロックと一致しなければなりません。スワップのあと、VMのコルーチン状態はクリアされるので、新しいモジュールはresumeではなくcallでエントリーポイントから動かします。
スワップを生き残るもの
スワップを跨ぐ事柄について3つ理解すべき点があります。
- 対話の型は変わってはいけません。新しいモジュールは古いものと同じ型を
yieldし、同じ型で再開されなければなりません。ホストは会話を途切れさせずに動かし続けるからです。対話の型は変わってはいけません。新しいモジュールは古いものと同じ型をyieldし、同じ型で再開されなければなりません。ホストは会話を途切れさせずに動かし続けるからです。 - データセグメントは新しいものとして渡されます。ホストは古い値を持ち越すこともできますし、ゼロに再初期化することも、新しいスキーマに合わせるための移行コードを走らせることもできます。ピアノロールは新しくゼロ化されたベクタを渡しているので、入ってくる各楽曲のinitブロックがクリーンな状態で走ります。データセグメントは新しいものとして渡されます。ホストは古い値を持ち越すこともできますし、ゼロに再初期化することも、新しいスキーマに合わせるための移行コードを走らせることもできます。ピアノロールは新しくゼロ化されたベクタを渡しているので、入ってくる各楽曲のinitブロックがクリーンな状態で走ります。
- ネイティブ関数の登録はモジュールではなくVMに住んでいるので、スワップを跨ぎます。新しいモジュールは古いモジュールが見ていたのと同じネイティブを見ます。ネイティブ関数の登録はモジュールではなくVMに住んでいるので、スワップを跨ぎます。新しいモジュールは古いモジュールが見ていたのと同じネイティブを見ます。
署名付きの更新
スワップが署名付きバイトコードとして配信されたモジュールを取り付ける場合、ホストはVm::replace_module_from_bytesを使います。これは、モジュールを取り付ける前に、VMが運ぶ信頼マトリクス — 第36章で登録した — に対して署名を確認します。署名付きホットスワップは多関係者配信シナリオの裏のメカニズムです — ベースラインのデバイスがリンク経由で署名付きの更新を受け取り、署名が通る場合だけ取り付けます。スワップが署名付きバイトコードとして配信されたモジュールを取り付ける場合、ホストはVm::replace_module_from_bytesを使います。これは、モジュールを取り付ける前に、VMが運ぶ信頼マトリクス — 第36章で登録した — に対して署名を確認します。署名付きホットスワップは多関係者配信シナリオの裏のメカニズムです — ベースラインのデバイスがリンク経由で署名付きの更新を受け取り、署名が通る場合だけ取り付けます。
ピアノロールのスワップ
ピアノロールが作業例です。標準入力スレッドがキー入力をスワップ要求に変えます。メインループは、次のVmState::Resetで、replace_moduleを次の楽曲のモジュールと新しくゼロ化されたデータベクタで呼び、ホスト所有の音声状態をリセットし、新しいモジュールのエントリーポイントを呼びます。耳で聴ける楽曲の変更は、まさにこのコードパスです。ピアノロールが作業例です。標準入力スレッドがキー入力をスワップ要求に変えます。メインループは、次のVmState::Resetで、replace_moduleを次の楽曲のモジュールと新しくゼロ化されたデータベクタで呼び、ホスト所有の音声状態をリセットし、新しいモジュールのエントリーポイントを呼びます。耳で聴ける楽曲の変更は、まさにこのコードパスです。
この章のまとめ
- ホットスワップはホスト主導で、
VmState::Resetでだけ起こります。 replace_moduleは新しいモジュールと新しいデータセグメントを取り付け、新しいモジュールはその後callから動かします。replace_moduleは新しいモジュールと新しいデータセグメントを取り付け、新しいモジュールはその後callから動かします。- 対話の型は変わってはいけません。ネイティブ登録は持続します。データセグメントはホストが持ち越すか、リセットするか、移行させます。対話の型は変わってはいけません。ネイティブ登録は持続します。データセグメントはホストが持ち越すか、リセットするか、移行させます。
replace_module_from_bytesは信頼マトリクスに対して署名付きの更新を取り付けます。replace_module_from_bytesは信頼マトリクスに対して署名付きの更新を取り付けます。
次の章では実行コストを測ります。
第38章. キャリブレートされたWCETとコストモデル
この章の目的
この章を読み終える頃には、ワーストケース実行時間がどう計上されるか、そしてホストが自分のハードウェアにキャリブレートされた数字をどう得るかが分かるようになります。この章を読み終える頃には、ワーストケース実行時間がどう計上されるか、そしてホストが自分のハードウェアにキャリブレートされた数字をどう得るかが分かるようになります。
コストモデル
第20章のWCET解析は、2つのyieldの間の最も長いパスでの仕事量を数えます。命令の数をサイクル数に変えるには、コストモデルを参照します — 各オペコードにパイプライン化サイクルのコストを割り当てる表と、値スロットのサイズです。第20章のWCET解析は、2つのyieldの間の最も長いパスでの仕事量を数えます。命令の数をサイクル数に変えるには、コストモデルを参照します — 各オペコードにパイプライン化サイクルのコストを割り当てる表と、値スロットのサイズです。
ランタイムはNOMINAL_COST_MODELを提供しています。そのコストは推定値であって、測定値ではありません — データの移動に1サイクル、算術に2、除算に3、複合構築に5、関数呼び出しに10、です。名目モデルは1つのプラットフォーム上で2つのプログラムを比較するには健全ですが、その数字は特定のプロセッサのサイクル数ではありません。ランタイムはNOMINAL_COST_MODELを提供しています。そのコストは推定値であって、測定値ではありません — データの移動に1サイクル、算術に2、除算に3、複合構築に5、関数呼び出しに10、です。名目モデルは1つのプラットフォーム上で2つのプログラムを比較するには健全ですが、その数字は特定のプロセッサのサイクル数ではありません。
測定されたモデル
本物のデプロイメントプロセッサにキャリブレートされたWCETの数字が必要なホストは、測定されたコストモデルを使います。ワークスペースメンバーのkeleusma-benchは、各オペコードをターゲット上でベンチマークし、生成されたMEASURED_COST_MODEL断片を出力します。ホストは自分のターゲット用の断片をインクルードし、WCET APIの_with_cost_modelバリアントにモデルを渡します。本物のデプロイメントプロセッサにキャリブレートされたWCETの数字が必要なホストは、測定されたコストモデルを使います。ワークスペースメンバーのkeleusma-benchは、各オペコードをターゲット上でベンチマークし、生成されたMEASURED_COST_MODEL断片を出力します。ホストは自分のターゲット用の断片をインクルードし、WCET APIの_with_cost_modelバリアントにモデルを渡します。
#![allow(unused)]
fn main() {
include!(concat!(env!("CARGO_MANIFEST_DIR"),
"/measured_cost_models/aarch64_apple_darwin.rs"));
use keleusma::verify::wcet_stream_iteration_with_cost_model;
let cycles = wcet_stream_iteration_with_cost_model(chunk, &MEASURED_COST_MODEL, &[])?;
}
keleusma-benchクレートは、新しいターゲット用の断片を生成するキャプチャワークフローを文書化しています。keleusma-benchクレートは、新しいターゲット用の断片を生成するキャプチャワークフローを文書化しています。
ネイティブ関数の計上
WCETとWCMU解析は、デフォルトではネイティブ関数呼び出しのコストをゼロとして計上します。検証器はホストのRustコードの中を見られないからです。健全な境界が必要なホストは、解析が走る前にネイティブごとのコストを宣言します。WCETとWCMU解析は、デフォルトではネイティブ関数呼び出しのコストをゼロとして計上します。検証器はホストのRustコードの中を見られないからです。健全な境界が必要なホストは、解析が走る前にネイティブごとのコストを宣言します。
#![allow(unused)]
fn main() {
vm.set_native_bounds("math::sin", 25, 0)?;
vm.set_native_bounds("text::upper", 100, 256)?;
}
最初の数字はネイティブのワーストケースパイプライン化サイクルコスト、2番目はそのワーストケースのアリーナヒープ割り当て、バイト単位です。これらはホストの誓いです。検証器は宣言された値を独立に測定せずに受け入れるので、ホストがその正確さに責任を負います。測定するか、ネイティブの有界解析を行うか、です。最初の数字はネイティブのワーストケースパイプライン化サイクルコスト、2番目はそのワーストケースのアリーナヒープ割り当て、バイト単位です。これらはホストの誓いです。検証器は宣言された値を独立に測定せずに受け入れるので、ホストがその正確さに責任を負います。測定するか、ネイティブの有界解析を行うか、です。
その数字の意味
解析が出す境界はパイプライン化サイクル単位、仕事量の単位です。それをデプロイメントプラットフォーム上の実時間秒数に変換するには、キャッシュとパイプラインのストールとクロック周期を勘案する、プラットフォーム特有の係数が必要です。言語はパイプライン化サイクル境界を保証します。ホストは自分のハードウェアに対する変換係数を誓います。この責任の分割は第20章で説明したのと同じもので、ここではホスト側から見ています。解析が出す境界はパイプライン化サイクル単位、仕事量の単位です。それをデプロイメントプラットフォーム上の実時間秒数に変換するには、キャッシュとパイプラインのストールとクロック周期を勘案する、プラットフォーム特有の係数が必要です。言語はパイプライン化サイクル境界を保証します。ホストは自分のハードウェアに対する変換係数を誓います。この責任の分割は第20章で説明したのと同じもので、ここではホスト側から見ています。
この章のまとめ
- コストモデルはオペコードにパイプライン化サイクルコストを割り当て、解析はそれを使ってWCETの数字を生みます。コストモデルはオペコードにパイプライン化サイクルコストを割り当て、解析はそれを使ってWCETの数字を生みます。
NOMINAL_COST_MODELは未測定で相対比較に良く、keleusma-benchから得るMEASURED_COST_MODELはターゲットにキャリブレートされています。NOMINAL_COST_MODELは未測定で相対比較に良く、keleusma-benchから得るMEASURED_COST_MODELはターゲットにキャリブレートされています。set_native_boundsはネイティブ関数のコストを誓うので、解析が計上できます。set_native_boundsはネイティブ関数のコストを誓うので、解析が計上できます。- 境界はパイプライン化サイクル単位です。実時間に変換するのはホストの責任です。境界はパイプライン化サイクル単位です。実時間に変換するのはホストの責任です。
次の章では完全なホストを最初から最後まで歩きます。
第39章. 完全なホスト、最初から最後まで
この章の目的
この章を読み終える頃には、完全なピアノロールホストを読んで、この部の各テクニックがその中のどこに出てくるかが分かるようになります。この章を読み終える頃には、完全なピアノロールホストを読んで、この部の各テクニックがその中のどこに出てくるかが分かるようになります。
ファイル
ピアノロールのホストはリポジトリの中の1つのファイルexamples/piano_roll.rsです。およそ1300行で、読まれることを意図して書かれています。この章はその地図です。ピアノロールのホストはリポジトリの中の1つのファイルexamples/piano_roll.rsです。およそ1300行で、読まれることを意図して書かれています。この章はその地図です。
2種類のコード
最初の通読で認識すべき最も役立つ事は、ファイルには2種類のコードが入っていて、そのうち1種類だけがKeleusmaの組み込みに関するものだ、ということです。最初の通読で認識すべき最も役立つ事は、ファイルには2種類のコードが入っていて、そのうち1種類だけがKeleusmaの組み込みに関するものだ、ということです。
組み込みコードはこの部の話題で、ファイルの一部分でしかありません。
build_moduleは第32章の段階 —tokenize、parse、compile、を実行します。build_moduleは第32章の段階 —tokenize、parse、compile、を実行します。runがArenaとVmを構築します — 第32章の残り、です。register_nativesが16個のネイティブをregister_native_closureで登録します — 第33章の取り込み状態のルート、です。register_nativesが16個のネイティブをregister_native_closureで登録します — 第33章の取り込み状態のルート、です。init_dataがset_dataでデータセグメントを初期化します。- メインのティックループが
resumeを呼び、VmStateにマッチし、Resetケースを処理します — 第34章のプロトコル、です。メインのティックループがresumeを呼び、VmStateにマッチし、Resetケースを処理します — 第34章のプロトコル、です。 Resetアームがreplace_moduleを呼んで楽曲を切り替えます — 第37章のホットスワップ、です。Resetアームがreplace_moduleを呼んで楽曲を切り替えます — 第37章のホットスワップ、です。
オーディオ合成コードはそれ以外のすべてで、Keleusmaにはまったく関係ありません。Mixer、AudioCallback実装、advance_envelope、waveform_sample、VoiceとEnvState構造体、SDL3デバイスのセットアップは普通のソフトウェアシンセサイザーです。どんなオーディオプログラムも同じようなコードが必要です。ファイルを組み込みの学習のために読むとき、このコードはざっと飛ばして構いません。オーディオ合成コードはそれ以外のすべてで、Keleusmaにはまったく関係ありません。Mixer、AudioCallback実装、advance_envelope、waveform_sample、VoiceとEnvState構造体、SDL3デバイスのセットアップは普通のソフトウェアシンセサイザーです。どんなオーディオプログラムも同じようなコードが必要です。ファイルを組み込みの学習のために読むとき、このコードはざっと飛ばして構いません。
main と run の分離
ファイルは2つの関心を分けています。関数mainはアプリケーションの装飾 — 引数処理やプロセスレベルのセットアップ — を担います。関数runは組み込み可能なホストループ — VMの構築、オーディオデバイスのオープン、ネイティブの登録、ティック・アンド・イールドサイクルのドライブ — を担います。両者の境界は、別のアプリケーションが捨てる部分とコピーする部分の境界です。ピアノロールを別のプログラムに持ち込む開発者は、runの本体をコピーします。ファイルは2つの関心を分けています。関数mainはアプリケーションの装飾 — 引数処理やプロセスレベルのセットアップ — を担います。関数runは組み込み可能なホストループ — VMの構築、オーディオデバイスのオープン、ネイティブの登録、ティック・アンド・イールドサイクルのドライブ — を担います。両者の境界は、別のアプリケーションが捨てる部分とコピーする部分の境界です。ピアノロールを別のプログラムに持ち込む開発者は、runの本体をコピーします。
クックブックのパターン
リポジトリのCOOKBOOK.mdは、アプリケーションを跨いで繰り返される、そしてピアノロールが触れているホスト側のパターンを集めています — モジュールのWCMUからアリーナをサイズする、ホスト構成のためのデータローダーパターン、サブ64ビットターゲットのための狭いランタイム型エイリアシング、署名付きバイトコードの配信、測定されたコストモデルでキャリブレートされたWCET、です。それぞれがこの部のテクニックを土台にした短いレシピです。リポジトリのCOOKBOOK.mdは、アプリケーションを跨いで繰り返される、そしてピアノロールが触れているホスト側のパターンを集めています — モジュールのWCMUからアリーナをサイズする、ホスト構成のためのデータローダーパターン、サブ64ビットターゲットのための狭いランタイム型エイリアシング、署名付きバイトコードの配信、測定されたコストモデルでキャリブレートされたWCET、です。それぞれがこの部のテクニックを土台にした短いレシピです。
この章のまとめ
- ピアノロールのホストは1つの読める形式の、およそ1300行のファイルです。
- ファイルには組み込みコードとオーディオ合成コードが入っていますが、最初の方だけがKeleusmaに関するもので、それはファイルのごく一部です。ファイルには組み込みコードとオーディオ合成コードが入っていますが、最初の方だけがKeleusmaに関するもので、それはファイルのごく一部です。
- この部のすべてのテクニックがその中に出てきます — 構築、ネイティブ登録、再開プロトコル、ホットスワップ、です。この部のすべてのテクニックがその中に出てきます — 構築、ネイティブ登録、再開プロトコル、ホットスワップ、です。
mainとrunの分離が、アプリケーションの装飾と組み込み可能なホストループを分けています。mainとrunの分離が、アプリケーションの装飾と組み込み可能なホストループを分けています。
これで第9部は完了です。ホスト側のすべての要素 — 構築、ネイティブ関数、コルーチンプロトコル、アリーナのサイズ、バイトコードの読み込み、ホットスワップ、コストモデル — を持ちました。最後の部は、ここからどこへ進めばよいかを指し示します。これで第9部は完了です。ホスト側のすべての要素 — 構築、ネイティブ関数、コルーチンプロトコル、アリーナのサイズ、バイトコードの読み込み、ホットスワップ、コストモデル — を持ちました。最後の部は、ここからどこへ進めばよいかを指し示します。
第40章. さらに読み進めるために
これはガイド最後の章です。
この章の目的
この章はガイドを締めくくり、次にどこへ進むかを指し示します。
ここまでに行ってきたこと
第1部から第8部はKeleusmaの言語を教えました — 値と型、関数と制御の流れ、データの形、3つの関数カテゴリとホストとの会話、検証器と保証、より深い機能、プログラムをどう出荷するか、そして集大成として動くピアノロール、です。第9部はもう一方の側、Rustホストの中にKeleusmaを組み込むことを教えました。両者あわせて、ガイドはどちら側のKeleusma開発者も始めるのに必要なすべてをカバーしています。第1部から第8部はKeleusmaの言語を教えました — 値と型、関数と制御の流れ、データの形、3つの関数カテゴリとホストとの会話、検証器と保証、より深い機能、プログラムをどう出荷するか、そして集大成として動くピアノロール、です。第9部はもう一方の側、Rustホストの中にKeleusmaを組み込むことを教えました。両者あわせて、ガイドはどちら側のKeleusma開発者も始めるのに必要なすべてをカバーしています。
これに続くのは、その知識を深めるための道です。
2つ目の作業例: ローグライク
ピアノロールは1つの作業例です。リポジトリにはもう一つ、より大きな作業例があります — examples/rogue/の中のローグライクゲームで、その長文マニュアルがdocs/guide/ROGUE.mdにあります。ピアノロールがloopスクリプトを1つ持つ一方、ローグライクは複数のスクリプトで動きます — ゲームティックループ、ダンジョン生成器、モンスターの振る舞いの集合、戦闘の数式、アイテムの効果、です。大きなアプリケーションがそのロジックを多くのKeleusmaスクリプトに分けて、1つのRustホストの裏で組み立てる方法を学ぶための研究対象です。ピアノロールは1つの作業例です。リポジトリにはもう一つ、より大きな作業例があります — examples/rogue/の中のローグライクゲームで、その長文マニュアルがdocs/guide/ROGUE.mdにあります。ピアノロールがloopスクリプトを1つ持つ一方、ローグライクは複数のスクリプトで動きます — ゲームティックループ、ダンジョン生成器、モンスターの振る舞いの集合、戦闘の数式、アイテムの効果、です。大きなアプリケーションがそのロジックを多くのKeleusmaスクリプトに分けて、1つのRustホストの裏で組み立てる方法を学ぶための研究対象です。
リファレンスドキュメント
ガイドは言語を説明しました。正確な参照のために、リポジトリのdocs/spec/ディレクトリに公式仕様があります — 形式文法、型システム、標準ライブラリ、命令セット、ワイヤフォーマット。docs/architecture/ディレクトリには設計の解説があります — 設計目標と保証を扱うLANGUAGE_DESIGN.md、ランタイムモデルを扱うEXECUTION_MODEL.md、です。質問が説明ではなく正確な答えを求めているとき、開くべきはこれらのドキュメントです。ガイドは言語を説明しました。正確な参照のために、リポジトリのdocs/spec/ディレクトリに公式仕様があります — 形式文法、型システム、標準ライブラリ、命令セット、ワイヤフォーマット。docs/architecture/ディレクトリには設計の解説があります — 設計目標と保証を扱うLANGUAGE_DESIGN.md、ランタイムモデルを扱うEXECUTION_MODEL.md、です。質問が説明ではなく正確な答えを求めているとき、開くべきはこれらのドキュメントです。
ホスト側とトラブルシューティングのリファレンス
第9部の先の組み込み作業については、docs/guide/COOKBOOK.mdがホスト側のレシピをまとめています。docs/guide/EMBEDDING.mdがホスト側の完全なリファレンスです。検証器がプログラムを却下するとき、docs/guide/WHY_REJECTED.mdが却下メッセージとその原因と書き換えを対応付けています。docs/guide/FAQ.mdは初期のユーザーが出会う粗削りな点と意外な点を集めています。docs/reference/ディレクトリには用語集と、Keleusmaが下敷きにしている学術的、産業的な仕事の中での位置を述べるRELATED_WORK.mdがあります。第9部の先の組み込み作業については、docs/guide/COOKBOOK.mdがホスト側のレシピをまとめています。docs/guide/EMBEDDING.mdがホスト側の完全なリファレンスです。検証器がプログラムを却下するとき、docs/guide/WHY_REJECTED.mdが却下メッセージとその原因と書き換えを対応付けています。docs/guide/FAQ.mdは初期のユーザーが出会う粗削りな点と意外な点を集めています。docs/reference/ディレクトリには用語集と、Keleusmaが下敷きにしている学術的、産業的な仕事の中での位置を述べるRELATED_WORK.mdがあります。
より広いリポジトリ
examples/ディレクトリにはより小さなプログラムがいくつもあって、それぞれが一つの組み込みテクニックを示しています。examples/rtos/ディレクトリには協調型リアルタイムマイクロカーネルがあって、組み込みハードウェアの上でKeleusmaを動かします — 言語が意図しているデプロイメントターゲットの最も明確な実演です。examples/ディレクトリにはより小さなプログラムがいくつもあって、それぞれが一つの組み込みテクニックを示しています。examples/rtos/ディレクトリには協調型リアルタイムマイクロカーネルがあって、組み込みハードウェアの上でKeleusmaを動かします — 言語が意図しているデプロイメントターゲットの最も明確な実演です。
締めくくり
Keleusmaは意図して小さい言語です。とても多くを省くからこそ、少しのことを確実に約束できます — プログラムは拍を保ち、決まった時間とメモリの予算の内側で、サイクルごとに、永遠に動き続ける、と。このガイドのすべてはその約束から派生していました。その内側で書かれるプログラムは、普通のプログラムには信頼を置けない場所でも信頼できます。Keleusmaは意図して小さい言語です。とても多くを省くからこそ、少しのことを確実に約束できます — プログラムは拍を保ち、決まった時間とメモリの予算の内側で、サイクルごとに、永遠に動き続ける、と。このガイドのすべてはその約束から派生していました。その内側で書かれるプログラムは、普通のプログラムには信頼を置けない場所でも信頼できます。
これがガイドの終わりです。次のステップは、何かを書くこと、です。
はじめに
本ドキュメントは、Keleusma のコマンドラインフロントエンドのインストール、最初のスクリプトの記述と実行、そして同じスクリプトを Rust ホストプログラムへ組み込む手順を、新規ユーザー向けに順を追って説明します。この解説では、edition 2024 かつサポートされる最小の Rust バージョン 1.88 の動作する Rust ツールチェインを前提とします。
CLI のインストール
Keleusma は keleusma という名前のスタンドアロンな CLI バイナリを同梱しています。この CLI は、スクリプトランナー、バイトコードコンパイラ、および対話型 REPL を提供します。ワークスペースのルートからインストールしてください。
git clone https://github.com/sgeos/keleusma
cd keleusma
cargo install --path keleusma-cli --bin keleusma
インストールを確認します。
keleusma --help
コマンドが見つからない場合は、CargoのbinディレクトリがシェルのPATHに含まれていることを確認してください。デフォルトの場所は~/.cargo/binです。
最初のスクリプト
hello.kelという名前のファイルを次の内容で作成します。
fn double(x: Word) -> Word {
x + x
}
fn main() -> Word {
double(21)
}
スクリプトを実行します。
keleusma run hello.kel
期待される出力です。
42
ランナーはスクリプトをパースし、コンパイルし、検証し、実行します。fnで宣言された原子的・全域関数は、ホストにyieldしたり、無制限の再帰を含んだりすることはできません。main関数がエントリーポイントです。戻り型は関数のシグネチャに現れ、必須です。
バイトコードへコンパイルする
CLIはスクリプトをバイトコードにシリアライズできます。シリアライズされた形式は組み込みAPIを通じて読み込めます。
keleusma compile hello.kel -o hello.kel.bin
出力ファイルはフレーム付きワイヤフォーマットを使い、マジック、長さ、バージョン、ターゲットのワード幅とアドレス幅、本体、CRCトレーラを持ちます。ホストはVm::load_bytesでこのファイルを読み込みます。
対話型REPL
REPLを起動して言語をインタラクティブに探索します。
keleusma repl
REPLは宣言をセッションのプレフィックスに蓄積し、現在のプレフィックスに対して式を評価します。REPLはコロン接頭辞のコマンド:help、:quit、:reset、:showをサポートします。
> 1 + 2
3
> fn double(x: Word) -> Word { x + x }
defined: double
> double(21)
42
> :quit
REPLは各式をfn main() -> T { <expression> }で包んで、戻り型Word、Float、bool、Text、()の順に試します。最初にコンパイルが通る型が使われます。このリストに含まれない型を持つ式は、明示的な関数宣言が必要です。
Rustホストに組み込む
同じスクリプトはRustホストプログラムからも実行できます。新しいCargoプロジェクトを作ります。
cargo new --bin keleusma-hello
cd keleusma-hello
Cargo.tomlにKeleusmaを追加します。
[dependencies]
keleusma = "0.2"
keleusma-arena = "0.3"
src/main.rsを次の内容で置き換えます。
use keleusma::compiler::compile;
use keleusma::lexer::tokenize;
use keleusma::parser::parse;
use keleusma::vm::{DEFAULT_ARENA_CAPACITY, Vm, VmState};
use keleusma::{Arena, Value};
const SCRIPT: &str = "
fn double(x: Word) -> Word { x + x }
fn main() -> Word { double(21) }
";
fn main() {
let tokens = tokenize(SCRIPT).expect("lex");
let program = parse(&tokens).expect("parse");
let module = compile(&program).expect("compile");
let arena = Arena::with_capacity(DEFAULT_ARENA_CAPACITY);
let mut vm = Vm::new(module, &arena).expect("verify");
match vm.call(&[]).expect("call") {
VmState::Finished(Value::Int(n)) => println!("{}", n),
other => panic!("unexpected: {:?}", other),
}
}
ビルドして実行します。
cargo run
期待される出力です。
42
ホストコードはCLIランナーと同じcompile-verify-runパイプラインを行います。4つのステップがソースに見えます — lex、parse、compile、execute、です。Arenaは、VMがオペランドスタックと動的文字列の確保のために借りる、境界付きメモリ領域です。DEFAULT_ARENA_CAPACITYは64キロバイトで、ほとんどのスクリプトに十分な値であり、バンドルされた例で使われている値です。
次のステップ
上のウォークスルーで、動くKeleusmaホストができました。よくある次のステップです。
- EMBEDDING.mdで、ネイティブ関数の登録、アリーナのサイズ、ストリーム分類スクリプトのためのcall/resumeループ、エラー回復を含む完全な組み込み面を読んでください。
- 検証器がプログラムを却下するときはWHY_REJECTED.mdを読んでください。エラーメッセージを根本原因にマッピングし、書き換えを提案しています。
- 一般的な言語機能を実演する短いスクリプトは
examples/scripts/を探索してください。各スクリプトはkeleusma runで実行できます。 - WCMU計算、ネイティブ計上、yieldを通じたエラー伝搬、文字列の相互運用を実演するRust組み込み例は
examples/を探索してください。 - フィーチャーゲート付きのエンドツーエンドSDL3オーディオデモは
examples/piano_roll.rsで実行してください。パラメータ制御の波形、エンベロープ、ビブラート、ローパスフィルタ、ステレオスピーカー別音量を持つ8つのヴォイスを、Keleusmaのティックループでシーケンスし、ホットコードスワップで事前コンパイル済みの楽曲一覧を巡ります。cargo run --release --example piano_roll --features sdl3-exampleで実行します。長文マニュアル(楽曲作曲、ホスト持ち上げ、他の制御ループ領域に対するアーキテクチャパターンを扱う)はPIANO_ROLL.mdを参照してください。
組み込み
本ドキュメントは、Keleusma のホスト向け組み込みインターフェースについて説明します。VM の構築、ネイティブ関数の登録、アリーナのサイズ設定、コルーチンスクリプトの呼び出しと再開のプロトコル、そしてエラー回復を扱います。このインターフェースのリファレンスは src/vm.rs です。実際に動作する例は examples/ にあります。
VM のライフサイクル
Keleusma の VM は、単一スレッドのコルーチンドライバです。ホストがバイトコードとアリーナを所有します。VM は、その存続期間にわたってアリーナを借用します。
最小限のライフサイクルは以下のとおりです。
#![allow(unused)]
fn main() {
use keleusma::compiler::compile;
use keleusma::lexer::tokenize;
use keleusma::parser::parse;
use keleusma::vm::{DEFAULT_ARENA_CAPACITY, Vm};
use keleusma::Arena;
let tokens = tokenize(SOURCE)?;
let program = parse(&tokens)?;
let module = compile(&program)?;
let arena = Arena::with_capacity(DEFAULT_ARENA_CAPACITY);
let mut vm = Vm::new(module, &arena)?;
}
4 つのフェーズは、4 つの異なる値の型を生成します。tokenize は Vec<Token> を生成します。parse は Program 構文木を生成します。compile は Module バイトコードオブジェクトを生成します。Vm::new はモジュールを消費し、アリーナを借用し、構造検証とリソース境界検証を実行して、呼び出し可能な状態の VM を返します。
VM とモジュールは、アリーナのライフタイムを共有します。ホストは、少なくとも VM が存続する間はアリーナを生存させ続けなければなりません。これは、Vm の 'arena ライフタイムパラメータを通じてボローチェッカーによって強制されます。
プリコンパイル済みバイトコードの読み込み
プリコンパイル済みの .kel.bin ファイルを持つホストは、字句解析、構文解析、およびコンパイルの各ステップをスキップします。
#![allow(unused)]
fn main() {
let bytes = std::fs::read("script.kel.bin")?;
let mut vm = Vm::load_bytes(&bytes, &arena)?;
}
ワイヤフォーマットは自己記述的です。ヘッダはマジック、長さ、バージョン、ターゲットのワード、アドレス、フロート幅を運びます。Vm::load_bytesはフレーミングを検証し、構造検証を走らせ、資源境界検証を走らせて、VMを返します。検証の失敗はフレーミングの失敗にはVmError::LoadError、解析の失敗にはVmError::VerifyErrorとして返されます。
スクリプトを呼ぶ
VMは2つのエントリポイントを公開しています — 実行を始めるVm::call(args)と、yieldのあとに続けるVm::resume(value)です。両方ともResult<VmState, VmError>を返します。
#![allow(unused)]
fn main() {
pub enum VmState {
Finished(Value),
Yielded(Value),
Reset,
}
}
3つの状態は3つの関数カテゴリに対応します。
fn(原子的・全域)。スクリプトは終了して値を返します。callはVmState::Finished(value)を返します。yield(非原子的・全域)。スクリプトはホストに値をyieldします。callはVmState::Yielded(value)を返します。ホストはホスト提供の入力でresume(value)を呼びます。スクリプトはまたyieldするか、終了します。loop(生産的・発散)。スクリプトは反復ごとにyieldし、本体の終わりでリセットします。callはVmState::Yielded(value)を返します。ホストはresume(value)を呼んで次のyieldまで動かします。本体が完了したあと、次の呼び出しはVmState::Resetを返します。ホットコードスワップはリセットの境界で許容されます。
典型的なyield駆動ループは次のように見えます。
#![allow(unused)]
fn main() {
let mut state = vm.call(&[Value::Int(seed)])?;
loop {
match state {
VmState::Yielded(out) => {
let reply = compute_host_response(&out);
state = vm.resume(reply)?;
}
VmState::Reset => {
state = vm.resume(Value::Int(next_seed))?;
}
VmState::Finished(value) => {
handle_result(value);
break;
}
}
}
}
ネイティブ関数
ネイティブ関数はホストがVMに登録するRust関数で、スクリプトが名前で呼べます。ホストは関数名、関数ポインタまたはクロージャ、そして(任意で)WCETとWCMUの境界を宣言します。
人間工学的な型付き登録
推奨される経路はマーシャリング層を使います。引数の型と戻り値の型がKeleusmaTypeを実装する、任意のRust関数またはクロージャ(引数0〜4個)はregister_fnで登録します。
#![allow(unused)]
fn main() {
vm.register_fn("math::add", |a: i64, b: i64| -> i64 { a + b });
vm.register_fn("math::sin", |x: f64| -> f64 { libm::sin(x) });
vm.register_fn("strings::upper", |s: String| -> String { s.to_uppercase() });
}
失敗する可能性のある関数には、register_fn_fallibleがResult<R, VmError>を受け取ります。
#![allow(unused)]
fn main() {
vm.register_fn_fallible("io::read_setting", |key: String| -> Result<String, VmError> {
fetch(&key).map_err(|e| VmError::NativeError(format!("{}", e)))
});
}
引数の取り出し、引数数のチェック、戻り値の包装は自動的に行われます。境界での型不一致は実行時にVmError::TypeErrorとして表面化します。
deriveマクロによるカスタム型
ホストの構造体と列挙型はKeleusmaTypederive を通じてマーシャル可能になります。
#![allow(unused)]
fn main() {
use keleusma::KeleusmaType;
#[derive(KeleusmaType, Debug, Clone)]
struct Point {
x: f64,
y: f64,
}
vm.register_fn("geom::midpoint", |a: Point, b: Point| -> Point {
Point {
x: (a.x + b.x) / 2.0,
y: (a.y + b.y) / 2.0,
}
});
}
スクリプトは、境界を越えて正しく流れるように、ホストの Point に対して構造的に互換な型を宣言しなければなりません。許容される相互運用型については TYPE_SYSTEM.md を参照してください。
低レベルの登録
関数が生のValue列挙型を検査しなければならないときは、&[Value]を受け取ってResult<Value, VmError>を返す関数ポインタを直接登録します。
#![allow(unused)]
fn main() {
fn first_argument(args: &[Value]) -> Result<Value, VmError> {
args.first()
.cloned()
.ok_or_else(|| VmError::NativeError(String::from("missing arg")))
}
vm.register_native("debug::first_argument", first_argument);
}
ボックスされたクロージャヴァリアントのregister_native_closureがホスト状態を取り込みます。コンテキスト対応ヴァリアントのregister_native_with_ctxはアリーナの借りを運ぶNativeCtx<'a>を受け取り、アリーナメモリに動的文字列を割り当てるネイティブが使います。
バンドルされているネイティブ
V0.2.0はスクリプト側のテキスト合成機構(to_string、concat、slice、lengthユーティリティネイティブとf-string補間サーフェス)を引退させました。ランタイムは小さなバンドルセットを出荷しています。
keleusma::utility_natives::register_utility_nativesはprintln(no_stdターゲットでは無操作のデバッグプリントプリミティブ。出力が欲しいホストはregister_native_closureで上書き)を登録します。keleusma::audio_natives::register_audio_nativesはaudio::midi_to_freq、audio::freq_to_midi、audio::db_to_linear、audio::linear_to_db、および STANDARD_LIBRARY.md に列挙されたmath::*関数を登録します。keleusma::stddsl::Math、Audio、ShellはVm::register_libraryを通じて登録します(下の「標準DSLライブラリ」セクション参照)。
すべて内部的にregister_fnまたはregister_nativeを通じて登録します。ホストはバンドルされたネイティブすべてを登録することも、サブセットを登録することも、自分の実装で任意の関数を置き換えることもできます。
ホスト定義の文字列ヘルパー
言語は重い文字列操作に適した道具ではなく、V0.2.0は文字列標準ライブラリを出荷していません。アプリケーションが文脈の中で文字列作業を必要とする場合、Rustのネイティブ関数を登録し、スクリプトにuse宣言を通じて消費させてください。 Rustの標準ライブラリは、スクリプト内で構築するよりはるかにうまく、フォーマット、分割、正規表現、Unicode操作、エンコーディング変換を扱います。
#![allow(unused)]
fn main() {
vm.register_fn("text::upper", |s: String| -> String { s.to_uppercase() });
vm.register_fn("text::trim", |s: String| -> String { s.trim().to_string() });
vm.register_fn_fallible(
"text::split_first_word",
|s: String| -> Result<String, VmError> {
s.split_whitespace()
.next()
.map(|w| w.to_string())
.ok_or_else(|| VmError::NativeError("empty input".into()))
},
);
}
スクリプト側:
use text::upper
use text::trim
use text::split_first_word
fn greet(name: Text) -> Text {
text::upper(text::trim(name))
}
文字列に関するより広いフレーミングはFAQ.mdを参照してください。
標準DSLライブラリ
keleusma::stddslモジュールは、ホストが単一の呼び出しを通じて登録する3つのバンドルされたライブラリを出荷しています。各バンドルはLibraryトレイトを実装するユニット構造体です。トレイトのregisterメソッドがバンドルのネイティブ関数をVMに取り付けます。
#![allow(unused)]
fn main() {
use keleusma::stddsl;
let mut vm = Vm::new(module, &arena)?;
vm.register_library(stddsl::Math); // math::sqrt, math::floor, ...
vm.register_library(stddsl::Audio); // audio::midi_to_freq, ...
vm.register_library(stddsl::Shell); // shell::getenv, shell::run, shell::exit
}
stddsl::Mathとstddsl::Audioはfloats Cargo機能を必要とします。stddsl::Shellはshell機能を必要とし、これはstd依存を追加するのでno_stdビルドと互換性がありません。keleusma-cliクレートは両方の機能を有効にし、デフォルトで3つすべてのバンドルを登録します。バンドルされたテキスト合成が欲しいホストは、ホスト側のformat / to_string / concatネイティブをregister_verified_nativeを通じて登録するか(上の「ホスト定義の文字列ヘルパー」セクション参照)、自分のLibraryバンドルを実装します。
自分の再利用可能バンドルを出荷したいホストは、ホスト側の型にLibraryトレイトを実装します。トレイトが拡張性サーフェスで、バンドルされたライブラリはパターンの例であって閉じた集合ではありません。
#![allow(unused)]
fn main() {
use keleusma::stddsl::Library;
use keleusma::vm::Vm;
pub struct MyDsl;
impl Library for MyDsl {
fn register<'a, 'arena>(self, vm: &mut Vm<'a, 'arena>) {
vm.register_fn("mydsl::greet", |name: i64| -> i64 { name + 1 });
// ... さらにネイティブを登録 ...
}
}
// 使用箇所:
vm.register_library(MyDsl);
}
単一ファイルスクリプト
Keleusmaスクリプトは必然的に単一ファイルです。言語の内側にはimportやmodの機構はありません。スクリプト間の再利用は意図的にV0.2サーフェスの外です。アプリケーションのニーズがモジュール化を欲しがるところまで成長したら、推奨される経路はカスタムDSLライブラリを作ることです。スクリプトが呼ぶネイティブを登録するLibraryをホスト側のユニット構造体に実装して、各スクリプトにuse宣言を通じて同じ語彙を消費させます。再利用の単位はホスト側のライブラリで、スクリプトではありません。
不透明ホスト型
ホストがRust値を、その内部構造を明かさずにスクリプトに公開する必要がある場合、V0.2.0で導入されたHostOpaqueトレイトを使います。ホストは具体的なRust型に対してトレイトを実装します。スクリプトは関数シグネチャで型を名前で宣言し、型検査器は名前をType::Opaqueとして解決します。ネイティブ関数はhost_arcを通じて不透明値を生み、dyn HostOpaque::downcast_refを通じて型付き参照を抽出して消費します。
#![allow(unused)]
fn main() {
use keleusma::{host_arc, HostOpaque, Value};
// 外部型に外部トレイトを実装するときのRustの孤児ルール違反を
// 避けるために必要なnewtype。
struct RustString(String);
impl HostOpaque for RustString {
fn type_name(&self) -> &'static str { "RustString" }
}
vm.register_native("make_string", |args| {
// Rustの値から不透明を構築する。
Ok(Value::Opaque(host_arc(RustString(String::from("hello")))))
});
vm.register_native("upper_case", |args| {
// 不透明を消費して、新しい不透明を返す。
let opaque = match &args[0] {
Value::Opaque(o) => o.clone(),
other => return Err(VmError::TypeError(format!(
"expected RustString, got {}", other.type_name()))),
};
let s = opaque.as_ref().downcast_ref::<RustString>().ok_or_else(|| {
VmError::TypeError(format!(
"expected RustString, got opaque {}", opaque.type_name()))
})?;
Ok(Value::Opaque(host_arc(RustString(s.0.to_uppercase()))))
});
}
スクリプト側:
use make_string
use upper_case
fn main() -> RustString {
let s = make_string();
upper_case(s)
}
不透明値はArcを通じてホスト管理されるので、アリーナとは独立したライフタイムを持ちます。対話の型でyield境界を越えることができ、アリーナリセットを跨いで持続します。ポインタの同一性が等価意味論です。2つの不透明値は、同じArc割り当てを共有するときのみ等しいと比較されます。
不透明値は、割り当てがホスト管理されているのでスクリプト側のWCMU境界にゼロを貢献します。メモリフットプリントが重要な重い作業に対しては、Vm::set_native_boundsを通じてネイティブごとの計上を付けるので、検証器は境界づけられたホスト貢献を見ます。
std::string::Stringをスクリプトに公開する完全なウォークスルーはexamples/opaque_rust_string.rsを参照してください。
WCETとWCMUの計上
ネイティブ関数呼び出しはWCETとWCMU解析に参加します。デフォルトでは、ネイティブ呼び出しはサイクルでゼロコスト、ヒープでゼロバイトと計上されます。健全な境界が必要なホストは、VM構築の前に(あるいは既に構築されたVMにはverify_resourcesを呼ぶ前に)ネイティブごとの境界を宣言します。
#![allow(unused)]
fn main() {
// vm.set_native_bounds(name, wcet_cycles, wcmu_bytes)
vm.set_native_bounds("math::sin", 25, 0)?;
vm.set_native_bounds("strings::upper", 100, 256)?;
}
境界はホストの誓いです。wcetはワーストケースのパイプライン化サイクル数、wcmu_bytesはワーストケースのアリーナヒープ割り当てです。検証器は独立した測定なしに宣言された値を受け入れます。ホストが正確性に責任を負います。通常は測定か、ネイティブ関数の境界解析を通じてです。完全なウォークスルーはexamples/wcmu_attestation.rsを参照してください。
CPUサイクルでのキャリブレートされたWCET
バンドルされたNOMINAL_COST_MODELは、1つのプラットフォーム上での相対的順序付けに適した、オペコードごとのパイプライン化サイクル推定値を返します。値は特定のホストCPUに対して測定されていません。データの移動に1、算術に2、除算に3、複合構築に5、関数呼び出しに10を割り当てます。デプロイメントターゲット用の実際のCPUサイクルでのWCETが欲しいホストは、keleusma-benchワークスペースメンバーが生成したMEASURED_COST_MODELを消費します。
配線はkeleusma-bench/measured_cost_models/からの測定モデル断片のinclude!と、それからWCET APIの_with_cost_modelヴァリアントへの呼び出しです。
#![allow(unused)]
fn main() {
include!(concat!(env!("CARGO_MANIFEST_DIR"),
"/keleusma-bench/measured_cost_models/aarch64_apple_darwin.rs"));
use keleusma::verify::wcet_stream_iteration_with_cost_model;
let cycles = wcet_stream_iteration_with_cost_model(chunk, &MEASURED_COST_MODEL)?;
}
クックブックのセクション測定コストモデルでのキャリブレートされたWCETがレシピのウォークスルーです。examples/measured_wcet.rsが最小の動作例です。keleusma-bench/measured_cost_models/README.mdが事前に生成された断片と新しいターゲット用のキャプチャワークフローをカタログしています。
アリーナのサイズ
アリーナはオペランドスタックを底に、動的文字列をトップに保持します。Stream-to-Reset反復中の合計バイトはWCMU解析により境界づけられています。ホストには3つの選択肢があります。
選択肢A. デフォルト容量を使う。 DEFAULT_ARENA_CAPACITYは64キロバイトです。ほとんどのスクリプトに十分です。
#![allow(unused)]
fn main() {
let arena = Arena::with_capacity(DEFAULT_ARENA_CAPACITY);
}
選択肢B. VM構築前にモジュールから容量を計算する。 関数auto_arena_capacity_forはモジュールを歩いて境界を返します。
#![allow(unused)]
fn main() {
let cap = keleusma::vm::auto_arena_capacity_for(&module, &[])?;
let arena = Arena::with_capacity(cap);
let vm = Vm::new(module, &arena)?;
}
空のスライス引数はネイティブごとのヒープ計上を表します。スクリプトがヒープ割り当てネイティブを呼ぶ場合は計上値を渡します。自動サイズパターンはexamples/wcmu_basic.rsを参照してください。
選択肢C. 静的バッファを提供する。 ヒープを持たない組み込みターゲットでは、アリーナを.bss内のホスト所有バッファから動かせます。
#![allow(unused)]
fn main() {
static mut ARENA_BUFFER: [u8; 16 * 1024] = [0; 16 * 1024];
let arena = unsafe {
Arena::from_static_buffer(core::ptr::addr_of_mut!(ARENA_BUFFER))
};
}
選んだ容量が解析されたWCMUより小さい場合、Vm::newはVmError::VerifyErrorを返します。エラーはコードが走る前に表面化されます。
エラー回復
callまたはresume中のエラーはErr(VmError)を返します。VMは自動的にリセットされません。揮発性の状態がオペランドスタックとアリーナに残っているかもしれません。2つの経路があります。
経路1. VMを捨てる。 ドロップして再構築します。それに対して新しいVMが構築されると、アリーナはリセットされます。
経路2. 回復して続ける。 Vm::reset_after_errorを呼んで、データセグメントを保持しながら揮発性状態をクリアします。
#![allow(unused)]
fn main() {
match vm.call(&[arg]) {
Ok(state) => handle_state(state),
Err(VmError::TypeError(msg)) => {
eprintln!("script error: {}", msg);
vm.reset_after_error();
}
Err(other) => return Err(other.into()),
}
}
データセグメント(宣言されている場合)はエラーイベントを跨いで持続します。エラーを生き残らなければならない状態を累積する長時間動くストリームは、ローカル束縛ではなくデータセグメントに依存すべきです。
エラーヴァリアント
VmErrorは実行時のエラー条件を列挙しています。
| ヴァリアント | 条件 |
|---|---|
StackUnderflow | popで空のオペランドスタック |
TypeError(msg) | オペランド型が操作と一致しない |
DivisionByZero | ゼロでの整数除算または剰余 |
IndexOutOfBounds(idx, len) | 配列またはタプルのインデックスが範囲外 |
FieldNotFound(struct, field) | フィールドを宣言していない構造体でのフィールドアクセス |
NoMatch(value) | matchアームまたは多頭関数の頭が一致しなかった |
NativeError(msg) | ネイティブ関数がErrを返した |
InvalidBytecode(msg) | 実行時にバイトコードの形が予期外 |
Trap(msg) | Trap命令でスクリプトが停止 |
VerifyError(msg) | 構築時に構造または資源境界検証が失敗 |
LoadError(msg) | load_bytes中にワイヤフォーマットフレーミングが失敗 |
VerifyErrorはスクリプトコードが実行される前に発火する唯一のヴァリアントです。他のヴァリアントは実行中に発火します。VerifyErrorの解釈はWHY_REJECTED.mdを参照してください。
ホットコードスワッピング
VMはloopスクリプトのリセット境界で読み込まれたモジュールを置き換えるサポートをします。ホストはVmState::Resetを観測したあとにVm::replace_moduleを呼んで、Vm::callで新しいモジュールのエントリポイントを始めます。シグネチャは新しいモジュールと、長さが新しいモジュールの宣言されたスキーマと一致しなければならない初期データセグメントベクタを取ります。
#![allow(unused)]
fn main() {
match vm.resume(input)? {
VmState::Reset => {
let new_module = recompile_or_load_new_version()?;
// データセグメントを再初期化する。長さは新しいモジュールの
// 宣言された`data`ブロックサイズと一致しなければならない。
// 適切に値を保持または移行する。
let initial_data = vec![Value::Int(0); new_module_data_slot_count];
vm.replace_module(new_module, initial_data)?;
// スワップはコルーチン状態をクリアする。新しいモジュールを
// `resume`ではなくエントリポイントから動かす。
vm.call(&[Value::Int(next_seed)])?;
}
other => { /* ... */ }
}
}
対話型、すなわち yield される型と resume される型は、スワップをまたいで安定していなければなりません。データセグメントは前方に持ち越すことも(現在の値を渡す)、新しいスキーマに再初期化することも、ホストの移行コードによって置き換えることもできます。ネイティブ関数の登録はモジュールではなく仮想マシン上に存在し、スワップをまたいで持続します。ホットスワップの完全な仕様については EXECUTION_MODEL.md を、実行可能なエンドツーエンドの実演については examples/piano_roll.rs を参照してください。
署名付きモジュール
任意のsignatures Cargo機能がコンパイルされたバイトコードのEd25519署名を有効にします。ソーススクリプトはエントリ関数にsigned修飾子で要求を宣言します(signed fn main、signed yield main、signed loop main)。コンパイラはフレーミングヘッダにFLAG_REQUIRES_SIGNATUREを発行します。ランタイムは、その署名が読み込み前にホストが追加する信頼マトリクスに対して検証されない限り、そのようなモジュールの読み込みを拒否します。
ビルド時の署名
ホスト(またはビルドパイプライン)は32バイトのEd25519シードを取り、wire_format::module_to_signed_wire_bytesを使って署名付きバイトを生みます。CLIはこれをkeleusma compile --signing-key seed.bin -o out.binを通じて公開しています。keleusma keygen --seed seed.bin --public pub.binサブコマンドはOS RNGから新しい鍵ペアを生成します。シードファイルはUnixで0o600権限で書かれ、既存のファイルは上書きされません。
#![allow(unused)]
fn main() {
let signing_key = ed25519_dalek::SigningKey::from_bytes(&seed_bytes);
let signed = keleusma::wire_format::module_to_signed_wire_bytes(&module, &signing_key)?;
std::fs::write("script.kel.bin", &signed)?;
}
検証と読み込み
ホストは署名付きモジュールをVm::load_signed_bytes(bytes, arena, &keys)を通じて読み込みます。鍵スライスは1つ以上の公開鍵を運びます。最初の一致する鍵がモジュールを許容します。空のスライスはすべての署名付きモジュールをLoadError::InvalidSignatureで却下します。マトリクスは構築されたVMにもコピーされるので、その後のVm::replace_module_from_bytes呼び出しは同じ鍵を継承します。
#![allow(unused)]
fn main() {
let pub_bytes: [u8; 32] = std::fs::read("pub.bin")?.try_into().unwrap();
let key = ed25519_dalek::VerifyingKey::from_bytes(&pub_bytes)?;
let mut vm = Vm::load_signed_bytes(&signed, &arena, &[key])?;
}
署名なしベースラインからブートストラップし、ホットスワップでだけ署名付きバイトコードを受け入れるホストは、通常通りVMを構築し、構築後に鍵を登録し、署名付きの更新をホットスワップします。
#![allow(unused)]
fn main() {
let mut vm = Vm::new(unsigned_baseline_module, &arena)?;
vm.register_verifying_key(signer_key);
// ... 後で、通信リンク上で署名付き更新を受け取ったあと ...
vm.replace_module_from_bytes(&update_bytes, initial_data)?;
}
Vm::load_bytesは署名付きモジュールを、正しいエントリポイントVm::load_signed_bytesを指す診断で拒否します。signatures機能なしでは、返されるヴァリアントはLoadError::SignaturesUnsupportedなので、運用者はビルドが検証できないこと(経路が間違っているだけでなく)を見ます。
署名メッセージ慣習は、署名ペイロードバイトとCRCトレーラバイトがゼロ化された、完全なフレーム付きバッファです。検証器は、暗号操作の前に自分のプライベートコピーで両方の領域をゼロ化することで、同じビューを再構築します。CRCトレーラは実署名を含むファイル全体をカバーするので、フレーミングレベルの改ざんはCRCだけで検出され、署名の変異はCRC修復後の暗号チェックで検出されます。
設計上の根拠については docs/decisions/RESOLVED.md の R42 を、ヘッダのレイアウトについては docs/spec/WIRE_FORMAT.md を参照してください。
トラストスキップ構築
検証コストをビルド時に払って毎回の読み込みでは払わないプログラムは、Vm::new_uncheckedを使って資源境界チェックを飛ばせます。構造検証は依然として走ります。
#![allow(unused)]
fn main() {
let vm = unsafe { Vm::new_unchecked(module, &arena) };
}
これは、安全検証器を通らないプログラムを許容するために用いた場合、意図的な誤用となります。意図された用途は、ホストがビルド時に一度検証済みのプリコンパイルされたバイトコードです。契約については LANGUAGE_DESIGN.md を参照してください。Vm::new_unchecked は署名付きモジュールフラグのチェックも省略します。呼び出し側は、いかなる署名検証もビルド時に実施済みであることを表明します。
相互参照
examples/wcmu_basic.rsが自動サイズパターンを最初から最後まで示します。examples/wcmu_attestation.rsがネイティブ境界宣言を示します。examples/wcmu_rejection.rsが検証器がサイズ不足のアリーナを却下する様子を示します。examples/string_ops.rsがホスト登録のテキスト連結とスライスネイティブを示します。examples/yield_error.rsがスクリプト定義のResult形状の列挙型でyieldを通じたエラー伝搬を示します。examples/method_call.rsがレシーバスタイル構文でのメソッドディスパッチを示します。examples/piano_roll.rsは機能ゲート付きのエンドツーエンドSDL3オーディオホストです。実時間オーディオ締切下での有界ステップ実行、Keleusmaメインスレッドと SDL3オーディオコールバックの間のスレッドセーフな受け渡し、データセグメントを通じたマルチヴォイス制御フロー、事前コンパイルされた楽曲一覧を跨ぐホットコードスワップ(piano_roll_<N>.kel、現在はpiano_roll_0.kel、piano_roll_1.kel、piano_roll_2.kel)を練習します。cargo run --release --example piano_roll --features sdl3-exampleで実行します。sで次の楽曲に切り替え、rで現在の楽曲を再開、数字で楽曲をインデックスで選択、Enterだけで終了します。長文マニュアルはPIANO_ROLL.mdで、楽曲の作成、ホストループを別のアプリケーションに持ち上げること、例を他の制御ループ領域でKeleusmaを組み込むアーキテクチャ参照として使うことを扱います。- LANGUAGE_DESIGN.md は言語モデルを説明します。
- EXECUTION_MODEL.md はランタイムモデルを説明します。
- WHY_REJECTED.mdは検証器の却下カテゴリを説明します。
命令セット
This chapter is the bytecode reference. It lists every instruction the Keleusma virtual machine executes, so the disassembly shown in the playground bytecode view has a place to be looked up. It reproduces the authoritative docs/spec/INSTRUCTION_SET.md from the repository.
Keleusma 仮想マシンは、ブロック構造の制御フローを用いてスタックベースのバイトコードを実行します。すべての命令は値スタック上で動作します。本文書は、すべての命令を、そのオペランド、動作、および WCET(最悪実行時間)と WCMU(最悪メモリ使用量)の解析へのコスト寄与とともに一覧します。
各命令は相対的な整数コストを持ちます。コストは単位のない相対的な重みであり、サイクル数ではありません。値が大きいほど高価な演算を示します。コスト表は wcet_stream_iteration() によって参照され、命令ごとのスタックおよびヒープへの影響は wcmu_stream_iteration() によって参照されます。
バイトコードがソースからどのように生成されるかの詳細については、第25章「ソースからバイトコードへ」を参照してください。フレーミングヘッダ、オペコードストリームのエンコード、およびオペランドプールを含むバイトコードのワイヤフォーマットについては、EXECUTION_MODEL.md を参照してください。ブロック階層および検証規則を含む構造的 ISA 仕様については、STRUCTURAL_ISA.md を参照してください。
定数
| 命令 | オペランド | コスト | 説明 |
|---|---|---|---|
| Const | u16 index | 1 | チャンクの定数プールから定数をプッシュします。 |
| PushImmediate | u8 value | 1 | インラインの即値をプッシュします。オペランドは、少数の番兵値または小さな整数のいずれかをエンコードします。後述の「PushImmediate encoding」を参照してください。 |
PushImmediate エンコード
| オペランド値 | 意味 |
|---|---|
0 | Value::Unit |
1 | Value::Bool(true) |
2 | Value::Bool(false) |
3 | Value::None(Option::None の番兵) |
4 | Value::Int(0) |
5 | Value::Int(1) |
| … | … |
19 | Value::Int(15) |
20..255 | 予約済み。デコーダは破損の信号として扱います。 |
十六個の小さな整数リテラル(Int(0) から Int(15))がインラインでエンコードされます。より大きなリテラルや即値でないリテラルは、引き続き定数プールを参照する Const を使用します。抽出規則は予測可能です。オペランド値 0..3 は番兵を選択し、値 4..19 は Int(value - 4) を選択し、値 20..255 は破損を示します。
ローカル変数
| 命令 | オペランド | コスト | 説明 |
|---|---|---|---|
| GetLocal | u16 slot | 1 | ローカル変数をスタックにプッシュします。 |
| SetLocal | u16 slot | 1 | スタックをポップしてローカル変数スロットに格納します。 |
データセグメント
統一されたスロットインデックス空間は、共有スロット [0, shared_count) とプライベートスロット [shared_count, shared_count + private_count) に分割されます。共有スロットは各呼び出し時に借用されるホスト所有のバッファに存在し、ホストは Vm::get_shared/Vm::set_shared(B28 項目 2)を通じて到達します。プライベートスロットはスクリプト専用であり、アリーナの永続領域に存在します。以下のオペコードは両方の区画を許容します。ランタイムはスロットインデックスをキャッシュされた shared_slot_count と比較して振り分け、共有スロットはバイトオフセットで借用バッファに、プライベートスロットはアリーナに送ります。const データフィールドはスロットを消費しません。フィールド読み出しは Const にコンパイルされ、書き込みはコンパイルエラーとなります。
| 命令 | オペランド | コスト | 説明 |
|---|---|---|---|
| GetData | u16 slot | 1 | データセグメントのスロット値をスタックにプッシュします。 |
| SetData | u16 slot | 1 | 値をポップしてデータセグメントのスロットに格納します。スカラーはインラインで格納されます。フラットな複合体は、モジュールのプライベート複合体レイアウトテーブルがそのスロットに対して記録するオフセットで、その本体を永続複合体プールにコピーします(専用の複合体書き込みオペコードはありません)。 |
| GetDataIndexed | u16 base, u16 len | 2 | 配列インデックスをポップし、len に対して境界チェックを行い、base + index の値をプッシュします。 |
| SetDataIndexed | u16 base, u16 len | 2 | 配列インデックスをポップし、次に値をポップし、len に対して境界チェックを行い、base + index のスロットに格納します。 |
| BoundsCheck | u16 bound | 2 | スタックの先頭を Int として覗き見し、[0, bound) の範囲外であればトラップします。多次元インデックスアクセスのレベル間でコンパイラによって発行されます。 |
算術
整数算術はチェック付き算術ファミリを使用します。CheckedAdd、CheckedSub、CheckedMul、CheckedNeg の各オペコードは Value::Int オペランドをポップし(二項形式では二つ、CheckedNeg では一つ)、真の結果を i128 で計算し、三つのスロット、すなわち下位半分、上位半分、および結果フラグ(Int(0) は正常、Int(1) はオーバーフロー、Int(2) はアンダーフロー)をプッシュします。プッシュ順序は low を最下部に、flag を最上部に配置するため、Int オペランドに対する a + b のような表層のラッピング式は、チェック付きオペコードに続けて PopN(2) にコンパイルされ、ラッピング結果をスタックに残します。ソースレベルのパターンアームによるマッチングは、この三つの出力を分解します。
ラッピング算術オペコード Add、Sub、Mul、Neg は命令セットに残っていますが、もはや Value::Int オペランドを受け付けません。許可されるオペランド型は Byte、Fixed、Float です。V0.2.0 の Consolidation B パスは、すべての Int 算術をチェック付きファミリに経由させることでこれらのオペコードを狭めました。コンパイラはすべての Int オペランド位置に対して CheckedXxx; PopN(2) を発行します。コンパイラが型を静的に推論できないオペランドも同様に Int パスに落ちます。なぜなら Word がデフォルトの数値型だからです。
Op::Div および Op::Mod は Int、Byte、Float に対して多相のままです。そのチェック付き対応物である CheckedDiv および CheckedMod は、符号付き除算のコーナーケースを露出します。
CheckedMul および CheckedDiv は、整数または Q 形式の算術を選択する u8 の小数ビット数を持ちます。ここで 0 は整数であり、正の数は Fixed です。これは整数データパスと固定小数点データパスが異なる唯一の箇所であり、すなわち乗算または除算の前後での小数ビット数によるシフトです。そのため、別々のオペコードではなく単一のパラメータ化されたオペコードが両方を担います。CheckedAdd、CheckedSub、CheckedMod、CheckedNeg は、その Fixed 形式がシフトを伴わず、オペランド型のみで振り分けられるため、このようなパラメータを必要としません。
| 命令 | オペランド | コスト | 説明 |
|---|---|---|---|
| CheckedAdd | なし | 2 | 二つの Int オペランドをポップし、(low, high, flag) をプッシュします。flag および high の半分は、共有の vm::checked_arith_outputs ヘルパーを通じて、バイトコードで宣言されたワード幅で報告されます。 |
| CheckedSub | なし | 2 | 二つの Int オペランドをポップし、(low, high, flag) をプッシュします。 |
| CheckedMul | u8 frac_bits | 2 | 二つのオペランドをポップし、(low, high, flag) をプッシュします。小数ビット数が形式を選択します。0 の場合は整数乗算であり、上位半分が大きな数の乗算における載荷値となります。0 より大きい数の場合、オペランドは Fixed であり、i128 の積は範囲チェックの前にそのビット数だけ右シフトされ、ラップされた結果は単一のワードであり、上位スロットは未使用です。したがって小数ビットが 0 の場合はちょうど整数乗算です。 |
| CheckedNeg | なし | 2 | 一つの Int オペランドをポップし、(low, high, flag) をプッシュします。デフォルトの 64 ビット宣言幅における唯一のオーバーフローケースは -i64::MIN です。 |
| CheckedDiv | u8 frac_bits | 2 | 二つのオペランドをポップし、(low, high, flag) をプッシュします。小数ビット数が形式を選択します。0 の場合は整数除算であり、デフォルトの 64 ビット幅における唯一のオーバーフローケースは i64::MIN / -1 です。0 より大きい数の場合、オペランドは Fixed であり、被除数は除算の前に i128 の領域でそのビット数だけ左シフトされます。ゼロ除数はトラップするのではなく、分子を運ぶフラグ 3 として具現化されます。したがって小数ビットが 0 の場合はちょうど整数除算です。 |
| CheckedMod | なし | 2 | 二つの Int オペランドをポップし、(low, high, flag) をプッシュします。ゼロ除算でトラップします。デフォルトの 64 ビット宣言幅における唯一のオーバーフローケースは i64::MIN % -1 です。 |
| Add | なし | 2 | Byte、Fixed、Float 型の二つのオペランドをポップし、ラッピングまたは IEEE 754 の和をプッシュします。Int オペランド位置は除外されます。コンパイラは Int + Int を CheckedAdd; PopN(2) に経由させます。 |
| Sub | なし | 2 | Byte、Fixed、Float 型の二つのオペランドをポップし、ラッピングまたは IEEE 754 の差をプッシュします。Int オペランド位置は除外されます。 |
| Mul | なし | 2 | Byte または Float 型の二つのオペランドをポップし、ラッピングまたは IEEE 754 の積をプッシュします。Fixed の乗算は FixedMul(n) を使用します。Int オペランド位置は除外されます。 |
| Neg | なし | 2 | Byte、Fixed、Float 型の一つのオペランドをポップし、ラッピングまたは IEEE 754 の否定をプッシュします。Int オペランド位置は除外されます。 |
| Div | なし | 3 | 二つの値をポップし、商をプッシュします。ゼロ除算でトラップします。オーバーフローフラグはありません。 |
| Mod | なし | 3 | 二つの値をポップし、剰余をプッシュします。ゼロ除算でトラップします。オーバーフローフラグはありません。 |
比較
| 命令 | オペランド | コスト | 説明 |
|---|---|---|---|
| CmpEq | なし | 2 | 二つの値をポップし、等しければ true をプッシュします。 |
| CmpNe | なし | 2 | 二つの値をポップし、等しくなければ true をプッシュします。 |
| CmpLt | なし | 2 | 二つの値をポップし、より小さければ true をプッシュします。 |
| CmpGt | なし | 2 | 二つの値をポップし、より大きければ true をプッシュします。 |
| CmpLe | なし | 2 | 二つの値をポップし、以下であれば true をプッシュします。 |
| CmpGe | なし | 2 | 2つの値をポップし、以上であれば true をプッシュします。 |
論理
| 命令 | オペランド | コスト | 説明 |
|---|---|---|---|
| Not | なし | 1 | boolean をポップし、論理否定をプッシュします。 |
短絡評価の AND と OR はバイトコードレベルでは If 分岐としてエンコードされます。LogicalAnd や LogicalOr のオペコードは存在しません。
ビット演算
| 命令 | オペランド | コスト | 説明 |
|---|---|---|---|
| BitAnd | なし | 2 | 2つの Int オペランドをポップし、ビット単位の AND をプッシュします。 |
| BitOr | なし | 2 | 2つの Int オペランドをポップし、ビット単位の OR をプッシュします。 |
| BitXor | なし | 2 | 2つの Int オペランドをポップし、ビット単位の XOR をプッシュします。 |
| Shl | なし | 2 | シフト数を、続いて値をポップします。値を count & (word_width - 1) だけ左シフトしてプッシュします。 |
| Shr | なし | 2 | シフト数を、続いて値をポップします。算術右シフトした値(符号を保持)をプッシュします。 |
制御フロー
ブロック構造の制御フローオペコードは u16 のジャンプ先を保持します。したがってチャンクのオペコード数は 65,535(CHUNK_SIZE_HARD_LIMIT)に上限が定められています。コンパイラは単一のチャンクが上限の 80%(52,428 命令、CHUNK_SIZE_SOFT_WARN_THRESHOLD)を超えたときに CompileWarning を発行し、ヘルパー関数への分解を促します。上限におけるバイトコードは有効なままです。CHUNK_SIZE_HARD_LIMIT を超えるチャンクはコンパイル時に CompileError として拒否されます。ホストは keleusma::compiler::compile_with_warnings(program, target) を呼び出すことで、モジュールとともに警告ベクトルを受け取ります。compile_with_target と compile は、警告を必要としない呼び出し元のために警告を破棄します。
| 命令 | オペランド | コスト | 説明 |
|---|---|---|---|
| If | u16 offset | 1 | boolean をポップします。false であれば、対応する Else または EndIf まで前方にスキップします。 |
| Else | u16 offset | 1 | 対応する EndIf まで無条件に前方へスキップします。 |
| EndIf | なし | 1 | if または if-else ブロックの終端です。No-op です。 |
| Loop | u16 offset | 1 | loop ブロックの開始です。オフセットは対応する EndLoop までの距離です。 |
| EndLoop | u16 offset | 1 | 対応する Loop へ無条件に後方ジャンプします。 |
| Break | u16 depth | 1 | 指定されたネスト深度にある外側の loop を抜けます。 |
| BreakIf | u16 depth | 1 | boolean をポップします。true であれば、指定されたネスト深度にある外側の loop を抜けます。 |
関数呼び出し
ネイティブ関数呼び出しは、ソースレベルの use 宣言と、それに対応するホスト側の登録 ABI によって区別される2つのクラスに分かれます。
- 検証済みネイティブ。
use module::nameでインポートされます。ホストはVm::register_verified_native(name, fn, wcet_bound, wcmu_bound)を通じて登録します。ホストが証明したコストは、イテレーションの WCET および WCMU の予算に組み込まれます。コンパイラはCallVerifiedNativeを発行します。 - 外部ネイティブ。
use external module::nameでインポートされます。ホストはVm::register_external_native(name, fn, max_invocations_per_iteration)を通じて登録します。ホストは呼び出しごとのコストではなく、イテレーションごとの呼び出し回数の上限を証明します。コンパイラはCallExternalNativeを発行します。
ランタイムは Vm::call_function の入口と、Vm::verify_native_classifications の明示的な呼び出しのたびに、宣言された各ネイティブをそのホスト登録に対して相互検証します。この検証はモジュール内のすべてのネイティブ呼び出し箇所を走査し、不一致(例えば use math::sqrt をインポートしているバイトコードに対し、ホストが register_external_native を通じて sqrt を登録している場合)を VmError::VerifyError として拒否します。結果は最初の走査に成功した後にキャッシュされます。いかなる register_* 呼び出しや replace_module 呼び出しもキャッシュを無効化します。不一致を最初の呼び出し時ではなくデプロイ検証の段階で明らかにしたいホストは、登録後に verify_native_classifications を明示的に呼び出すことができます。
V0.2.0 のフェーズ5で、検証済みと外部の分割が導入されました。従来の Op::CallNative オペコードは廃止されました。すべてのネイティブ呼び出し箇所は Op::CallVerifiedNative または Op::CallExternalNative のいずれかにコンパイルされます。以前 Vm::register_native を呼び出していたホストは、このメソッドが検証済み分類を付与するため、引き続き検証済みネイティブを登録します。
外部ネイティブに対する max_invocations_per_iteration の証明は登録時に記録されますが、まだ WCMU の境界には組み込まれていません。検証器は外部ネイティブがスクリプトのイテレーションごとの WCMU 予算にゼロを寄与するものとして扱います。ホストは外部ネイティブがスクリプトのリソース契約の外にあることを受け入れ、それらのリソース使用を別途検証しています。チャンクレベルの統合(外部ネイティブのコストを静的な呼び出し箇所ごとではなく、チャンクごとに max_invocations_per_iteration * per_call_wcmu として境界づけるもの)は、B20 の下で追跡される将来の作業です。
| 命令 | オペランド | コスト | 説明 |
|---|---|---|---|
| Call | u16 chunk_idx, u8 argc | 10 | インデックスによってコンパイル済みチャンクを argc 個の引数で直接呼び出します。 |
| CallVerifiedNative | u16 native_idx, u8 argc | 10 | 検証済みネイティブ関数を呼び出します。コストはホストの証明に従ってイテレーション予算に組み込まれます。 |
| CallExternalNative | u16 native_idx, u8 argc | 10 | 外部ネイティブ関数を呼び出します。イテレーションのコスト予算は呼び出しの間は一時停止します。検証器はイテレーションごとの呼び出し回数を追跡します。 |
| Return | なし | 2 | 現在のチャンクから戻ります。 |
クロージャ構築および間接ディスパッチのオペコード(PushFunc、MakeClosure、MakeRecursiveClosure、CallIndirect)は ISA に存在しません。クロージャ形状の表層式は、その構文を名指しする診断とともに型検査の段階で拒否されます。第一級関数値も同様に拒否されます。Value::Func ランタイムバリアントは V0.2.0 のフェーズ4でオペコードとともに廃止されました。以前クロージャを使用していた表層プログラムは、トップレベルの fn 定義またはトレイトメソッドとして書き直す必要があります。
コルーチンとストリーミング
| 命令 | オペランド | コスト | 説明 |
|---|---|---|---|
| Yield | なし | 1 | 出力値をポップして中断します。再開時には、ホストの入力値がスタックにプッシュされます。 |
| Stream | なし | 1 | ストリーミング領域の入口です。Reset のみがこれを対象にできます。 |
| Reset | なし | 1 | アリーナのトップ領域をクリアし、スケジュールされていればホットスワップを有効化し、対応する Stream へジャンプします。 |
スタック
| 命令 | オペランド | コスト | 説明 |
|---|---|---|---|
| PopN | u8 count | 1 | スタックの先頭から count 個の値を破棄します。コンパイラは単一スロットのポップに対して PopN(1) を、複数スロットの破棄に対して PopN(n) を発行します。これには、ラッピングセマンティクスが望まれる場合のチェック付き算術オペコード後の3スロット破棄も含まれます。 |
| Dup | なし | 1 | スタックの先頭を複製します。 |
型構築
| 命令 | オペランド | コスト | 説明 |
|---|---|---|---|
| NewComposite | kind, count, byte_size または meta | 5 | count 個の値をポップし、指定された種類(struct、tuple、array、または enum)の複合体を1つ構築します。フラット形式はポップした値を byte_size バイトにパックします。ボックス形式はテンプレートインデックス meta からヒープ複合体を構築します。enum の先頭の判別子は count 個の値のうちの1つとして数えられます。この単一オペコードは、V0.2.0 の4つの構築オペコード(wire id 34〜37、廃止済み)を置き換えます。 |
tuple は無名の struct、array は同種の struct、フラットな enum は最初にパックされる値が判別子である struct であるため、フラット構築は4つの種類すべてにわたって1つの操作となります。オペランドは複合体の種類、オペランドスタックのポップ数、およびバイト単位の正確なフラット割り当てサイズ(フラット形式)または struct テンプレートインデックス(ボックス形式)のいずれかを保持します。フラットバイトサイズは、検証器が合計する正確な最悪メモリ使用量の寄与分です。下記のヒープ割り当ての表を参照してください。
Option::None センチネルと Option::Some ラップは、それぞれ PushImmediate(3) とラップされた値の自然な表現を通じて処理されます。専用の PushNone や WrapSome のオペコードは存在しません。
フィールドアクセス
| 命令 | オペランド | コスト | 説明 |
|---|---|---|---|
| GetField | StructField(フラット/ネスト/ボックス) | 3 | struct をポップし、フィールド値をプッシュします。ベイクされた StructField オペランドは、バイトオフセットでのフラット読み取り、ネスト複合体の抽出、またはボックス化された位置指定/名前指定の検索を選択します。 |
| GetIndex | ArrayElem(フラット/ネスト/ボックス) | 2 | インデックスと array をポップし、要素をプッシュします。ベイクされた ArrayElem オペランドは、index * element_size でのフラット読み取り、ネスト複合体の抽出、またはボックス化されたインデックスを選択します。 |
| GetTupleField | TupleField(フラット/ネスト/ボックス) | 2 | tuple をポップし、要素をプッシュします。ベイクされた TupleField オペランドは、バイトオフセットでのフラット読み取り、ネスト複合体の抽出、またはボックス化された位置指定インデックスを選択します。 |
| GetEnumField | EnumField(フラット/ネスト/ボックス) | 2 | enum バリアントをポップし、ペイロードフィールドをプッシュします。ベイクされた EnumField オペランドは、判別子ワードを越えたフラット読み取り、ネスト複合体の抽出、またはボックス化された位置指定インデックスを選択します。 |
| Len | なし | 2 | 複合体値(Array、Text、Tuple)をポップし、長さを Int としてプッシュします。 |
型テスト
| 命令 | オペランド | コスト | 説明 |
|---|---|---|---|
| IsEnum | u16 enum-name, u16 variant-name, u16 discriminant-value(すべて定数インデックス) | 3 | スタックの先頭を覗き見します。enum 型とバリアントに一致すれば true をプッシュします。 |
| IsStruct | u16 name | 3 | スタックの先頭を覗き見します。struct 型に一致すれば true をプッシュします。 |
キャストと固定小数点演算
| 命令 | オペランド | コスト | 説明 |
|---|---|---|---|
| IntToFloat | なし | 2 | Word をポップし、Float としてプッシュします。floats 機能でゲートされます。 |
| FloatToInt | なし | 2 | Float をポップし、Word としてプッシュします(ゼロ方向へ切り捨て)。floats 機能でゲートされます。 |
| WordToByte | なし | 2 | Word をポップし、下位8ビットを Byte としてプッシュします。 |
| ByteToWord | なし | 2 | Byte をポップし、Word にゼロ拡張します。 |
| WordToFixed | u8 frac_bits | 2 | Word をポップし、指定された小数ビット数を持つ対応する Q フォーマットの Fixed 値をプッシュします。 |
| FixedToWord | u8 frac_bits | 2 | Fixed をポップし、整数部分を Word としてプッシュします。飽和します。 |
| FixedMul | u8 frac_bits | 2 | 2つの Q フォーマットの Fixed 値をポップし、その積をプッシュします。i128 の積を小数ビット数だけ右シフトし、飽和します。 |
| FixedDiv | u8 frac_bits | 2 | 2つの Q フォーマットの Fixed 値をポップし、その商をプッシュします。除算の前に被除数を小数ビット数だけ左シフトし、飽和します。 |
フォールト
| 命令 | オペランド | コスト | 説明 |
|---|---|---|---|
| Trap | u16 message index | 1 | 定数プールからのエラーメッセージとともに実行を停止します。 |
オペコード数とオペランド形状の一覧
この命令セットには66個のオペコードが含まれます。B28 の統合により、V0.2.0 の4つの構築オペコード(NewStruct、NewEnum、NewArray、NewTuple、wire id 34〜37)は廃止され、単一の NewComposite オペコード(wire id 69)に置き換えられました。廃止された id は予約され、再利用されません。有効な最大 wire id は69です。オペランド形状:
以下の形状は src/bytecode.rs で定義されている Op バリアントのオペランド型であり、WIRE_FORMAT.md に文書化されているフラット化されたインラインのワイヤエンコーディングとは異なります。特に、4つのベイクされたフィールドおよび要素アクセスオペコードは、単純な整数ではなく専用のオペランド構造体を保持します。
| 形状 | 使用箇所 |
|---|---|
| なし(ゼロオペランド) | 33 個のオペコード(算術、比較、ビット演算、型強制、スタック操作、ストリーミング、コルーチンなど) |
u8 | 8 個のオペコード(PushImmediate、PopN、WordToFixed、FixedToWord、FixedMul、FixedDiv、CheckedMul、CheckedDiv) |
u16 | 14 個のオペコード(Const、GetLocal、SetLocal、GetData、SetData、IsStruct、If、Else、Loop、EndLoop、Break、BreakIf、BoundsCheck、Trap) |
(u16, u8) | 3 個のオペコード(Call、CallVerifiedNative、CallExternalNative) |
(u16, u16) | 2 個のオペコード(GetDataIndexed、SetDataIndexed) |
(u16, u16, u16) | 1 個のオペコード(IsEnum。列挙型名、バリアント名、および判別子値の定数インデックスを伴います) |
| ベイクされたアクセスオペランド(専用) | 4 個のオペコード(GetField(StructField)、GetIndex(ArrayElem)、GetTupleField(TupleField)、GetEnumField(EnumField))。それぞれがコンパイラによって選択された Flat、FlatNested、または Boxed 形式を持つ専用の B28 オペランド構造体を伴います。フラット形式はバイトオフセットとスカラーの kind(あるいはネストされた複合体の size と variant)をベイクします。ボックス化形式は B28 以前の Vec ボディ用に位置インデックスまたはフィールド名定数インデックスを保持します。src/bytecode.rs の StructField、ArrayElem、TupleField、および EnumField 列挙型を参照してください。 |
| NewComposite(専用) | 1 個のオペコード(NewComposite)。フラット形式は複合体の種別、オペランドスタックのポップ数(0 から 62)、および正確なフラットバイトサイズをレコードの 3 つのオペランドバイトにパックします。ボックス化形式、またはフラットフィールド数が 62 を超える場合は、24 ビットのオペランドプールインデックスを (count, byte_size-or-meta, boxed_flag) を保持する (u16, u16, u8) エントリにスピルします。 |
ワイヤーエンコーディングはこれらの Op レベルの形状をフラット化します。66 個のオペコードのうち 62 個は、常に 4 バイトのオペコードレコード内にオペランドをインラインで保持します。3 個のオペコード(GetDataIndexed、SetDataIndexed、および IsEnum)は、常にインデックスによってオペランドプール内のエントリを参照します。NewComposite は共通の小さなフラット形式ではオペランドをインラインで保持し、ボックス化形式またはフラットフィールド数が 62 を超える場合にのみ (u16, u16, u8) オペランドプールエントリを参照します。(u16, u8) オペコード(Call、CallVerifiedNative、CallExternalNative)は、u8 がレコードのバイト 3 に収まるためインラインに収まります。4 個のベイクされたアクセスオペコードは、コンパイラによって選択されたオペランド形式をインラインに収めます。これらの形状をエンコードするワイヤーフォーマットについては、EXECUTION_MODEL.md および WIRE_FORMAT.md を参照してください。
コストの概要
コストのグルーピングは bytecode::nominal_op_cycles を再現します。実時間の WCET を必要とするホストは、ターゲットに合わせてキャリブレーションされたカスタムの CostModel を提供します。
| コスト | 命令 |
|---|---|
| 1 | Const, PushImmediate, GetLocal, SetLocal, GetData, SetData, Dup, Not, If, Else, EndIf, Loop, EndLoop, Break, BreakIf, Stream, Reset, Yield, Trap, PopN |
| 2 | Add, Sub, Mul, Neg, CheckedAdd, CheckedSub, CheckedMul, CheckedNeg, CheckedDiv, CheckedMod, CmpEq, CmpNe, CmpLt, CmpGt, CmpLe, CmpGe, GetIndex, GetTupleField, GetEnumField, Len, IntToFloat, FloatToInt, WordToByte, ByteToWord, WordToFixed, FixedToWord, FixedMul, FixedDiv, Return, GetDataIndexed, SetDataIndexed, BoundsCheck, BitAnd, BitOr, BitXor, Shl, Shr |
| 3 | Div, Mod, GetField, IsEnum, IsStruct |
| 5 | NewComposite |
| 10 | Call, CallVerifiedNative, CallExternalNative |
WCMU への寄与
WCMU コストは、スタックスロットの増加、スタックスロットの縮小、およびバイト単位のヒープ割り当てとして別々に報告されます。定数 VALUE_SLOT_SIZE_BYTES はスロット数をバイトに変換します。パラメトリックな Vm<W, A, F> 形状は size_of::<GenericValue<W, F>>() を直接使用します。src/verify.rs の wcmu_stream_iteration() によって計算されます。
スタックの増加(実行中のピーク正味デルタ)
これらの値は src/bytecode.rs の Op::stack_growth を再現します。複数出力のオペコードについては、値は生のプッシュ数ではなく、開始深度に対する正味のピークデルタです。例えば CheckedAdd は 2 つをポップして 3 つをプッシュするため、開始に対する相対的なピーク深度は +1 です。IsEnum と IsStruct は被検査値をピーク(ポップなし)して Bool をプッシュするため、正味デルタは +1 です。
| 増加 | 命令 |
|---|---|
| 0 | Not, Neg, Add, Sub, Mul, Div, Mod, CmpEq, CmpNe, CmpLt, CmpGt, CmpLe, CmpGe, SetLocal, SetData, SetDataIndexed, BoundsCheck, If, BreakIf, Else, EndIf, Loop, EndLoop, Break, Stream, Reset, Yield, Return, GetField, GetIndex, GetTupleField, GetEnumField, Len, IntToFloat, FloatToInt, WordToByte, ByteToWord, WordToFixed, FixedToWord, FixedMul, FixedDiv, Trap, PopN, BitAnd, BitOr, BitXor, Shl, Shr |
| 1 | Const, PushImmediate, GetLocal, GetData, Dup, GetDataIndexed, CheckedAdd, CheckedSub, CheckedMul, CheckedDiv, CheckedMod, Call, CallVerifiedNative, CallExternalNative, NewComposite, IsEnum, IsStruct |
| 2 | CheckedNeg |
スタックの縮小(実行中にポップされたスロット)
これらの値は Op::stack_shrink を再現します。正味デルタが非負であるオペコード(例えば CheckedAdd、CheckedNeg)については、検証器が stack_growth を通じてピークを計上し、正味のポップが存在しないため、縮小はゼロになります。
| 縮小 | 命令 |
|---|---|
| 0 | Const, PushImmediate, GetLocal, GetData, Dup, Not, Neg, CheckedAdd, CheckedSub, CheckedMul, CheckedNeg, CheckedDiv, CheckedMod, BoundsCheck, Else, EndIf, Loop, EndLoop, Break, Stream, Reset, Return, Len, IsEnum, IsStruct, IntToFloat, FloatToInt, WordToByte, ByteToWord, WordToFixed, FixedToWord, FixedMul, FixedDiv, Trap |
| 1 | Add, Sub, Mul, Div, Mod, CmpEq, CmpNe, CmpLt, CmpGt, CmpLe, CmpGe, SetLocal, SetData, GetDataIndexed, If, BreakIf, Yield, GetField, GetIndex, GetTupleField, GetEnumField, BitAnd, BitOr, BitXor, Shl, Shr |
| 2 | SetDataIndexed |
| n | PopN(n), Call(, n), CallVerifiedNative(, n), CallExternalNative(_, n), NewComposite(count) |
ヒープ割り当て(バイト)
| ヒープ | 命令 |
|---|---|
| 0 | 以下に列挙されていないすべての命令 |
オペランドからの byte_size | NewComposite、フラット形式。正確なフラット割り当てサイズはコンパイル時にオペランドにベイクされるため、最悪メモリ使用量への寄与は count * VALUE_SLOT_SIZE_BYTES の推定値ではなく正確なバイト数になります。ボックス化形式はフラットバイトをゼロと報告します。そのボディはヒープの Vec であり、別途計上されます。 |
| ホストによる証明 | CallVerifiedNative はホスト登録を通じて、CallExternalNative は反復ごとの呼び出し回数の上限を通じて |
Keleusma クックブック
レシピは、Keleusma をより大きなシステムへ組み込むための、実際に動作するパターンです。各レシピは、それが解決する問題、それが尊重する制約、および最小限の動作する例を示します。レシピは、そのパターンを実運用規模でインスタンス化しているバンドル済みの例へリンクしています。リンク先の節は、より深く読み進めるための場所です。
索引
| レシピ | 使うとき |
|---|---|
Text を扱う | ホストまたはスクリプトが文字列を扱う必要があります。 |
| モジュールからアリーナを自動サイズ設定する | ホストが、ハードコードされた容量ではなく、正確な WCMU 境界に基づくアリーナのサイズ設定を望んでいます。 |
| データローダーパターン | ホストが、スクリプト側での編集から恩恵を受ける読み取り専用の構成データを必要としています。 |
| 狭いランタイムの型エイリアス | ホストが 64 ビット未満のネイティブランタイム(16 ビットまたは 8 ビットの符号付きワード)を対象としています。 |
| 署名付きバイトコードの配布 | ホストが、信頼できないチャネル越しにコンパイル済みモジュールを配信し、発信元の真正性を必要としています。 |
| 実測コストモデルによる較正済み WCET | ホストが、名目上の相対的な重みではなく、デプロイ対象における実際の CPU サイクル数での WCET 推定を望んでいます。 |
Text を扱う
問題
ホストまたはスクリプトが文字列を扱う必要があります。名前、ログメッセージ、エラーレポート、構成値、外部世界からの識別子などです。Keleusma は文字列処理における付加価値ではありませんが、実際のアプリケーションは境界において何らかの文字列処理を日常的に必要とします。
解決
2 つのルールがあります。
その一。静的なテキストには文字列リテラルを使用してください。 ソースレベルの文字列リテラルは Value::StaticStr へコンパイルされ、バイトコードの読み取り専用定数プールに配置されます。これらは不変で固定サイズのハンドルであり、関数の引数、戻り値、および yield のペイロードに使用可能です。スクリプトのサーフェス型は Text です。ランタイムは、内部的に静的と動的の区別を保持します。
fn label() -> Text {
"ready"
}
2. リテラル以外のテキスト操作にはRust関数を登録する。 V0.2.0はバンドルされたconcat、to_string、slice、lengthユーティリティネイティブをf-string補間と並んで引退させました。スクリプト側の文字列合成はホスト登録の関数を通じて流れます。フォーマット、分割、正規表現、Unicode操作、エンコーディング変換が必要なホストはRust関数を登録し、スクリプトはuseを通じてそれをインポートします。
#![allow(unused)]
fn main() {
vm.register_fn("text::upper", |s: String| -> String { s.to_uppercase() });
vm.register_fn("text::trim", |s: String| -> String { s.trim().to_string() });
}
use text::upper
use text::trim
fn greet(name: Text) -> Text {
text::upper(text::trim(name))
}
ホスト生成の動的文字列は、Value::KStr(アリーナハンドル)としてアリーナヒープに住みます。スタックとローカル束縛で許容されますが、yield境界を跨ぐことはできません。検証器は、アリーナ常駐のKStrをホスト-VM境界を跨いで運ぼうとするプログラムを却下します。
なぜこれがRTOSや組み込みターゲットで動くか
静的文字列は読み取り専用データセクションに住み、割り当てコストがありません。静的文字列を通じて名前やログラベルを返すスクリプトはアリーナをゼロ消費します。ホスト生成の動的文字列は、ホストがregister_verified_native(name, fn, wcet, wcmu_bytes)を通じて計上するアリーナヒープを消費します。検証器が呼び出しごとのWCMUを反復予算に折り込みます。文字列作業が無制限に成長する経路はありません。固定サイズの静的文字列ハンドルを通るか、検証器境界付きのヒープ割り当てに計上されるか、そもそもコンパイルされないかです。
相互参照
- FAQ.md, Stringsがサーフェスの注意点と静的文字列エスケープ表を扱います。
- TYPE_SYSTEM.md、テキスト型 が型システムの仕様です。
- ローグ例の bestiary スクリプトがモンスター名をこのパターンで返します。
モジュールからアリーナを自動サイズする
問題
すべてのKeleusma Vmはアリーナを必要とします。ホストが容量を選びます。小さすぎると検証器がVmError::VerifyErrorでVm::newでモジュールを却下し、大きすぎるとホストは必要としないメモリを無駄にします。組み込みターゲットは特に厳密なサイジングを欲しがります。ヒープがまったく無いかもしれない(アリーナは.bss内の静的[u8; N]バッファから動きます)からです。
解決
keleusma::vm::auto_arena_capacity_for(&module, native_wcmu)を使って、VM構築前にコンパイル済みモジュールから最小必要容量を計算します。関数はモジュールのStreamチャンクを歩き、各チャンクのスタックとヒープのWCMUを合計し、最大の合計を返します。結果は、提供されたネイティブ計上のもとでモジュールを許容する最小の容量です。
#![allow(unused)]
fn main() {
use keleusma::vm::{auto_arena_capacity_for, Vm};
use keleusma::Arena;
let cap = auto_arena_capacity_for(&module, &[])?;
let arena = Arena::with_capacity(cap);
let vm = Vm::new(module, &arena)?;
}
2番目の引数はネイティブごとのヒープ割り当て計上のスライスです。アリーナから割り当てるネイティブが無いときは空のスライスを渡します。ヒープ割り当てネイティブをホストが登録している場合は適切なu32値を渡します。
#![allow(unused)]
fn main() {
// ホスト登録のテキストまたはバッファネイティブを使うスクリプト。
// ホストの呼び出しごとの計上は、モジュールの`native_names`テーブルと
// 同じ順序でスライスを流れる。
let native_wcmu = &[upper_wcmu, trim_wcmu];
let cap = auto_arena_capacity_for(&module, native_wcmu)?;
}
どのアリーナサイズオプションをいつ使うか
ライブラリは3つのパターンを提供しています。
| オプション | 使うとき |
|---|---|
Arena::with_capacity(DEFAULT_ARENA_CAPACITY) | ホスト型開発と素早いプロトタイピング。寛大なデフォルト容量が許容できる。 |
auto_arena_capacity_for | 最小の正しい容量が欲しいプロダクションホスト、特に多くのVMを動かすときやホストメモリが厳しいとき。 |
Arena::from_static_buffer | ヒープなしのベアメタルターゲット。ホストが.bss内の固定サイズバッファを所有して、そのポインタをアリーナに渡す。 |
自動サイズオプションは静的バッファオプションと組み合わせられます。容量をコンパイル時に計算(モジュールがinclude_bytes!を通じてconst読み込み可能な場合)するか、ビルド時に計算(ホストを1度走らせて値を表示)して、それからそのサイズで静的バッファを宣言します。
失敗モード
選んだ容量がモジュールのWCMUより下回る場合、Vm::newがコードが走る前にVmError::VerifyErrorを返します。これは実行時ではなく構築時に検出されるので、失敗はランの途中ではなく前面で観測可能です。
相互参照
- EMBEDDING.md, Arena Sizingが組み込みガイドリファレンスです。
- バンドルされた
examples/wcmu_basic.rsが完全な自動サイズパターンを最初から最後まで示します。
データローダーパターン
問題
ホストが設定データのテーブルを必要とします。データは構造的に均質(エントリごとに固定形状のレコード)で設計者調整可能(ゲームバランス、ルックアップテーブル、コンテンツ)です。テーブルをRustソースに保存することは、設計者がチューニングしなおすためにホストを再ビルドしなければならないことを意味します。スクリプトファイルに保存することは、設計者が.kelファイルを編集して実行時に再読み込みできることを意味します。
Keleusmaは現在、レコード配列のモジュールスコープconst宣言、インライン文字列テーブル、成長可能な構造の実行時割り当てをサポートしません。下のパターンはそれらの制約の中で動きます。
解決
テーブルを3つの部分を持つKeleusmaスクリプトとしてエンコードします。
- データセグメントをスクリプト側で宣言し、レコードの出力列ごとに1つのフィールドを持ちます。データセグメントはホスト-スクリプトI/O構造体です。
- 多頭ディスパッチャをエントリごとに1つの頭で持ちます。各頭はエントリごとの定数をデータセグメントに書き込みます。
- ローダー関数がインデックス(負のインデックス慣習を含む)を解決し、ディスパッチャに連鎖します。
ホストは起動時にエントリごとに1度スクリプトを走らせ、各呼び出しのあとにデータセグメントを読み、結果を通常のRustコンテナ(Vec<T>、HashMap<K, T>など)にキャッシュします。キャッシュが温まったあとは、実行時の読み取りはRustキャッシュを通ります。スクリプトはホストが再読み込みを欲しがるときにのみ再び触られます。
このパターンは実行時のホットリロードを許容します。テーブルがスクリプト形式だからです。スクリプトを再コンパイルし、ローダーを再実行し、キャッシュをアトミックに置き換えるホストは、再起動なしでデータを差し替えられます。起動時に1度キャッシュするホストも恩恵を受けます。テーブルがRustソースから設計者が編集できるファイルに移動するからです。
3つの構成テクニック
パターンは個別に知られている3つのテクニックを構成しますが、それらが組み合わさることがうまくいきます。
定数テーブルをエンコードする多頭ディスパッチ。 Keleusmaは整数パターンのパラメータを持つ多頭関数定義を許容しています。エントリごとに1頭で、各本体がエントリのフィールドを代入することは、機能的に定数配列と等価です。エンコードは検証器に優しいです。すべての本体が直線コードだからです。Prologファクトと、Erlang/Elixirのパターンマッチングが近い類似物です。
ホスト-スクリプトI/O構造体としてのデータセグメント。 データセグメントは通常、loop mainスクリプトが再開を跨いで状態を保持する場所です。1ショットの純粋関数のために、出力構造体としてそれを再利用することは、get_dataとset_dataがすでにホスト境界の一部だから動きます。スクリプトは関数の引数を通じて入力を読み、state.field = ...代入を通じて出力を書きます。
負のインデックスサイズ発見。 ローダーは負のインデックスをcount + n(Pythonシーケンス慣習)に解決します。fn main(-1)を呼ぶと、最後のエントリのフィールドが、count - 1に等しいidスロットを含めて書かれます。ホストはidスロットを読んでテーブルサイズを1呼び出しで学び、そこから自分のキャッシュをサイズし、並列のホスト側定数に対してその値をアサートします。これはRustソースに数を埋め込むことを避けます。
最小例
3色のテーブル、それぞれに赤、緑、青のチャネルがあります。
// colours.kel
data state {
id: Word,
r: Word, g: Word, b: Word,
}
fn main(n: Word) -> Word {
let count = 3;
let i = if n < 0 { count + n } else { n };
fill(i);
0
}
fn fill(0) -> Word { state.id = 0; state.r = 255; state.g = 0; state.b = 0; 0 } // 赤
fn fill(1) -> Word { state.id = 1; state.r = 0; state.g = 255; state.b = 0; 0 } // 緑
fn fill(2) -> Word { state.id = 2; state.r = 0; state.g = 0; state.b = 255; 0 } // 青
fn fill(_n: Word) -> Word { 0 }
ホスト側で、キャッシュをスクリプトから発見します。
#![allow(unused)]
fn main() {
use std::sync::OnceLock;
pub struct Colour { pub r: u8, pub g: u8, pub b: u8 }
static COLOURS: OnceLock<Vec<Colour>> = OnceLock::new();
pub fn colours() -> &'static [Colour] {
COLOURS.get().expect("colours not loaded")
}
fn load_colours(vm: &mut Vm) -> Result<(), Box<dyn std::error::Error>> {
// The host lends a zeroed shared buffer; the script writes its fields into
// it and the host reads them back out (B28 item 2).
let mut shared = vec![0u8; vm.shared_data_bytes()];
// Discover the count by calling with -1.
vm.call_with_shared(&mut shared, &[Value::Int(-1)])?;
let count = read_int(vm, &shared, 0)? as usize + 1;
let mut table = Vec::with_capacity(count);
for i in 0..count {
vm.call_with_shared(&mut shared, &[Value::Int(i as i64)])?;
table.push(Colour {
r: read_int(vm, &shared, 1)? as u8,
g: read_int(vm, &shared, 2)? as u8,
b: read_int(vm, &shared, 3)? as u8,
});
}
let _ = COLOURS.set(table);
Ok(())
}
fn read_int(vm: &Vm, shared: &[u8], slot: usize) -> Result<i64, Box<dyn std::error::Error>> {
match vm.get_shared(shared, slot)? {
Value::Int(n) => Ok(n),
other => Err(format!("expected Int at slot {}, got {:?}", slot, other).into()),
}
}
}
バリエーション
1つのスクリプトの中の複数のテーブル。 2つのテーブルが同じデータセグメント形状を共有する場合、先頭のtable引数でディスパッチします。fn main(table, tier)はtableに基づいて2つのテーブルごとの内部関数のうち1つにディスパッチします。各テーブルは-1を通じて独立に発見可能です。
fn main(table: Word, tier: Word) -> Word {
let count = 20;
let i = if tier < 0 { count + tier } else { tier };
if table == 0 { weapon(i); }
else { if table == 1 { armor(i); } };
0
}
fn weapon(0) -> Word { ... }
fn armor(0) -> Word { ... }
連鎖したディスパッチャ。 出力フィールドが他のものから派生する場合、ローダーで2つのディスパッチャを連鎖します。最初のディスパッチャがキーイングフィールドを設定し、2番目がデータセグメントからそれを読んで派生フィールドを設定します。ホストは1回の呼び出しから完全に埋まったエントリを受け取ります。
fn main(n: Word) -> Word {
let count = 100;
let i = if n < 0 { count + n } else { n };
fill(i);
corpse_fill(state.shape);
0
}
戻り値を通じた名前。 Keleusmaのデータセグメントは現在、ソースで文字列フィールドを受け付けません。エントリに名前がある場合、それをTextを返す3つ目の多頭ディスパッチャとしてエンコードし、fn mainの最後の式として呼びます。ホストは戻り値として文字列を受け取り、データセグメントは数値フィールドを運びます。ホストは起動時に1度返された静的文字列をリークして、&'static strを取得できます。
いつ使うか
パターンが合うのは次のすべてが成り立つときです。
- テーブルが約10エントリより多い。それより下では、スクリプトのオーバーヘッドが節約を超えます。
- 各エントリが整数または列挙型の序数の小さな構造体。文字列、奇妙な精度の浮動小数点、可変サイズのペイロードは回避策が必要です。
- データが、ホストの再ビルド無しに設計者が編集できることから恩恵を受ける。Rust作者だけがテーブルに触るなら、Rustに残します。
- 初期実装が一度だけキャッシュするとしても、実行時のホットリロードが望ましい。パターンが経路を開いておきます。
いつ使わないか
- データがすでにRustで密(エントリごとに1行、エントリごとの構造体ボイラープレートなし)。移行はストレージを圧縮することなくスクリプト読み込みオーバーヘッドを追加します。
- データがライフサイクルフック(コンストラクタ、ドロップ)を持つ。Keleusmaはそれらを運べません。Rustに残します。
- データがスクリプトが表現できない型でキーされる。文字列、特定の精度要件を持つ浮動小数点、複合キーはすべてパターンを適合外に押し出します。
このリポジトリでの例
ローグ例は bestiary と装備のテーブルにこのパターンを使います。ROGUE.md, bestiaryスクリプトを読むを参照してください。
狭いランタイム型エイリアス
問題
ホストがサブ64ビットネイティブランタイムを対象にします。16ビットマイクロコントローラ、レトロクラスの8ビットマシン、32ビット組み込みコア。デフォルトのVm<'a, 'arena>はGenericVm<'a, 'arena, i64, u64, f64>です。16ビットネイティブターゲット上で64ビット値を運ぶことはメモリを無駄にし、ハードウェアがネイティブにサポートしていないマシンオペランドに対してソフトウェア算術を強制します。ホストはランタイムのワード、アドレス、フロート幅をターゲットに合わせたいのです。
解決
Vmの形は、バイトコードヘッダのword_bits_log2、addr_bits_log2、float_bits_log2宣言幅をミラーする3つのトレイトパラメータでジェネリックです。GenericVm<W, A, F>をホストの選んだ幅で直接インスタンス化し、人間工学的な呼び出し場所のために型エイリアスを定義します。
#![allow(unused)]
fn main() {
use keleusma::vm::GenericVm;
// 16ビット符号付きワード、16ビット符号なしアドレス、32ビットフロート。
type NarrowVm<'a, 'arena> = GenericVm<'a, 'arena, i16, u16, f32>;
// 8ビット符号付きワード、16ビット符号なしアドレス、32ビットフロート
// (6502クラスのレトロターゲット。将来のオペコードのためにフロートを保持)。
type RetroVm<'a, 'arena> = GenericVm<'a, 'arena, i8, u16, f32>;
}
狭いターゲット用のバイトコードはcompile_with_targetを通じて生成されます。embedded_16プリセットは浮動小数点オペコードを却下します。フロートが狭い幅で欲しい場合はカスタムTargetを使います。
#![allow(unused)]
fn main() {
use keleusma::Arena;
use keleusma::compiler::compile_with_target;
use keleusma::lexer::tokenize;
use keleusma::parser::parse;
use keleusma::target::Target;
let module = {
let tokens = tokenize(src).expect("lex");
let program = parse(&tokens).expect("parse");
compile_with_target(&program, &Target::embedded_16()).expect("compile")
};
let arena = Arena::with_capacity(4096);
let mut vm: NarrowVm<'_, '_> = NarrowVm::new(module, &arena).expect("verify");
}
ホスト関数はRustの自然な型を話す
マーシャル層(KeleusmaType、IntoNativeFn、IntoFallibleNativeFn)は(W, F)に対してパラメトリックで、i64、f64、bool、()、Option<T>、固定配列、タプル(アリティ2から5)に対して普遍的実装を持ちます。KeleusmaType<W, F> for i64の普遍実装はWord::to_i64とWord::from_i64_wrapを通じて橋渡しします。KeleusmaType<W, F> for f64の普遍実装はFloat::to_f64とFloat::from_f64を通じて橋渡しします。
ホスト作者は、スクリプトの狭いワードとフロート型に関わらず、クロージャのシグネチャにi64とf64を書きます。ランタイムが境界で切り捨てます。
#![allow(unused)]
fn main() {
vm.register_fn("host::triple", |x: i64| -> i64 { x * 3 });
}
NarrowVmでは、スクリプト側のi16引数がホストクロージャ用にi64に広げられ、i64の戻り値はWord::from_i64_wrapを通じてi16に切り捨てられます。ネイティブ幅のRust型が欲しいホスト(i16を直接受け取って広げを避けるクロージャ本体)は、自分のクレートで自分のKeleusmaType<i16, f32> for i16実装を追加できます。
標準ライブラリバンドルは狭いランタイムで動く
3つのstddslバンドルはLibrary<W, A, F>を普遍的に実装していて、許容可能などのランタイム形にも登録します。MathとAudioは内部のクロージャをf64で運びます。Fがf32のランタイムでは、すべてのクロージャの引数と戻り値はマーシャル境界でFloat::from_f64とFloat::to_f64を通り、中間値と定数を狭めます。狭めは数学的に定義されていて、静かです。Shellは浮動小数点サーフェスを持たないので、精度の含みなくFを量化します。
#![allow(unused)]
fn main() {
let mut vm: NarrowVm<'_, '_> = NarrowVm::new(module, &arena).expect("verify");
vm.register_library(keleusma::stddsl::Math);
vm.register_library(keleusma::stddsl::Audio);
}
完全なf64精度が必要なプログラムは、静かな狭めに頼るのではなくFがf64のランタイムを宣言すべきです。狭いフロートランタイムは、ターゲットのFPUが単精度で、スクリプトが余分な仮数を必要としないときの適切な選択です。
ワード幅算術の規律
狭いランタイム上のスクリプト側算術は、64ビットではなくランタイムのワード境界でラップします。Wordトレイトのwrapping_add、wrapping_sub、wrapping_mul、wrapping_div、wrapping_rem、wrapping_negメソッドが、すべての算術ディスパッチサイトを駆動します。NarrowVm上では30_000 + 10_000は40_000ではなく-25_536を生みます。より広い算術に依存するプログラムは、より広いワードを宣言するか、自然なRust型を取る登録されたネイティブを通じてホスト側で操作を実行すべきです。
相互参照
examples/narrow_runtime.rsが作業デモです。tests/narrow_vm.rsがパターンをピンする統合テストです。docs/decisions/BACKLOG.md、B16 がパラメトリック形状に関する設計上の根拠を記録しています。Word、Address、Floatトレイトはsrc/word.rs、src/address.rs、src/float.rsに住みます。カスタム実装は許容されます。バンドルされた実装は標準の幅をカバーします。
署名付きバイトコードの配信
問題
ホストアプリケーションがコンパイル済みバイトコードを信頼されないソースから(通信リンク、ディスク、コンテンツ配信チャネルから)読み込み、許可された署名者によって生成されたものでないモジュールを拒否する必要があります。脅威モデルは多関係者です。1つ以上の既知のマザーシップ識別がモジュールに署名することを信頼され、その他すべては却下されます。
解決
3つのステップ。Cargo機能のsignaturesはデフォルトでオフです。プロデューサとコンシューマの両方でオンにします。
1. キーペアを生成する。 keleusma keygen --seed seed.bin --public pub.binサブコマンドが32バイトのEd25519シードと一致する公開鍵を別々のファイルに書きます。Unixではシードファイルはモード0o600で、既存のファイルは上書きされません。シードを署名システム上で保管された長命の秘密として扱います。公開鍵は自由に配布可能で、コンシューマが信頼するものです。
2. 要求を宣言して署名する。 ソーススクリプトは、エントリ関数のsigned修飾子で署名要求を宣言します。
signed loop main(input: Word) -> Word {
let next = yield input * 2;
next
}
keleusma compile script.kel --signing-key seed.bin -o script.kel.binが署名付きバイトコードを生成します。コンパイラはフレーミングヘッダにFLAG_REQUIRES_SIGNATUREを発行し、署名者はEd25519署名を追加します。
3. コンシューマで検証する。 ホストは受け入れる公開鍵で信頼マトリクスを populate し、署名対応エントリポイントを通じて読み込みます。
#![allow(unused)]
fn main() {
let key = ed25519_dalek::VerifyingKey::from_bytes(&public_key_bytes)?;
let mut vm = Vm::load_signed_bytes(&signed_bytes, &arena, &[key])?;
}
ホットスワップ配信(マザーシップ/ドーターシップパターン)では、ホストは署名なしベースラインからVMを構築し、信頼マトリクスを登録し、通信リンク上で署名付きの更新を受け入れます。
#![allow(unused)]
fn main() {
let mut vm = Vm::new(baseline_module, &arena)?;
vm.register_verifying_key(signer_key);
// 後で、ワイヤ上で更新を受け取ったあと:
vm.replace_module_from_bytes(&update_bytes, initial_data)?;
}
署名は新しいバイトコードがデコードされる前に検証されます。無効な署名はスワップを却下し、現在のモジュールが走り続けます。
なぜこれが組み込みターゲットで動くか
検証経路はno_std + alloc下でed25519-dalekを使います。examples/rtosデモは--features keleusma-signaturesでビルドし、スケジューラループに入る前にブートで組み込みの署名付きフィクスチャを検証します。Cortex-M33上の600 MHzでのEd25519検証はミリ秒のオーダーで走ります。コストはモジュール読み込みまたはホットスワップごとに払われ、yieldごとではありません。
相互参照
docs/decisions/RESOLVED.mdのR42が設計上の根拠です。docs/spec/WIRE_FORMAT.mdがヘッダー拡張のレイアウトを説明しています。- 組み込み側APIをより深く扱うのは
docs/guide/EMBEDDING.md, Signed Modules。 - 作業署名付きスクリプトは
examples/scripts/11_signed.kel。
測定コストモデルでのキャリブレートされたWCET
問題
ホストはデプロイメントターゲット用の実際のCPUサイクルでのWCET(ワーストケース実行時間)推定値を欲しがります。バンドルされたNOMINAL_COST_MODELが出荷する相対重み推定値ではありません。名目モデルはデータの移動に1、算術に2、除算に3、複合構築に5、関数呼び出しに10を割り当てます。これらの比率は同じホスト上でプログラムを互いに順序付けるのに役立ちますが、絶対スケールは特定のハードウェアのパイプライン化CPUサイクルではありません。
デプロイメントターゲットの本物のWCET解析は、測定された数字を欲しがります。Cortex-M55の800 MHz上のデータ移動はVMディスパッチごとに数百サイクル走ります。Apple M1 MaxのPコア上の同じワークロードは数十サイクル走ります。2つは別の単位で、WCETを壁時計時間に変換したいホストは正しいほうを必要とします。
解決
keleusma-benchワークスペースメンバーがホストCPU上のオペコードごとのパイプライン化サイクルを測定し、MEASURED_COST_MODEL定数を定義するRustソース断片を発行します。ホストクレートが断片をインクルードし、消費するWCET APIの_with_cost_modelヴァリアントに定数を渡します。
1. 断片を入手する。 クレートはkeleusma-bench/measured_cost_models/で開発ホストとSTM32N6570-DK用の事前生成された断片を出荷します。他のホストには再生成します。
# ホストベンチ(走っているCPU用の断片を書く)
cargo run --release --bin keleusma-bench -- \
--cpu-hz 3500000000 \
--output keleusma-bench/measured_cost_models/<target-triple>.rs
# Embedded bench (captures defmt RTT, parses to a fragment)
cd examples/rtos
cargo run --release --bin bench_n6 --target thumbv8m.main-none-eabihf \
--no-default-features --features stm32n6570dk-platform \
2>&1 | tee /tmp/bench.log
cd -
cargo run --release -p keleusma-bench -- \
--from-log /tmp/bench.log \
--output keleusma-bench/measured_cost_models/thumbv8m_main_none_eabihf.rs
--cpu-hzはサイクル計算のためのホストのCPUクロックを宣言します。デフォルトはkeleusma-bench/src/counter.rs(DEFAULT_ASSUMED_CPU_HZ)に文書化されています。ターゲットごとのキャプチャワークフローはkeleusma-bench/measured_cost_models/README.mdを参照してください。
2. 断片をホストクレートにインクルードする。 断片はMEASURED_COST_MODELとバッキングのmeasured_op_cycles関数を宣言する平のRustソースです。
#![allow(unused)]
fn main() {
include!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/path/to/keleusma-bench/measured_cost_models/<fragment>.rs"
));
}
複数の断片はcfg(target_arch = ...)でゲートすれば、きれいに同居します。
#![allow(unused)]
fn main() {
#[cfg(all(target_arch = "aarch64", target_os = "macos"))]
include!(concat!(env!("CARGO_MANIFEST_DIR"),
"/keleusma-bench/measured_cost_models/aarch64_apple_darwin.rs"));
#[cfg(target_arch = "arm")]
include!(concat!(env!("CARGO_MANIFEST_DIR"),
"/keleusma-bench/measured_cost_models/thumbv8m_main_none_eabihf.rs"));
}
3. モデルを検証器に渡す。 WCET APIの_with_cost_modelヴァリアントは&CostModelを受け取ります。
#![allow(unused)]
fn main() {
use keleusma::verify::wcet_stream_iteration_with_cost_model;
for chunk in module.chunks.iter() {
if chunk.block_type != BlockType::Stream { continue; }
let cycles = wcet_stream_iteration_with_cost_model(chunk, &MEASURED_COST_MODEL)?;
println!("Chunk `{}`: {} CPU cycles per iteration", chunk.name, cycles);
}
}
読み込み時のリソース境界検証には、verify::verify_resource_bounds_with_cost_modelが同じ&CostModelを取り、WCETとWCMUの両方の経路に通します。
なぜこれが大切か
バンドルされたNOMINAL_COST_MODELは相対的にのみであることに正直です。「反復ごとに何マイクロ秒か」に答えられません。測定モデルはベンチのキャリブレーション仮定(CPUクロック、温まったキャッシュ、予測された分岐)を条件として、答えられます。WCET境界がスケジューラの判断を伝えるデプロイメントでは、この変換が土台のステップです。
失敗モード
- 断片がデプロイメントターゲットと違うホストで測定された。 サイクル数は実行するCPUのキャリブレーションではなく開発ホストの推定値です。断片のヘッダがホストと仮定されたCPUクロックを記録します。使う前に両方を確認します。
- ホストCPUの実際のクロックがベンチの仮定と違う。 実際のクロックに設定された
--cpu-hz <Hz>で再ベンチするか、--cpu-hzをkeleusma-bench --from-logに渡してキャプチャ後に文書化された値を訂正します。 - ベンチが
YieldまたはCallを孤立で測定しない。 両方のカテゴリはスケールされた名目値にフォールバックします。スケールされた値はWCETには保守的ですが、外挿であって測定ではありません。これは断片のヘッダに文書化されています。
相互参照
examples/measured_wcet.rsが最小の作業例です。小さなStream分類されたプログラムをコンパイルし、名目と測定の両方のモデルでWCETを表示します。keleusma-bench/README.mdがベンチツールの方法論とCLIを文書化します。keleusma-bench/measured_cost_models/README.mdが事前生成された断片とキャプチャワークフローをカタログします。docs/architecture/LANGUAGE_DESIGN.md§ コストモデル がCostModelの契約とキャリブレーションにおけるホストの役割を説明しています。examples/rtosが見出し例で配線を実演します。各タスクがブートで反復ごとの測定WCETをログします。
よくある質問
本ドキュメントは、早期採用者が遭遇した意外な点を集めたものです。その意図は、他のドキュメントがまだ想定していない疑問に答えることであり、網羅的であることではありません。
文字列
文字列は Keleusma の価値提案ではありません。 この言語の価値提案は、組み込みリアルタイムスクリプティングに向けた、確定的な最悪実行時間および最悪メモリ使用量の検証です。文字列を多用するスタンドアロンな作業には、豊富な標準ライブラリを備えた動的言語のほうが適したツールです。Python、Ruby、JavaScript、あるいは多数あるシェルおよびテキスト処理言語のいずれもが、Keleusma よりも人間工学的に、かつより多くの組み込みユーティリティとともに文字列を扱えます。Keleusma における文字列は、ホスト境界の型として、またデバッグの利便性として存在します。それらは最適化の対象とすべきサーフェスではありません。
V0.2.0 における Text サーフェス
V0.2.0 は、スクリプトレベルにおいて静的文字列サーフェスのみを同梱します。文字列リテラル("...")は、バイトコードの読み取り専用定数プール内の Value::StaticStr 定数へコンパイルされます。Text プリミティブ型は、静的文字列、ホストが生成する動的文字列(Value::KStr アリーナハンドル)、およびホスト境界を越える文字列型パラメータのサーフェス型を指します。バンドルされた to_string、concat、slice、length ユーティリティネイティブは、V0.2.0 のフェーズ 3.5 におけるテキスト合成の削除に伴い、f 文字列補間とともに廃止されました。ランタイムは、Text の背後で静的(StaticStr)と動的(KStr)のバリアントを依然として区別します。yield をまたぐ禁止事項は、引き続き動的文字列に適用されます。
推奨されるパターンは、文字列処理を実行するネイティブ Rust 関数を登録し、それらをスクリプトへ公開することです。Rust の標準ライブラリは、書式設定、分割、正規表現、エンコーディング変換、および Unicode 操作を、スクリプト内部で構築する妥当なものよりもはるかに巧みに扱います。ホストは小さな Rust 関数を記述し、1 回の register_fn 呼び出しでそれを登録します。すると、スクリプトはネイティブ性能と Rust エコシステムへの完全なアクセスをもたらす単一の use 宣言を得ます。
#![allow(unused)]
fn main() {
// Rust host code.
use keleusma::{Arena, Value, vm::Vm};
let mut vm = Vm::new(module, &arena)?;
// Host-defined string helpers using Rust's standard library.
vm.register_fn("text::upper", |s: String| -> String {
s.to_uppercase()
});
vm.register_fn("text::trim", |s: String| -> String {
s.trim().to_string()
});
vm.register_fn_fallible(
"text::split_first_word",
|s: String| -> Result<String, keleusma::VmError> {
s.split_whitespace()
.next()
.map(|w| w.to_string())
.ok_or_else(|| keleusma::VmError::NativeError("empty input".into()))
},
);
// スクリプトは各ネイティブを名前でインポートします。
//
// use text::upper
// use text::trim
// use text::split_first_word
//
// fn greet(name: Text) -> Text {
// text::upper(text::trim(name))
// }
}
ホストが文字列処理の語彙を所有し、スクリプトはuse宣言を通じてそれを消費します。完全なネイティブ登録サーフェスはEMBEDDING.mdを参照してください。
テキストが動く場所
- 静的文字列リテラル。
Value::StaticStrにコンパイルされ、バイトコードの定数プールに住みます。許容されるどこにでも流れられ、対話型のyield境界を跨ぐこともできます。 - アリーナ常駐の動的文字列。 ホスト登録されたネイティブ関数によって
KString::allocアリーナ境界を通じて生成されます。ホスト所有のアリーナを通じて解決されるValue::KStrハンドルとして保持され、次回のアリーナリセット時に無効になります。yield をまたぐ禁止の対象です。完全なテキスト型の規律については TYPE_SYSTEM.md を参照してください。
V0.1.xに存在したValue::DynStrグローバルヒープヴァリアントはV0.2.0で削除されました。すべての動的テキストはアリーナ常駐です。
静的文字列リテラルのエスケープ表
| エスケープ | 結果 |
|---|---|
\n | 改行(U+000A) |
\t | タブ(U+0009) |
\r | キャリッジリターン(U+000D) |
\\ | リテラルのバックスラッシュ |
\" | リテラルの二重引用符 |
\0 | ヌルバイト |
その他のすべての文字はエスケープなしでそのまま現れます。その他のバックスラッシュシーケンスはlexエラーです。V0.2.0はf-string補間と並んでf-string特有の\{と\}エスケープを引退しました。{と}は"..."内では普通の文字です。
WCMUのカバレッジ
指数的な文字列連結はWCMU境界を迂回する
次のようなプログラム
fn main() -> Text {
let s = "a";
let s = s + s;
let s = s + s;
/* 60回の倍化のあと */
s
}
はV0.1.xではコンパイルされて、Vm::newに受け入れられて、実行時にホストプロセスを使い果たしていたでしょう。V0.2.0はアロケータと静的解析の両方の次元に対処しています。
問題1: 文字列の+は以前はアリーナではなくグローバルアロケータから割り当てていました。 V0.2.0で解決されました。テキストオペランドに対するOp::Addは今、KString::allocを通じてアリーナのトップ領域に割り当てられたValue::KStrを生みます。割り当て失敗はホストプロセスを使い果たすのではなく、VmError::OutOfArenaとして表面化します。Value::DynStrヴァリアントは完全に削除されました。
問題2: WCMUパスは以前テキストサイズを静的に追跡していませんでした。 V0.2.0で解決されました。検証器は今、各チャンクに対してテキストサイズの抽象解釈パスを走らせ、スロット別のTextSize::{NotText, Known(u32), Unbounded}格子をバイトコードを通じて追跡し、テキストに対するOp::AddのOpCost::Dynamicコストをオペランド境界に対して評価し、結果をチャンクのWCMUヒープ合計に累積します。テキスト値を2倍に拡大するプログラムは累積で境界をu32::MAXに飽和させ、安全なコンストラクタはデフォルトのOverflowPolicy::Rejectのもとでこれを却下します。上の倍化文字列の例は、Streamブロックとして表現されたときVm::newで却下されるようになりました。
V0.2.0のテキストサイズ解析の制限。
- ループはテキスト値を
Unboundedに広げます。forまたはloop本体内のテキスト操作は保守的なu32::MAXの貢献を生みます。パスは線形で反復的ではないからです。ストリーム反復ごとに1回テキスト連結を行うプログラムは正確に扱われ、ループとテキストを混ぜるプログラムは保守的になります。 - 分岐はテキスト値を
Unboundedに広げます。if/elseの内側で条件的に書かれたテキスト値は精密な境界を失います。パスは条件の外で書かれたテキストを引き続き正しく境界づけます。 - ネイティブの戻り値は
Unboundedです。 登録されたネイティブ関数から返されたテキストは無界として追跡されます。その後のOp::Addはそれに対して飽和的に貢献します。ネイティブにより厳しい境界が必要なホストは、Vm::set_native_boundsを通じてネイティブごとのヒープ計上を提供します。 - 原子的・全域プログラム(Streamブロックなし)は反復ごとのWCMU境界の対象になりません。
fn main() -> Text { let s = "a"; let s = s + s; ... }はコンパイルされて走ります。リソース境界チェックがStreamチャンクにのみ適用されるからです。VmError::OutOfArenaを通じたアリーナ枯渇経路が、これらのプログラムのための優雅な失敗保証を提供します。
テキスト中心の作業に対する推奨。 重いテキスト作業はホスト計上で帯域外と扱ってください。境界づけられたやり方で作業を行うRustのネイティブ関数(上のセクション参照)を登録して、スクリプトに消費させます。ホスト側のテキストヘルパーは、無制限に割り当てるのではなく、大きな入力で安全に失敗するように実装できます。
アリーナ常駐の割り当てに対するホスト計上パターンの作業例はexamples/wcmu_attestation.rsにあります。
V0.2.0は小さすぎるアリーナで早めに失敗する
以前のリリースはArena::with_capacity(0)をVm::newに通し、最初のプッシュでhandle_alloc_errorを介してホストプロセスを中断していました。V0.2.0はこれを2層で変えました。
構築時の最小予約。 Vm::newとVm::new_uncheckedは、アリーナのボトム領域に小さな最小オペランドスタックとコールフレーム割り当てを事前予約します。アリーナが最小を保持できない場合、両方のコンストラクタは中断ではなく新しいVmError::OutOfArenaヴァリアントを返します。最小は保守的(4個のスタックスロットと1個のコールフレーム)です。約500バイト以上のアリーナは通ります。
実行時のプッシュパスがOutOfArenaを返す。 VMの実行ループ内のすべてのオペランドスタックとコールフレームのプッシュは今、Vec::try_reserveをまず呼んで割り当て失敗でVmError::OutOfArenaを返す内部のsp!とfp!マクロを通じて経路指定されます。実行時の使用がアリーナを超えるプログラムはもうホストプロセスを中断しません。ホストは型付きエラーを得て、どう扱うかを決められます(VMを捨てる、Vm::reset_after_errorで状態をリセット、より大きなアリーナで再試行、ユーザーにエラーを表面化)。
組み合わせは、実行のアリーナ常駐部分 — オペランドスタックとコールフレーム — が、優雅な失敗とともに完全にアリーナで境界づけられていることを意味します。
#![allow(unused)]
fn main() {
let arena = Arena::with_capacity(2 * 1024);
let mut vm = Vm::new(module, &arena)?;
// ...
match vm.call(&[]) {
Ok(state) => /* 状態を扱う */,
Err(VmError::OutOfArena(msg)) => {
eprintln!("arena exhausted: {}", msg);
// 回復または再構成
}
Err(other) => /* 他のエラーを扱う */,
}
}
アリーナのサイズには、auto_arena_capacity_forにホスト側のマージンを加えたものか、典型的な組み込みスクリプティングのためのバンドルされた64キロバイトのDEFAULT_ARENA_CAPACITYを使います。
その他の意外な振る舞い
Vm::callは引数の数または型の間違いを先に却下する
RustからKeleusmaスクリプトを動かすホストは、vm.call(&[Value::Int(1), Value::Int(2)])で引数を渡します。ランタイムは、どのバイトコードも走る前に、引数の数をエントリチャンクのparam_countと照合し、各引数のランタイム型をパラメータの宣言されたTypeTagと照合します。引数が少なすぎる、多すぎる、または型が間違っている引数は、後の混乱した算術エラーではなく、呼び出しの境界でVmError::TypeErrorを生みます。
典型的な再現:
// スクリプト: fn main(a: Word, b: Word) -> Word { a + b }
vm.call(&[Value::Int(1)])
// -> VmError::TypeError("function `main` expected 2 arguments, got 1")
vm.call(&[Value::Int(1), Value::Float(2.5)])
// -> VmError::TypeError("function `main` parameter 1 expected Word, got Float")
不透明または複合値を本当に渡したいホストはTypeTag::Composite検証を受け取り、これは任意のValueを受け入れます。各チャンクのparam_typesフィールドが、ランタイムが何を受け入れるかについての真実のソースです。コンパイラは関数の宣言されたパラメータ型からそれを埋めます。
Vm::resumeはStreamブロックの再開値の型を検証する
loop main(x: T) -> Rスクリプトは型Rの値をyieldし、次の反復の型Tの値で再開します。ホストはvm.resume(value)を呼んで次の反復を駆動します。ランタイムはvalueをループのパラメータ型と照合してから、パラメータスロットに押し込みます。
#![allow(unused)]
fn main() {
// スクリプト: loop main(x: Word) -> Word { let z = yield x; z }
vm.call(&[Value::Int(11)]) // Ok(Yielded(Int(11)))
vm.resume(Value::Float(1.5)) // VmError::TypeError(
// "loop `main` resume expected Word, got Float")
}
yield式の型と再開値の型は言語設計により同じ(パラメータ型)です。だからチャンクレベルの1つのタグが対話の両方向をカバーします。
パーサは深くネストされた式を却下する
パーサは再帰下降ウォーカーです。深くネストされたパレン(約千以上)は以前ホストプロセスのスタックをオーバーフローしました。パーサは今、MAX_PARSE_DEPTH = 32レベルのネストで型付きParseErrorで中断します。制限は3つの再帰エントリポイント(parse_expr、parse_type_expr、parse_pattern)に適用されます。
Keleusmaソースをプログラム的に生成するホスト(テンプレーティング、コード生成)は、式のネストを32レベルのずっと下に保つべきです。現実的な手書きのソースが制限に近づくことはまれです。境界は悪意のあるまたは偶然の入力がホストプロセスを殺すのを防ぐために存在します。
ローカルの束縛は不変です
letの束縛は再束縛も変更もできません。データセグメントがスクリプトに観測可能な唯一の可変状態の領域で、loop分類されたエントリポイントからのみアクセス可能です。原子的・全域なfn内のループ反復間の累積はしたがって、(a)データセグメントを使うloop mainスクリプトか、(b)ホスト側のフォールドネイティブ、のどちらか無しには不可能です。両方の書き換えの例は再帰的クロージャエントリの下のWHY_REJECTED.mdを参照してください。
クロージャは型検査段階で却下されます
V0.2.0 のフェーズ 4 はクロージャファミリーを廃止しました。Op::PushFunc、Op::MakeClosure、Op::MakeRecursiveClosure、および Op::CallIndirect の各オペコードは削除され、Value::Func ランタイムバリアントは削除され、クロージャホイスティングコンパイラパスは削除されました。型検査器は現在、Expr::Closure を診断メッセージ closures are not supported; V0.2.0 admits only direct calls and trait dispatch under the conservative-verification stance. Rewrite as a top-level fn or trait method. とともに拒否します。第一級関数参照(例えば let f = my_func;)も同様にコンパイラによって拒否されます。これは LANGUAGE_DESIGN.md に記載された保守的検証の立場です。境界のない実行の有効な形式は、生産性規則によって強制されるトップレベルの loop ブロックです。
パイプライン演算子は括弧を必要とする
|>の右側は、関数が追加の引数を取らない場合でも、括弧付きの関数呼び出しでなければなりません。expr |> fはパースエラーで、expr |> f()が正しいです。
signed修飾子は何をするのか?
V0.2.0はエントリ関数宣言にsigned修飾子を導入しました(signed fn main、signed yield main、signed loop main)。これはフレーミングヘッダにFLAG_REQUIRES_SIGNATUREを設定するので、読み込み時のランタイムは、暗号学的署名が添付されてホストの信頼マトリクスに対して検証されない限り、モジュールを拒否します。
署名操作自体はコンパイラから独立したツールチェーンステップです。keleusma compile script.kel --signing-key seed.binはEd25519署名されたバイトコードファイルを生み、消費者は対応する公開鍵をVMに登録し(Vm::register_verifying_key)、Vm::load_signed_bytesを通じて読み込む、またはVm::replace_module_from_bytesを通じて署名付きの更新をホットスワップします。Vm::load_bytesは署名付きモジュールを、代替エントリポイントを名指しする診断で拒否します。
この機能はsignatures Cargo機能を必要とします。これはデフォルトでオフで、ed25519-dalekを引き込みます。機能なしのビルドは署名なしモジュールを通常通り受け入れ、署名付きモジュールをLoadError::SignaturesUnsupportedで拒否します。signedサーフェスキーワードは機能なしでも依然としてパースされるので、ソースファイルは移植可能なままです。
ユースケース: 組み込みターゲットへの複数当事者によるモジュール配信。署名者はスクリプトをコンパイルして署名します。署名者の公開鍵でフラッシュされたデバイスは、ロード前に検証します。署名済みバイトコードの配布 のクックブックレシピと RESOLVED.md の R42 を参照してください。
文の位置のif-elseは末尾のセミコロンを必要とする
パーサはセミコロンを自動挿入しません。文として使われるif-else式(別の文が続く場合)は、式がユニットに評価されても;が必要です。
if state.rem0 == 0 {
/* ... */
} else {
state.rem0 = state.rem0 - 1;
}; // <-- このセミコロンが必要
state.rem1 = state.rem1 - 1;
不透明型はネイティブ境界をValue::Opaqueとして流れる
V0.2.0はkeleusma::opaqueモジュールのHostOpaqueトレイトを通じて、第一級の不透明型サポートを導入しました。ホストは公開したい値の周りのRustnewtypeに対してトレイトを実装します。ネイティブ関数はhost_arc(...)を通じて不透明値を生み、dyn HostOpaque::downcast_ref::<T>()を通じて型付き参照を抽出して消費します。スクリプトは関数シグネチャで型を名前で宣言し、型検査器は名前をType::Opaqueとして解決します。不透明値はArcを通じてホスト管理され、アリーナとは独立したライフタイムを持ち、yield境界を跨いで運ばれることができ、スクリプト側のWCMU境界に対してゼロを貢献します。EMBEDDING.mdの「Opaque Host Types」セクションとexamples/opaque_rust_string.rsの作業例を参照してください。
整数算術はターゲットのワード幅にラップする
KeleusmaのWordはターゲット記述子で宣言された幅の固定幅符号付き整数です。算術操作は結果をその幅にマスクし、各ステップで符号拡張シフトを使います。オーバーフローは型付きエラーを生みません。結果は宣言された幅が許容する剰余的な意味で静かにラップします。
fn main() -> Word {
let max = 9223372036854775807;
max + 1
}
// 64ビットターゲットでは-9223372036854775808を返します。
この選択は意図的です。言語が保証するワーストケース実行時間とワーストケースメモリ使用量の境界は、すべての算術操作が固定ステップ数を持つことに依存しています。トラップオーバーフローの意味論は、すべての操作のワーストケースコストを膨張させるか、静的解析が列挙しなければならない制御フローのエッジを導入するかのどちらかです。ラップ意味論は予測可能なステップ数と、解析が一様に推論できる閉じた結果ドメインを与えます。
オーバーフロー検出が必要なホストは、より広いRust整数に対して確認された操作を行ってVmError::NativeErrorを通じてエラーを表面化するネイティブを登録します。ホストが確認算術の語彙を所有し、スクリプトはuse宣言を通じてそれを消費します。
loop-呼び出し-loopは語彙的生産性によって却下される
loopブロックを許容する生産性ルールは、純粋に語彙的な構造チェックによって強制されます。検証器は各loopの構文本体を歩き、1つの反復を通るすべての制御フロー経路が少なくとも1つのyieldを含むことを要求します。本体の唯一のyieldが呼ぶ関数の内側にあるloopブロックは却下されます。構造パスは呼び出しを追わないからです。
yield helper() -> Word { yield 1 }
loop main() -> Word {
let v = helper(); // <-- 構造パスはyieldを見ない
v
}
このプログラムはloop body has no yield on at least one pathで却下されます。ルールは意図的に保守的に間違えます。呼び出しを追う意味的チェックは、パラメータ依存ディスパッチやトレイトメソッド解決に対して健全でなく、相互に再帰的なコールグラフも扱わなければなりません。語彙的チェックは健全で速く、説明しやすい代わりに、yieldをloop本体のトップレベルに置くことを強制します。
推奨されるパターンはyieldをloop本体のトップに置いて、その周りでヘルパーを呼ぶことです。
yield helper() -> Word { yield 1 }
loop main() -> Word {
let v = yield helper(); // 直接のyieldがルールを満たす
v
}
同じ制約はloop本体内のif/elseとmatch分岐にも適用されます。すべての抜け落ち経路はyieldを含むか、分岐はbreakで抜けなければなりません。
V0.2.0の境界診断
構築と呼び出しサーフェスはV0.2.0で厳しくなり、以前は静かか紛らわしかった複数のケースが、適切な境界で型付き診断を生むようになりました。
i64をオーバーフローする整数リテラルは今LexErrorです。 以前の振る舞いは99999999999999999999999999999のようなリテラルに対して静かにValue::Int(0)を生んでいました。lexerは今、リテラルのソーススパンとともにinteger literal does not fit in i64を報告します。- 型なしパラメータは文脈が解決するとき推論されます。
fn main(x) -> Word { x }と書くと、以前はパースされてxが型なしで登録され、後で混乱したエラーをつまずきました。型検査器は今、推論されたプリミティブ型をASTに書き戻します。fn main(x) -> Word { x }では戻り型制約がx: Wordを強制し、チャンクのparam_typesがTypeTag::Wordを運ぶので、Vm::call(&[Value::Float(1.5)])はAPI境界で却下されます。推論がパラメータを解決しない場合(制約なし)、チャンクはTypeTag::Compositeを記録し、ランタイムは任意の値を受け入れます。 - 重複した関数頭は、エントリポイントであろうとなかろうと却下されます。 同じ名前を共有する2つの関数定義で、パラメータシグネチャが多頭パターンマッチング(同じ形、ガードなし)として明確に区別できないものは、以前は最初を残して残りを静かに捨てていました。コンパイラは今、2番目の定義で
function head is dead codeを報告します。ルールはすべてのカテゴリ(fn、yield、loop)、そしてエントリポイントだけでなくすべての関数に適用されます。 - 多頭エントリポイントは
fn、yield、loopに対して受け入れられます。 3つすべての関数カテゴリが、パターンマッチングされたエントリポイントを許容します。多頭loop main(...)Streamブロックは、各マッチした頭の本体がOp::PopとOp::Breakで終わるディスパッチをOp::Loop/Op::EndLoopで包んだものの周りの単一のOp::Streamと単一のOp::Resetエンベロープにコンパイルされるので、構造検証器のStream不変条件は成り立ちます。 - エントリポイントのないモジュールは今
VmError::VerifyErrorです。fn main、yield main、loop mainを省くソースからコンパイルされたモジュールは、以前は最初のVm::callでVmError::InvalidBytecode("no entry point")として表面化していました。コンストラクタVm::new(とVm::new_unchecked)は今、モジュールをAPI境界でmodule has no entry pointで却下します。 - 時期尚早の
Vm::resumeは今VmError::NotSuspendedです。vm.call(args)の前にvm.resume(value)を呼ぶことは、以前はVmError::InvalidBytecode("cannot resume: VM not suspended")として表面化し、APIの誤用と壊れたバイトコードを混同していました。ランタイムは今、専用のVmError::NotSuspendedヴァリアントを返します。 - 構造検証の却下は今ソーススパンを運びます。
CallIndirectとMakeRecursiveClosureのコンパイルパイプライン却下は、以前Span::default()を付けていて、これは間違ったソース位置を隠していました。各却下は今、起源となる関数またはクロージャ宣言を指すので、エディタは構造に下線を引けます。
さらに見るべき場所
- 完全な言語リファレンスは GRAMMAR.md です(説明的なものです。規範的なリファレンスは
src/parser.rsのパーサーです)。 - 検証器の却下カタログはWHY_REJECTED.md。
- 組み込みAPIサーフェスはEMBEDDING.md。
- 保守的検証の立場は LANGUAGE_DESIGN.md にあります。
ある振る舞いが意図されているのかバグなのか疑わしいとき、パーサ、型検査器、検証器が権威で、ドキュメントは記述的です。
スクリプティングおよび自動化ツールとしての Keleusma
本リファレンスは、devops および sysadmin の自動化を記述するために keleusma コマンドラインツールを使用する、オペレーター向けの説明を集約したものです。番号付きのガイド章は言語を教えます。本ドキュメントは、スクリプトをデプロイ可能なコマンド、ストリーミングフィルタ、または長時間稼働するデーモンへと変える要素を集め、その成果物を署名付きかつ任意で暗号化されたバイトコードとして配布する方法を説明します。
ここでの内容は、それぞれ 1 つの側面を扱う複数の章とリファレンスドキュメントから寄せ集められています。ある話題について他所でより充実した扱いがある場合、本ドキュメントはそれを再述するのではなくリンクします。
想定読者
ランタイムを Rust ホストへ組み込むのではなく、Keleusma プログラムをスタンドアロンなツールとして実行したいオペレーターおよびスクリプト作成者向けです。ランタイムを組み込みたい読者は、代わりに第31章から始めてください。
スクリプトを実行する 3 つの方法
ソーススクリプトは、3 つの相互に交換可能な形式で実行されます。3 つすべてが同じスクリプト引数を受け付けます。
| 形式 | コマンド | プラットフォーム |
|---|---|---|
| 明示的なサブコマンド | keleusma run script.kel | すべて |
| 拡張子による省略記法 | keleusma script.kel | すべて |
| シバンによる実行可能ファイル | chmod +x 後の ./script.kel | macOS および Linux |
シバン形式では、ソースの最初の行が #!/usr/bin/env keleusma である必要があります。レキサーはその行をスキップしますが、診断におけるソース行番号は保持するため、同じファイルは Windows でも引き続きコンパイル可能であり、そこでは keleusma run を通じて実行されます。第2章では、チュートリアルのペースでシバンを紹介します。コンパイル済みのバイトコードファイルもシバンを持つことができ、これは第25章で扱います。
スクリプト引数
スクリプトは shell バンドルを通じて自身の引数を読み取ります。shell::arg(0) はスクリプトパスを返し、shell::arg(1) 以降はランチャーがその後に渡した位置引数を返します。これは POSIX シェルの $0 および $1 の慣習を反映しています。shell::arg_count() は、引数ゼロを含めたエントリの数を報告します。
CLI は、keleusma run script.kel a b c とシバン形式の ./script.kel a b c の両方について位置引数を収集します。-- 終端子は CLI オプションの終わりを示し、それ以降のすべてのトークンは、ダッシュで始まる場合であってもスクリプト引数として扱われます。-- の前にある認識されない先頭ダッシュのトークンは、そのまま渡されるのではなく、フラグの入力ミスとして拒否されます。
./report.kel --since 2026-01-01 -- --raw
その呼び出しにおいて、CLI は認識しないものを一切消費せず、スクリプトは --since、2026-01-01、--raw を位置引数の1番目から3番目として受け取ります。
エントリ種別はデプロイ形態に対応します
スクリプトは3つのエントリ種別のうちの1つを宣言します。CLI はコンパイルされたエントリブロックを検査し、それに応じて駆動します。エントリ種別は、スクリプト作成者がデプロイ形態を決めるための主要なレバーです。第15章、第16章、第17章では言語のセマンティクスを扱います。以下の表は、それぞれを運用上の役割に対応付けます。
| エントリ種別 | 宣言 | 終了 | 運用形態 |
|---|---|---|---|
| アトミック | fn main() -> Word | 1回の呼び出しで完了まで実行されます | ワンショットコマンド。cron ジョブ、ビルドステップ、手動呼び出しに適しています。 |
| ステージド | yield main(tick: Word) -> Word | yield ではなく return します | ティックをまたいで一時停止と再開を行い、その後終了する協調的タスク。 |
| デーモン | loop main(tick: Word) -> Word | shell::exit または終了シグナルの場合のみ | 長時間稼働するサービス。return はエラーとして扱われます。 |
CLI が各種別をどのように駆動するかから、いくつかの運用上の帰結が生じます。
- アトミックな
fn mainは、その返り値が標準出力に表示されます。プロセスの終了ステータスは返り値から取得されません。特定の終了コードを必要とするスクリプトはshell::exit(code)を呼び出します。リポジトリのリンクチェッカーは、まさにこのパターンを使用しています。 loop mainデーモンは--tick-intervalによってレート制限されます。これは100ms、1s、1m、1h、1d、1wのような人間に読みやすい期間を、最大4週間まで受け付けます。ランタイムは反復と反復の間、実際にアイドル状態となるため、長い周期のデーモンは計算ではなくページフォールト回避のコストがかかります。メモリ常駐性の分析についてはMETRICS.mdを、デーモン周期に関する運用者向けガイドについてはSECURITY_POLICY.mdを参照してください。
シェルバンドルを通じた作業の委譲
ホスト登録のネイティブ関数がなければ、言語は純粋な全域関数と生産的発散ループのみを許容します。CLI は shell バンドルを登録し、これがオーケストレーションを可能にします。スクリプトは通常のコマンドラインプログラムに作業を委譲し、それぞれが返す Word の終了コードで分岐し、可変な private data セグメントに実行全体を通じて蓄積し、自身のプロセス終了ステータスを設定します。
| ネイティブ | 目的 |
|---|---|
shell::run(cmd) -> (Word, Text) | sh -c を通じてコマンドを実行します。終了コードと標準出力を返します。標準エラー出力は破棄されます。 |
shell::run_full(cmd) -> (Word, Text, Text) | 上記と同様ですが、終了コード、標準出力、標準エラー出力を返します。 |
shell::run_checked(cmd) -> Text | コマンドを実行します。非ゼロの終了でトラップします。 |
shell::run_timeout(cmd, ms) -> (Word, Text) | ウォールクロックの期限付きでコマンドを実行します。 |
shell::read_file、shell::write_file、shell::append_file | ファイルの入出力。 |
shell::writeln_err、shell::write_err | 標準エラー出力へのログ形式の出力。 |
shell::arg、shell::arg_count | スクリプト自身の引数。 |
shell::exit(code) | 終了ステータスとともにプロセスを終了します。 |
完全な一覧、シグネチャ、およびネイティブごとの契約は STANDARD_LIBRARY.md にあります。バンドルの機能評価とその現行の制限は SHELL_AUDIT.md にあります。
実践例: Markdown リンクチェッカー
リポジトリ自身の Markdown リンクチェッカーである scripts/check-md-links.kel は、オーケストレーターパターンの完全な実践例であり、継続的インテグレーションで実行されています。これは部分的なテキスト走査作業を shell::run を通じて POSIX ツールに委譲し、返された Word に基づいて制御フローを駆動し、第18章で説明する private data セグメントに失敗を蓄積し、shell::exit を通じて結果を伝播します。オーケストレーションを全域とする構成要素である部分演算ファミリーは、第23章で扱います。
署名および暗号化されたバイトコードとしてのスクリプト配布
完成したスクリプトはバイトコードにコンパイルし、改ざん検知が可能で、任意で機密性を持つアーティファクトとして配布できます。2つのポリシーは独立しています。いずれも無効、署名のみ、暗号化のみ、または両方が有効となり得ます。
| ステップ | コマンド |
|---|---|
| 署名鍵ペアの生成 | keleusma keygen --seed sign.seed --public sign.pub |
| 暗号化鍵ペアの生成 | keleusma keygen --kind encryption --seed dest.seed --public dest.pub |
| コンパイル、署名、暗号化 | keleusma compile script.kel --signing-key sign.seed --encryption-key dest.pub -o script.kel.bin |
| 検証および復号しての実行 | keleusma run script.kel.bin --verifying-key sign.pub --decryption-key dest.seed |
署名にはエントリ関数が signed 修飾子を持つことが必要です。そうでない場合、ツールチェーンは署名なしバイトコードを生成し、署名鍵を拒否します。暗号化は X25519 鍵合意を使用するため、成果物は受信者の公開鍵に対して封印され、対応する秘密シードで開かれます。署名と暗号化の設計は 第26章 で、ワイヤーフォーマットは WIRE_FORMAT.md で扱われています。
厳格モードの鍵ストア
管理されたホストでは、信頼に関する判断は運用者の手から取り上げられます。厳格署名モードでは、CLI はシステムディレクトリから信頼された公開鍵を読み込み、ソースファイル、署名なしのバイトコード、およびストア外の鍵で署名されたバイトコードを拒否します。--verifying-key 引数は拒否されるため、権限を持たない運用者がポリシーを緩めることはできません。厳格暗号化モードは、復号鍵ストアについて同様に振る舞います。ディレクトリ、環境変数、および脅威モデルは SECURITY_POLICY.md に文書化されています。
シバンも持つ署名および暗号化されたバイトコードアーティファクトは、改ざん検知が可能で機密性を保ちながら、オペレーティングシステムのシェルを通じて直接実行可能です。これは配布可能なランブックの配信形態であり、宅配便で配送されるメディアについて SECURITY_POLICY.md で説明されています。
複数のスクリプトを監視下で実行する
複数のスクリプトを必要とするワークロードのために、run-tasks サブコマンドは、イベントキュー、監視付き再起動、およびタスクごとの署名・暗号化ポリシーを備えた協調スケジューラを通じて、TOML マニフェストから一連のスクリプトを駆動します。これは examples/rtos/ の協調的 RTOS パターンをデスクトップおよびサーバーに引き上げるものです。
keleusma run-tasks fleet.toml --quiet
マニフェストはスケジューラのティック間隔、各スクリプトの名前・バイトコードパス・never/on_error/always のいずれかの再起動ポリシーを持つ [[task]] テーブル、およびオプションのイベントテーブルを宣言します。マニフェストの形式、検証規則、およびスケジューラのセマンティクスは RUN_TASKS.md に記載されています。
静的保証が成り立たない箇所
言語はそれ以外の場合、最悪実行時間またはメモリを静的に有界化できないプログラムを拒否します。オーケストレーションのネイティブ関数は、その保証が及ばない明示的な境界です。
shell::runおよびその兄弟を通じて生成されたサブプロセスは、検証器がモデル化できない時間とメモリを持ちます。静的な最悪ケース境界はこれらの呼び出しをまたぎません。shell::run_timeoutは呼び出しごとのウォールクロック時間に上限を設けますが、メモリには設けません。shell::read_fileおよび run 系のネイティブ関数は、スクリプトアリーナの予算の外側にあるホストヒープ上に出力バッファを割り当てます。- オーケストレーションのネイティブ関数は可変な外部状態を読み取るため、それらを使用するスクリプトは再現可能ではありません。
shell::exitはホストプロセスを直接終了します。
これらの性質は、言語ではなく、アンビエント権限を持つ shell バンドルに属します。封じ込めまたは証明された境界を必要とするデプロイメントは、完全なバンドルを信頼できないスクリプトに配布するのではなく、厳選されたサブセットまたは独自のより狭いネイティブ関数をカスタムホストに登録します。制約の完全なリストは SHELL_AUDIT.md にあります。
関連する読み物
- 第2章: Keleusma のインストールとインタラクティブプロンプト
- 第15章: 3つの関数カテゴリ、第16章: Yield、第17章: loop 関数
- 第18章: データセグメント
- 第25章: ソースからバイトコードへ、第26章: 署名付きモジュールとホットコードスワップ
SHELL_AUDIT.md、SECURITY_POLICY.md、METRICS.mdSTANDARD_LIBRARY.md、RUN_TASKS.md
StdDSL::Shell 監査
署名および暗号化された loop main スクリプトとして配信される、典型的な DevOps およびシステム管理のワークロードに対する V0.2.1 の stddsl::Shell バンドルの評価。当初の監査では3つの優先度の高いギャップが特定されました。3つすべてが V0.2.1 で解消され、バンドルはデーモンのユースケースに対して現在は十分なものとなっています。スクリプトオーケストレーションの経路に関する後続のレビューでは、shell::arg/arg_count がスクリプト自身の引数ではなくホストプロセスの argv を報告していたこと、および shell::run が捕捉した標準エラー出力を破棄していたことが判明しました。両方とも V0.2.1 で修正されました。本ドキュメントは現在の状態、修正、および残存する制約を記録します。
現在の機能
バンドルは、vm.register_library(stddsl::Shell) を通じて登録される22個のネイティブ関数を提供します。
| ネイティブ | シグネチャ | 目的 |
|---|---|---|
shell::getenv | (name: Text) -> Option<Text> | 環境変数を読み取ります。Some(value) または None |
shell::has_env | (name: Text) -> bool | 環境変数が設定されているかどうかをテストします |
shell::run | (cmd: Text) -> (Word, Text) | sh -c を通じて cmd を実行します。(exit_code, stdout) を返します。捕捉された標準エラー出力は破棄されます。非ゼロの終了コードはエラーではありません |
shell::run_full | (cmd: Text) -> (Word, Text, Text) | sh -c を通じて cmd を実行します。(exit_code, stdout, stderr) を返します |
shell::run_checked | (cmd: Text) -> Text | sh -c を通じて cmd を実行します。stdout を返します。非ゼロの終了コードは NativeError として現れます |
shell::run_timeout | (cmd: Text, ms: Word) -> (Word, Text) | ウォールクロックの期限付きで cmd を実行します。タイムアウト時にはサブプロセスを終了させた後にトラップします |
shell::exit | (code: Word) -> () | 指定された終了コードでホストプロセスを終了します |
shell::sleep_ms | (ms: Word) -> () | サブプロセスを生成することなく、現在のスレッドを ms ミリ秒スリープさせます |
shell::now_unix_ms | () -> Word | 現在の Unix タイムスタンプをミリ秒単位で返します |
shell::read_file | (path: Text) -> Text | ファイルの内容を読み取ります。I/O 障害または非 UTF-8 でトラップします |
shell::write_file | (path: Text, content: Text) -> () | 指定した内容でファイルを置き換えます。入出力の失敗時にはトラップします。 |
shell::append_file | (path: Text, content: Text) -> () | ファイルに追記し、存在しない場合は作成します。入出力の失敗時にはトラップします。 |
shell::file_exists | (path: Text) -> bool | ファイルシステムのエントリが存在するかどうかを判定します。シンボリックリンクをたどります。 |
shell::write_err | (text: Text) -> () | 末尾に改行を付けずに標準エラー出力へ書き込みます。 |
shell::writeln_err | (text: Text) -> () | 末尾に改行を付けて標準エラー出力へ書き込みます。 |
shell::arg_count | () -> Word | スクリプト引数ベクタ(スクリプトパスに加えて位置引数)のエントリ数です。何も設定されていない場合はホストプロセスの argv にフォールバックします。 |
shell::arg | (index: Word) -> Option<Text> | index の位置にあるスクリプト引数です。インデックス 0 はスクリプトパス、1 以降は位置引数です。範囲外または負の場合は None になります。 |
shell::setenv | (name: Text, value: Text) -> () | shell::run を通じて生成されるサブプロセス向けに環境変数を設定します。 |
shell::pid | () -> Word | 現在のプロセス識別子で、pidfile の作成に用います。 |
shell::hostname | () -> Text | オペレーティングシステムが報告するホスト名です。取得できない場合はトラップします。 |
shell::pwd | () -> Text | 現在の作業ディレクトリです。失敗時にはトラップします。 |
shell::cd | (path: Text) -> () | 現在の作業ディレクトリを変更します。失敗時にはトラップします。 |
utility_natives にバンドルされた println と組み合わせることで、スクリプトは出力を生成し、外部プロセスを実行し(標準エラー出力のキャプチャやタイムアウトの有無を選べます)、フォークせずにスリープし、ファイルを直接読み書きし、ログ形式の出力を標準エラー出力へ振り分け、自身の引数を調べ、プロセス環境や作業ディレクトリを操作できます。
解消されたギャップ
初回の監査では、3 つの重大なギャップと 4 つの重要なギャップが特定されました。7 つすべてが V0.2.1 で解消されており、当初の推奨から 1 つの設計調整が加えられています。
| 当初の推奨 | 状態 | 備考 |
|---|---|---|
shell::sleep_ms | 実装済み | 負またはゼロの入力は、拒否するのではなく即座に戻ります。 |
shell::now_unix_ms | 実装済み | Unix タイムスタンプをミリ秒単位で返します。システムクロックが i64 のミリ秒範囲を超える値を返した場合は Word の範囲にクランプされますが、これは実際にはあり得ない状況です。 |
shell::write、shell::writeln、shell::write_err、shell::writeln_err | 一部実装済み | 標準エラー出力向けの変種は存在します。標準出力向けの変種(write と writeln)は追加されませんでした。既存の utility_natives の println が一般的なケースをカバーしており、インライン書き込みのイディオムは十分にまれであるため、後回しにできると判断されたためです。 |
shell::file_exists | 実装済み | シンボリックリンクをたどります。たどらない変種はまだ公開されていませんが、ユースケースが現れれば追加できます。 |
shell::read_file | 実装済み | Text を直接返し、入出力の失敗や非 UTF-8 の内容の場合は NativeError を介してトラップします。これは shell::run_checked のエラーパターンに一致します。当初提案されていた Result<Text> の戻り値型は、言語がまだ汎用の Result 型を備えていないため、単純なトラップに縮小されました。設計上の判断として、ファイル入出力のためだけに Result を導入するのではなく、バンドル全体で一貫したエラー時トラップのパターンを維持することにしました。 |
shell::write_file、shell::append_file | 実装済み | read_file と同じエラー時トラップのパターンです。 |
shell::read_lines | 未実装 | 行単位の反復処理というユースケースは、read_file とホスト側での分割によって満たされます。行単位のエラー時トラップのセマンティクスが実際の要件になった時点で、専用のネイティブ関数を追加できます。 |
shell::arg、shell::arg_count の argv 正確性 | 修正済み | この対はかつて std::env::args を直接読み取っていたため、スクリプトからは自身の引数の前に keleusma、run、およびスクリプトパスが安定したオフセットなしに見えていました。現在は、CLI が設定するスクリプト引数ベクタ(set_script_args)を報告します。インデックス 0 はスクリプトパス、1 以降は位置引数です。CLI は keleusma run とシバン呼び出しの両方で位置引数を収集し、-- ターミネータを尊重します。 |
shell::run の標準エラー出力キャプチャ | 解消済み | shell::run は標準エラー出力をキャプチャして破棄します。新しい shell::run_full は、コマンドの診断ストリームを必要とする呼び出し側のために (exit_code, stdout, stderr) を返します。 |
エラー時トラップの慣習は STANDARD_LIBRARY.md のネイティブごとの契約に記載されています。将来のジェネリックな Result 型またはリファインメント newtype ラッパーがトラップパターンを置き換える可能性があります。それはバンドル設計の問題ではなく言語設計の問題です。
総合評価
現在のバンドルは、単発型とデーモン型の両方のワークロードに対して十分です。V0.2.1 で追加された 3 つの機能(スリープ、時刻、ファイル入出力)は、以前デーモンのユースケースを扱いにくくしていたギャップを解消しました。ループデーモンは、フォークせずに自身のペースを調整し、ファイルから設定を読み取り、ログレコードをファイルや標準エラー出力へ書き込み、経過時間について推論できるようになり、いずれもサブプロセスへ委譲することなく行えます。
署名および暗号化された配信モデルは、今日、アトミックモードと生産的発散モードの両方で完全に利用可能です。
未解決の推奨事項
初回の監査で提案された利便性のためのネイティブ関数(pid、hostname、setenv、pwd、cd、run_timeout)はすべて実装済みで、利用可能な機能の下に記載されています。残る利便性項目は小さく、何も妨げていません。
| 優先度 | ネイティブ | ユースケース |
|---|---|---|
| 低 | shell::read_lines(path: Text) -> Array<Text> | 一般的な行単位の反復処理。現在はスクリプトがファイル全体を読み取り、ホスト側で分割しています。 |
残る制限事項
これらは、欠けているネイティブ関数ではなく、標準バンドルの性質です。Shell バンドルはアンビエントなプロセス権限を付与するため、これらが問題になります。したがって、信頼できないスクリプトに対してこれを提供するホストは、次の注意事項を引き継ぎます。閉じ込めや証明済みの境界を必要とするホストは、バンドル全体ではなく、厳選したサブセットまたは独自のより狭いネイティブ関数を登録すべきです。
- 副作用のあるネイティブ関数では WCET と WCMU は境界付けされません。
shell::run、shell::run_full、shell::run_checked、shell::run_timeoutは任意のサブプロセスを生成しますが、その時間とメモリは検証器がモデル化できません。言語がそれ以外の場面で保証する静的な最悪ケース境界は、これらの呼び出しをまたいでは及びません。shell::run_timeoutは呼び出しごとの実時間に上限を設けますが、メモリには設けず、その上限は静的に検証されるのではなく動的なものです。 - 境界のないホストヒープ割り当て。
read_file、run、run_fullは、スクリプトアリーナの予算の外側にあるホストヒープ上に、ファイルまたはサブプロセスの大きさに応じた出力バッファを割り当てます。大きなファイルや出力の多いサブプロセスは、ホストのメモリを枯渇させる可能性があります。 shell::exitはホストプロセスを終了させます。 これはstd::process::exitを呼び出し、仮想マシンの後始末とホスト側のクリーンアップをすべて回避します。これはスタンドアロンの CLI スクリプトには適切ですが、より大きなプロセス内でスクリプトを実行する埋め込み側にとっては危険です。- 決定性は放棄されます。
getenv、run、now_unix_ms、hostname、pid、pwd、およびファイルシステム関連のネイティブ関数はすべて、可変の外部状態を読み取ります。これらを用いるスクリプトは再現可能ではありません。 read_fileは UTF-8 を必要とします。 非 UTF-8 のファイル内容はバイト列を返すのではなくトラップします。バンドルにはバイナリファイル用のアクセサはありません。set_script_argsはスレッドローカルです。 あるスレッドでset_script_argsを呼び出し、別のスレッドでスクリプトを実行するホストは、設定したベクタではなくstd::env::argsのフォールバックを観測します。CLI は両方をメインスレッドで実行するため、そこでは見えませんが、マルチスレッドの埋め込み側にとっては制約となります。
デーモンのユースケースに関する総合判断
stddsl::Shell は今や、Keleusma で実質的な devops および sysadmin のループデーモンを記述できるほど充実しています。今日機能するユースケースは次のとおりです。運用者が管理するホストへ配備された署名および暗号化済みの成果物、--tick-interval を介した組み込みのレート制限を備えた生産的発散ループでの運用ロジックの実行、設定ファイルの読み取り、ログレコードの標準エラー出力またはディスクへの書き込み、shell::exit を介したクリーンな終了です。入出力の失敗に対するエラー時トラップのパターンは、ホストのファイルシステムが期待と食い違ったときに、デーモンスクリプトへ即座に失敗する振る舞いを与えます。
なぜ私のプログラムは拒否されたのか
Keleusma の検証器は、WCET および WCMU 解析が有界であることを証明できないプログラムを拒否します。これは意図的なものです。この言語の価値提案は実行時間とメモリに関する確定的な境界であり、境界を引く最も安全な場所は解析の現在の能力です。完全な記述については LANGUAGE_DESIGN.md を参照してください。
この文書は、検証器のエラーメッセージを根本原因に対応付け、書き換えを提案します。エラーメッセージは src/verify.rs と src/compiler.rs が生成する実際の文字列です。検証器がプログラムを拒否した場合は、エラーメッセージの部分文字列でこの文書を検索してください。
拒否の分類
拒否されたプログラムは、拒否が根本的なものか解析上のものかによって区別される 2 つのカテゴリに分類されます。
第 1 のカテゴリ、証明可能に境界がないもの。 その構文は、構造上、実行時に境界のない実行を許します。境界が存在しないため、外部の証明書がない限り、将来の検証器の改善によっても許容されることはありません。対処法は、プログラムを境界付けされた形式に書き換えることです。
第 2 のカテゴリ、境界付けされているがまだ証明されていないもの。 実行時の振る舞いは事実として境界付けされていますが、静的な証明がまだ実装されていません。将来の解析は、表層言語を変えることなく、そのようなプログラムを許容される集合へ移せます。対処法は、現在の解析が扱える形式にプログラムを書き換えるか、将来の検証器の拡張を待つことです。
これらのカテゴリが一貫しているのは、この言語が拒否を安全性の性質として扱うためです。Vm::new によって受理されるプログラムは、その境界が証明されたものであって、原理的に境界が存在するものではありません。設計上の根拠については LANGUAGE_DESIGN.md を参照してください。
一般的な拒否メッセージ
MakeRecursiveClosure
type error: closures are not supported; V0.2.0 admits only direct calls and
trait dispatch under the conservative-verification stance. Rewrite as a
top-level fn or trait method.
カテゴリ. 第1。V0.2.0 Phase 4はクロージャ系全体を引退させました — クロージャのオペコードはOpの列挙型から消え、Value::Funcランタイムヴァリアントは消え、型検査器がExpr::Closureを直接却下します。診断は読み込み時の検証器ではなく型検査器から表面化します — 却下がパイプラインで早めに動いたので、エラーメッセージは下げられたオペコードではなく構文に名前を付けます。
トリガー. let束縛が自分自身を名前で参照し、自分の環境スロットを取り込むクロージャ値を生成する。
let factorial = |n: Word| if n <= 1 { 1 } else { n * factorial(n - 1) };
書き換え. Keleusmaのローカルは不変です。ループ間の累積は、データセグメント(これ自体はloop-分類のエントリーポイントからのみアクセス可能)か、フォールドを実行するホスト供給のネイティブのどちらかを必要とします。2つの構造的な書き換えが適用できます。
最初はエントリーポイントをloopに再分類し、データブロックを通じて反復間で累積することです。
data state { result: Word }
loop main(input: Word) -> Word {
state.result = state.result * input;
let _next = yield state.result;
state.result
}
ホストはスクリプトを駆動する前に、vm.set_data(slot, value)を通じてstate.resultを意味のある開始値に初期化しなければなりません。
2つ目はホスト側のフォールドネイティブを登録してfnから呼ぶことです。
use math::fold_product
fn main() -> Word {
math::fold_product([1, 2, 3, 4, 5])
}
選択は、反復回数が無界か(ホストがloopを駆動する)、有限でコンパイル時に既知か(ホストがフォールドネイティブを登録する)によります。
第一級関数参照
first-class function references are not supported in V0.2.0; rewrite `name` as
a direct call site or as a trait-bounded generic
カテゴリ. 第1。V0.2.0 Phase 4は、それが奉仕したクロージャと並んでOp::PushFuncとOp::CallIndirectオペコードを引退させました。トップレベル関数名への裸の参照が(呼び出し位置ではなく)値位置にあったときは、以前はOp::PushFuncにコンパイルされました。コンパイラは今、上記の診断でそのパターンを却下します。
トリガー. let束縛が関数値を保持する、または関数値が引数を流れる。
fn increment(x: Word) -> Word { x + 1 }
fn main() -> Word {
let f = increment; // 却下: 第一級関数参照
f(5)
}
書き換え. 間接ディスパッチを直接呼び出しまたはトレイトメソッドで置き換えます。
fn increment(x: Word) -> Word { x + 1 }
fn main() -> Word {
increment(5)
}
以前は第一級関数を使用していた合成パターンについて、V0.2.0 はモノモーフィゼーションを通じて impl が静的にディスパッチされるトレイト境界付きジェネリックを受理します。トレイトのサーフェスについては LANGUAGE_DESIGN.md を参照してください。
ループ反復境界を抽出できない(Loop Iteration Bound Not Extractable)
loop at instruction <ip> has no statically extractable iteration bound; strict
mode requires loops with fall-through bodies to match the canonical for-range
pattern
カテゴリ. 第2。実行時のループ回数は実行時に既知の値で境界づけられるかもしれませんが、現在の検証器はNがコンパイル時定数の正準的なfor i in 0..N形式からだけ反復回数を抽出します。
トリガー. forループが、終端がパラメータまたは関数呼び出しの結果である範囲を反復する。
fn process(n: Word) -> Word {
for i in 0..n { ... }
0
}
書き換え. コンパイル時定数の境界を使うか、長さが既知の配列を反復します。
fn process() -> Word {
for i in 0..10 { ... }
0
}
境界が真に実行時既知の場合、プログラムは今日の安全な検証サーフェスの外にあり、ループ境界推論の拡張を待つか、ホストが無界のリスクを受け入れた上でVm::new_uncheckedを通じて出荷するかのどちらかです。
再帰呼び出しを検出(Recursive Call Detected)
recursive call detected during WCMU topological sort
カテゴリ. 第1。fnまたはyield関数での直接または相互再帰は言語設計によって却下されます。loopだけが循環的実行を許容し、それも生産的なRESETサイクルを通じてのみです。
トリガー. fnが直接または別のfnを経由して自分自身を呼ぶ。
fn count_down(n: Word) -> Word {
if n <= 0 { 0 } else { count_down(n - 1) }
}
書き換え. 再帰的クロージャの場合と同様に、書き換えは反復回数が有界かどうかによります。コンパイル時に有界な回数の場合は、forループを使って、結果が累積ではなく反復回数によって決まるように計算を構造化します。無界の回数の場合は、循環的な振る舞いをトップレベルのloopブロックに移します。そこでは生産性ルールがそれを許容します。
カウントダウンの純粋関数型書き換えは、スクリプトがステップごとの出力を必要としない場合は無操作です。
fn count_down(n: Word) -> Word {
for _ in 0..n { let _step = 1; }
0
}
ステップごとの出力が必要な場合は、上の再帰的クロージャの書き換えで示したように、loopスクリプトのデータセグメントを通じて累積します。
ストリームブロックに yield がない(Stream Block Missing Yield)
Stream block must contain at least one Yield
カテゴリ. 第1。loop関数は生産性保証を満たすために、反復ごとにyieldしなければなりません。本体にyieldを含まないループ関数は、無界の静かな計算を許容してしまいます。
トリガー. yieldを呼ばない本体を持つloop宣言。
loop main(input: Word) -> Word {
input * 2
}
書き換え. ループ本体にyield式を追加します。
loop main(input: Word) -> Word {
let doubled = input * 2;
let _next = yield doubled;
doubled
}
再入可能ブロックに yield がない(Reentrant Block Missing Yield)
Reentrant block must contain at least one Yield
カテゴリ. 第1。yield-分類された関数は、すべての経路にyield式を含むか、分類が間違っているかのどちらかです。
トリガー. yieldとして宣言されたが本体が決してyieldしない関数。
書き換え. 本体にyield式を追加するか、関数が実際には直接返るのなら分類をfnに変えます。
リソース境界を超過(Resource Bounds Exceeded)
verify_resource_bounds: arena capacity <cap> bytes is below WCMU bound
of <wcmu> bytes
カテゴリ. 精神的には第2、効果的には第1。プログラムはメモリで有界ですが、構成されたアリーナが境界に対して小さすぎます。
トリガー. アリーナが手動で構成されて小さすぎるか、スクリプトのWCMUが期待を超えている。
書き換え. auto_arena_capacity_forを使ってアリーナをモジュールから大きさを決めるか、明示的な容量を増やします。
let cap = keleusma::vm::auto_arena_capacity_for(&module, &[])?;
let arena = Arena::with_capacity(cap);
let vm = Vm::new(module, &arena)?;
WCMU自体が驚きの場合は、verify::module_wcmu出力をチャンクごとに調べて高コストの経路を特定します。検査パターンの例はexamples/wcmu_basic.rsを参照してください。
ブロック境界エラー(Block Boundary Errors)
EndIf at <ip> with no matching If
EndLoop at <ip> with no matching Loop
Break at <ip> outside any Loop block
カテゴリ. 第1。バイトコードレベルのブロック境界が一貫していません。これらのメッセージはユーザーのプログラム問題というよりも、ソースからバイトコードへのパイプラインのバグを示しています。Keleusmaユーザーがこれらのどれかに遭遇した場合、問題はコンパイラのバグです。プロジェクトに対してissueを提出してください。
サーフェスはコンパイルされるが検証器が却下するとき
保守的検証スタンスは、サーフェス言語が検証器の許容集合より広いことを受け入れます。字句解析、構文解析、型検査、コンパイルに成功したプログラムが、それでもVm::newで却下されることがあります。これは第2カテゴリが動いている姿です — 言語は構文を記述するので、検証器はそれを近似ではなく正確に却下できます。
標準的な応答はプログラムを書き換えることです。代替の応答は次のとおりです。
Vm::new_uncheckedを使って無界リスクを明示的に受け入れる。これはWCET契約の外の意図的な誤用で、そのように文書化されています。- 将来の検証器の改善を待ちます。BACKLOG.md が保留中の検証器拡張を追跡しています。B3 のクロージャと B14 の CallIndirect フロー解析は V0.1 時代のエントリであり、V0.2.0 のフェーズ 4 がクロージャサーフェスを完全に削除することで置き換えました。
- 解析が許容すべきと考える却下されたプログラムでissueを提出する。作業例は解析の改善を優先付けするのに価値があります。
相互参照
- LANGUAGE_DESIGN.md が保守的検証の立場の正典的な記述です。
- EXECUTION_MODEL.md が構造検証器の動作を説明しています。
- BACKLOG.md が将来の検証器拡張を追跡しています。
src/verify.rsは実際の却下ロジックとエラーメッセージ文字列を含みます。
セキュリティポリシー
V0.2.1 の厳格モードの署名および暗号化ポリシーに関する運用者向けガイドです。鍵の生成、ポリシーの有効化、デプロイのシナリオ、および信頼モデルを扱います。
想定読者
バイトコードの実行を暗号的に制約しなければならない環境に keleusma-cli を配備する運用者向けです。例としては、ロックダウンされた本番サーバ、エアギャップされたワークステーション、規制対象のワークフロー実行、組み込みフリートの配備などが挙げられます。ここで説明する仕組みは任意かつ追加的なものであり、V0.2.0 の寛容な振る舞いが引き続き既定です。
4 つのポリシー状態
CLI は 2 つの独立した厳格モードを備えています。いずれの組み合わせでも有効にできます。
| 署名ゲート | 暗号化ゲート | ポリシーの概要 |
|---|---|---|
| 無効 | 無効 | V0.2.0 の寛容な既定です。ソースファイル、未署名のバイトコード、--verifying-key を伴う署名付きバイトコード、--decryption-key を伴う暗号化されたバイトコードを受け入れます。 |
| 有効 | 無効 | ソースファイルと未署名のバイトコードは拒否されます。署名付きバイトコードは、署名が登録済みの署名者に対して検証された場合にのみ許容されます。--verifying-key コマンドライン引数は拒否されます。 |
| 無効 | 有効 | 暗号化されていないバイトコード(署名の有無を問わず)は拒否されます。暗号化されたバイトコードは、登録済みの復号鍵が成果物の受信者識別子と一致した場合にのみ許容されます。--decryption-key コマンドライン引数は拒否されます。 |
| 有効 | 有効 | 厳格署名かつ厳格暗号化です。バイトコードは、登録済みの署名者によって署名され、かつ登録済みの受信者に対して暗号化されていなければなりません。 |
厳格署名の有効化
厳格署名を有効化するつまみは 3 つあり、優先順位順に示します。
KELEUSMA_TRUSTED_KEYS_DIR環境変数は、*.pubファイルのディレクトリを指します。各ファイルは 32 バイトの Ed25519 検証鍵を保持します。- プラットフォーム慣習のディレクトリ、Unix 系システムでは
/etc/keleusma/trusted_keys、Windows では%PROGRAMDATA%\keleusma\trusted_keysです。環境変数が設定されていない場合に用いられます。 KELEUSMA_REQUIRE_SIGNED=1環境変数は、信頼ストアが空であっても厳格モードを強制します。あらゆるものに対してフェイルクローズとなります。
厳格署名は、信頼ストアが空でない場合、または強制厳格変数が設定されている場合に有効になります。
検出はフェイルクローズです。不正な鍵ファイル(サイズが誤っている、Ed25519 エンコーディングが無効)は、CLI が明確な診断とともに起動を拒否する原因となります。これにより、信頼リストが部分的になるエッジケースを防ぎます。
厳格暗号化の有効化
暗号化ゲートは並行する仕組みを用います。優先順位順に 3 つのつまみがあります。
KELEUSMA_DECRYPTION_KEYS_DIR環境変数は、*.seedファイルのディレクトリを指します。各ファイルは 32 バイトの X25519 秘密鍵を保持します。- プラットフォーム慣習のディレクトリ、Unix 系システムでは
/etc/keleusma/decryption_keys、Windows では%PROGRAMDATA%\keleusma\decryption_keysです。 KELEUSMA_REQUIRE_ENCRYPTED=1環境変数は、復号鍵ストアが空であっても厳格暗号化モードを強制します。
厳格暗号化には、ランタイム上で encryption Cargo フィーチャが有効になっている必要があります。keleusma-cli バイナリはこのフィーチャを有効にして出荷されます。独自のランタイムをビルドする埋め込み側は、明示的にオプトインします。
鍵の生成
keleusma keygen コマンドは、署名 (Ed25519) または暗号化 (X25519) のいずれかに用いる 32 バイトのシードファイルと 32 バイトの公開鍵ファイルを生成します。この 2 種類の鍵は互換性がありません。
# Ed25519 署名鍵ペア(デフォルト)。
keleusma keygen --seed sign.seed --public sign.pub
# 同等の明示的な形式。
keleusma keygen --kind signing --seed sign.seed --public sign.pub
# X25519 暗号化鍵ペア。
keleusma keygen --kind encryption --seed enc.seed --public enc.pub
シードファイルは秘密の片割れであり、機密として扱わなければなりません。Unix システムでは、keygen は多層防御策としてシードファイルのパーミッションをモード 0600(所有者の読み書きのみ)に厳格化します。公開鍵ファイルは、検証者(署名の場合)や、このホスト向けの成果物を生成するコンパイラ(暗号化の場合)へ配布しても安全です。
keygen は既存のシードファイルや公開鍵ファイルの上書きを拒否します。ローテーションを行うには、まず古いファイルを明示的に削除する必要があります。
成果物のコンパイル
keleusma compile コマンドは、未署名、署名付き、または署名付きかつ暗号化された成果物を生成します。
# 未署名。厳格な署名モードでは実行されません。
keleusma compile script.kel -o script.kel.bin
# 署名付き。ソースはエントリ関数を `signed` 修飾子付きで宣言する必要があります。
keleusma compile script.kel --signing-key sign.seed -o script.kel.bin
# 特定の受信者向けに署名かつ暗号化されたもの。
keleusma compile script.kel \
--signing-key sign.seed \
--encryption-key recipient.pub \
-o script.kel.bin
暗号化には署名が必要です。署名が暗号化された本体を対象とするため、ワイヤーフォーマットは両者を結び付けています。攻撃者は署名を無効化することなく暗号化レイヤーを剥がして平文に差し替えることはできません。
成果物の実行
keleusma run コマンドはコンパイル済みの成果物を実行します。CLI はフレーミングヘッダーからバイトコードの形状を自動検出します。
許容モード(鍵が登録されておらず、強制厳格フラグもない場合)では次のようになります。
# 未署名のバイトコードは無条件に実行されます。
keleusma run script.kel.bin
# 署名付きのバイトコードは、署名が --verifying-key に対して検証された場合に実行されます。
keleusma run script.kel.bin --verifying-key sign.pub
# 暗号化されたバイトコードは、署名が検証され、かつ復号鍵が一致した場合に実行されます。
keleusma run script.kel.bin --verifying-key sign.pub --decryption-key host.seed
厳格モードでは、コマンドラインの鍵フラグは拒否されます。CLI はシステム管理下のトラストストアに登録された鍵のみを使用します。
デプロイのシナリオ
エアギャップされたオフィス配布
本社が、エアギャップされたワークステーションを使用する遠隔地の従業員へ運用スクリプトを配布します。
初期プロビジョニング(信頼できる担当者による、現地作業):
- 本社で従業員ごとの X25519 鍵ペアを生成します。各従業員の秘密鍵を、信頼できる担当者を通じて当該従業員のワークステーションへ届けます。
- 本社の Ed25519 署名鍵ペアを生成します。シードは本社に保管します。公開鍵を各ワークステーションへ配布します。
- 各ワークステーションに
keleusma-cliバイナリをインストールします。 - 本社の検証鍵を各ワークステーションの
/etc/keleusma/trusted_keys/ディレクトリに登録します。 - そのワークステーション固有の X25519 秘密鍵を
/etc/keleusma/decryption_keys/に登録します。
これで各ワークステーションにおいて両方の厳格モードが有効になります。
スクリプトごとの配布(本社):
keleusma compile script.kel \
--signing-key head_office.seed \
--encryption-key workstation_42.pub \
-o script_for_42.kel.bin
この成果物はワークステーション 42 専用に暗号化されています。通信経路上で傍受されたとしても、他のいかなるワークステーションでも復号されません。
配送:宅配便で配送されるストレージメディア(USB メモリ、リムーバブルドライブ)。shebang を備えた成果物は、オペレーティングシステムのシェルを通じて直接実行可能です。
実行(ワークステーション 42):
./script_for_42.kel.bin
CLI は両方の厳格モードを自動的に強制します。スクリプトは、本社によって署名され、かつワークステーション 42 の登録済み鍵で復号可能な場合に実行されます。それ以外の場合は拒否されます。
傍受された成果物:配送経路上で盗まれた成果物は不透明な暗号文です。攻撃者はその内容を読み取ることができません。
侵害されたワークステーション:侵害されたワークステーションは自身の秘密鍵のみを露呈します。他のワークステーション向けの成果物は機密のまま保たれます。
本番サーバーフリート
本番環境ではリリースチームが署名したビルドのみを実行します。
# 各本番サーバーで、ビルドサーバーの検証鍵をインストールします。
sudo cp /tmp/release_key.pub /etc/keleusma/trusted_keys/release.pub
# これで CLI は厳格な署名を強制します。
keleusma run /opt/keleusma/scripts/job.kel.bin
本番サーバー上のローカルオペレーターは、認可されていないスクリプトを実行できません。厳格な署名ポリシーがそれらを拒否します。--verifying-key コマンドライン引数は拒否され、ローカルでの緩和を防ぎます。
規制されたワークフローの実行
医療情報学のパイプラインは、検証済みのワークフロースクリプトのみが患者データ上で実行されたことを監査人に証明しなければなりません。
- 各ワークフローバージョンは承認チームによって署名されます。
- 本番処理ノードは、そのトラストストアに承認チームの検証鍵のみを保持します。
- 監査ログ(ホスト側、スクリプトの外部)は、どの署名付きバイトコードのハッシュがどの入力を実行したかを記録します。
厳格な署名ゲートにより、不正なスクリプトが承認プロセスを迂回できないことが保証されます。
キオスクまたは隔離デプロイ
特定の事前インストール済みスクリプトのみを実行し、それ以外はすべて拒否すべきキオスクの例です。
export KELEUSMA_REQUIRE_SIGNED=1
export KELEUSMA_REQUIRE_ENCRYPTED=1
# 鍵が登録されていません。実行可能なバイトコードはありません。キオスクはロックされています。
登録済みの鍵ストアと組み合わせることで、厳格モードの姿勢を維持しながら、特定の署名付きかつ暗号化されたバイトコードを許可できます。
デーモンのデプロイとティック間隔のケイデンス
CLI の生産的発散ループランナーは、長期稼働する署名付きかつ暗号化されたデーモンワークロードの主要な経路です。--tick-interval <duration> フラグはループのレートを制限します。フラグのリファレンスと、スクリプト側のネイティブ関数 shell::set_tick_interval および shell::tick_interval については CLI README を参照してください。
フェイルファストな構成
セッターのネイティブ関数は、与えられた文字列が有効な人間可読の期間でない場合、実行時に失敗することがあります。不正な引数が最初のイテレーションで表面化し、運用状態が構築される前にデーモンが終了するよう、ループ本体の先頭で shell::set_tick_interval を呼び出してください。推奨されるパターンは次のとおりです。
loop main(tick: Word) -> Word {
let _ = shell::set_tick_interval("1s");
// Operational logic from here.
...
}
実行時の判断に基づいてループの途中でセッターを呼び出すデーモンは、構成エラーを長期間にわたって隠蔽するおそれがあります。オペレーターは、この間隔を静的な構成つまみとして扱うべきです。
機能としてのメモリ常駐
Keleusma のループデーモンを意図的にメモリ上に常駐させることは、割り当て失敗が予期される一連の運用シナリオに対処します。ホストが、新しいプロセスの起動が失敗するほどのメモリ圧迫下にある場合でも、すでに常駐しているデーモンはマッピング済みのページを保持し、実行を継続します。これは CLI のループランナーについて文書化されたユースケースです。
パターン:小さなフットプリント(1 桁のメガバイト単位の常駐セットサイズ。METRICS.md を参照)と長いティック間隔で Keleusma のループデーモンを実行します。このデーモンは、システムが新しいプロセスを起動できない場合でもスケジュールされ続け、すでにロード済みのコードを必要とする診断や復旧作業に利用できます。
デフォルトのゼロ間隔の動作は、スクリプトが yield する限り高速に回転します。これはバッチ処理には適していますが、メモリ常駐でオンコールとなるデプロイには適していません。メモリ常駐デーモンとして実行する場合は、明示的な間隔(--tick-interval 30s、--tick-interval 5m など、ケイデンスの必要性に応じて)を設定してください。
メモリ圧迫下でのクリーンな失敗
ランナーは、ホストがプログラムのメモリを真に満たせない場合にもクリーンに失敗します。検証済みプログラムの最悪ケースのアリーナは境界が定まり既知であるため、CLI はアリーナをその境界に合わせてサイズ設定し、失敗可能な形で割り当てます。それを提供できないホストは、SIGABRT でアボートするのではなく、out of memory: this program needs an N-byte arena という診断メッセージと非ゼロのステータスで終了するため、スーパーバイザーやオーケストレーターが観測して対応できます。事前にホストをプロビジョニングまたは適格性判定するには、keleusma run <file> --print-memory がプログラムの最悪ケースのアリーナフットプリント、すなわち永続部分と一時部分を含む合計を報告し、実行せずに終了します。
4 週間を超えるケイデンス
--tick-interval フラグは、4 週間を超える間隔を拒否します。月次または四半期のケイデンスを扱うオペレーターには 2 つの選択肢があります。
外部スケジューラ。デプロイプラットフォーム上の cron、systemd タイマー、またはそれに相当するものを使用して、所望のケイデンスでワンショットの Keleusma スクリプトを起動します。この手法は、要件がタイミングのみである場合に適しています。
Noop な yield サイクル。より短い間隔(1 時間、1 日)で Keleusma のループデーモンを実行し、内部ティックを所望のケイデンスに対してカウントします。ほとんどのイテレーションは何もせず yield するだけです。周期的なイテレーションが実際の作業を実行します。
loop main(tick: Word) -> Word {
let _ = shell::set_tick_interval("1d");
// Tick counts days. Real work runs every thirtieth day.
let _ = if tick % 30 == 0 {
// Operational logic.
...;
};
let _ = yield tick;
tick
}
この手法は、メモリ常駐が要件の一部である場合(上記参照)に適しています。また、署名付きかつ暗号化された配送モデルをエンドツーエンドで維持します。スクリプトは決して終了せず、決して再起動されません。
鍵の侵害、失効、およびローテーション
このモデルは、侵害された場合の結果が大きく異なる 2 つの秘密鍵に依拠しています。オペレーターはこの非対称性を理解し、各鍵をそれに応じて保護すべきです。
2 つの秘密鍵とそれぞれの影響範囲
- 署名シード(
sign.seed、生成者が保持)は最重要の機密です。 対応する検証鍵を登録したすべてのホストは、それによって署名されたあらゆるものを信頼します。署名シードが漏洩すると、攻撃者はフリート全体で厳格な署名検証を通過する成果物を偽造でき、鍵を登録したすべてのデプロイにわたって真正性が完全に失われます。これを最も厳重に保護してください。オフラインまたはエアギャップされた署名ホストに保管し、アクセスを可能な限り最小のオペレーター集合に制限し、脅威モデルが正当化する場合はハードウェアセキュリティモジュールまたは同等のものを優先してください。これはシステム内で最も影響の大きい単一の機密であり、署名鍵漏洩のケースはモデル全体を掘り崩すものです。 - 復号シード(
dest.seed、受信者が保持)の影響範囲は限定的です。 復号シードが漏洩すると、攻撃者はその 1 つの受信者向けに暗号化された成果物のみを読み取れます。他の受信者向けの成果物は、前述のエアギャップシナリオで述べたとおり機密のまま保たれます。受信者鍵のレベルでは前方秘匿性がないため、復号シードが漏洩すると、攻撃者が保持していた過去の暗号化成果物も、将来のものだけでなく露呈します。各受信者の鍵ペアは受信者ホスト上で生成し、シードが送信されないようにするとともに、keygenが Unix 上で設定するモード 0600 のパーミッションに依拠してください。
有効期限はなく、失効は手動です
登録された鍵は、有効期間が埋め込まれていない生の Ed25519 鍵および X25519 鍵であるため、登録された鍵はオペレーターが削除するまで信頼されます。証明書失効リストもオンライン失効チェックもありません。これはエアギャップされた登録鍵モデルに内在するものです。したがって鍵を失効させるとは、影響を受けるすべてのホストのトラストストアまたは復号鍵ストアから、1 ホストずつそのファイルを削除することを意味し、失効はそのホストが更新された時点で初めて有効になります。この遅延を見込んでください。フリート全体の失効は、デプロイ作業であって即時のブロードキャストではありません。
ローテーション手順
ポリシーとして定期的に、そして侵害の疑いがある場合には直ちにローテーションを行ってください。署名鍵のローテーションは次のとおりです。
- 署名ホスト上で新しい署名鍵ペアを生成します(
keleusma keygen --seed sign.v2.seed --public sign.v2.pub)。 - 新しい検証鍵を配布し、帯域外で認証したうえで(登録の真正性に関する残余リスクの注記を参照)すべてのホストのトラストストアに登録します。移行期間中は古い鍵を登録したままにしておきます。
- 実行可能なまま維持しなければならない成果物を、新しい鍵で再署名します。
- すべてのホストが新しい鍵を保持し、稼働中のすべての成果物が再署名されたら、すべてのトラストストアから古い検証鍵を削除します。古い署名シードはその時点で無力となり、破棄すべきです。
復号鍵のローテーションは対称的です。受信者ホスト上で新しい受信者鍵ペアを生成し、新しい公開鍵を生成者へ配布し、その受信者が依然として必要とする成果物を再暗号化し、それに向けて暗号化された流通中のものがなくなった時点で古い復号シードを削除します。
信頼モデル
信頼されるコンポーネント:
keleusma-cliバイナリ自体。バイナリを差し替えた攻撃者はすべてのポリシーを無効化します。バイナリの完全性はオペレーターの責任です(ファイルシステムのパーミッション、OS レイヤーでの実行ファイル署名、完全性監視ツール)。- トラストストアのディレクトリ。登録済み鍵ファイルを改変した攻撃者は信頼リストを拡張できます。非特権の改ざんを防ぐため、ファイルシステムのパーミッション(root 所有、鍵ファイルはモード 0644、ディレクトリはモード 0755)を使用してください。
- ホストのオペレーティングシステム。root またはカーネルレベルのアクセスを持つ攻撃者は、あらゆるユーザー空間の仕組みを無効化できます。
信頼されないコンポーネント:
- 配送経路(ネットワーク、USB、宅配便)。通信中のバイトコードは、検査可能かつ差し替え可能なものと想定されます。
- ディスク上のバイトコードファイル。バイトコードファイルを差し替えた攻撃者は、その差し替え物もアクティブなポリシーを通過しない限り、それを実行させることはできません。
- デプロイホスト上のローカルオペレーター。非特権ユーザーはポリシーを緩和できません。システム管理下のトラストストアがコマンドライン引数を上書きします。
既知の残余リスク:
- 実行中のランタイムに対してメモリアクセス権を持つ攻撃者は、復号ステップの後に RAM から復号された平文を復元できます。このギャップを塞ぐにはハードウェア分離(Cortex-M55 上の TrustZone-M、その他のプラットフォーム上の同等物)が必要です。この作業は
docs/decisions/BACKLOG.mdの B24 として追跡されています。 - 暗号処理に対するサイドチャネル攻撃(タイミング、電力解析)は、現在の実装では対象外です。純粋な Rust の暗号クレート(
ed25519-dalek、x25519-dalek、aes-gcm)はコアプリミティブの定数時間実装を提供しますが、より広範なホスト環境は他の経路を通じて漏洩する可能性があります。 - リプレイ防止や鮮度の束縛はありません。署名は起源と完全性を証明しますが、新しさは証明しません。成果物には、ホストが検査するタイムスタンプ、シーケンス番号、ノンスが付いておらず、ホストはすでに実行した成果物の記録を保持しないため、過去に有効であった成果物を保持していた攻撃者はそれを再配送でき、ホストはそれを検証して実行します。置き換えられたが一度は有効であった成果物、例えば古いワークフロースクリプトを実行することが有害である場合は、モデルの外部で鮮度を強制してください。完全性が管理された経路で配送する、置き換えの間に署名鍵をローテーションして古い成果物が検証されなくなるようにする、あるいは成果物のハッシュをホスト側で追跡する、といった方法があります。
- 耐量子ではなく古典的な暗号です。Ed25519 と X25519 は耐量子ではありません。今日暗号化された成果物を記録した攻撃者は、暗号学的に意味のある量子コンピュータが存在すれば、それを復号できます。これはハーベスト・ナウ・デクリプト・レイターの脅威であり、長期的に機密なペイロードにとって重要です。ワイヤーフォーマットは ABI の破壊なしに耐量子スキームへ移行するために
scheme_idバイトを予約していますが、そのようなスキームは今日実装されていません。 - 登録の真正性はオペレーターの責任です。トラストストアは登録後の改ざんから保護しますが、初回の公開鍵交換は帯域外で認証されなければなりません。攻撃者が差し替えた検証鍵を登録すると、フリートは攻撃者の署名を信頼するようになり、攻撃者が差し替えた受信者公開鍵に向けて暗号化すると、ペイロードが攻撃者に開示されます。登録の前に、別の経路によるフィンガープリント比較、信頼できる宅配便、または既存の信頼アンカーによって、鍵の由来を検証してください。
- メタデータは秘匿されません。成果物のサイズ、配送のタイミング、および暗号化された成果物のヘッダーに含まれる
recipient_key_idは、経路を観測する者に見えます。内容は保護されますが、配送の事実と形状は保護されません。これは物理的なエアギャップ転送では軽微ですが、観測可能なネットワーク上ではより関連性が高くなります。
相互参照
- 厳格モード署名ゲートの設計記録については RESOLVED.md の R49 を参照してください。
- 暗号化レイヤーの設計記録については RESOLVED.md の R50 を参照してください。
- 基盤となる Ed25519 署名インフラストラクチャについては RESOLVED.md の R42 を参照してください。
- 署名済みおよび暗号化された成果物のワイヤーフォーマットの詳細については WIRE_FORMAT.md を参照してください。
- 復号ステップを含むロード時のパイプラインについては EXECUTION_MODEL.md を参照してください。
- 将来のハードウェア分離作業については BACKLOG.md の B24 を参照してください。
大きな数
V0.2 の数値オーバーフロー構文は、検査付きの算術演算のたびに i128 中間結果の上位半分と下位半分を束縛します。これは、64 ビット符号付き整数である同梱の Word 型に対する多桁算術の要となる仕組みです。本ガイドでは、examples/scripts/09_big_numbers.kel の実例を用いてこのパターンを解説します。
この構文が公開するもの
この構文の表層形式は次のとおりです。
op_expr {
ok(v) => arm_body,
overflow(h, l) => arm_body,
underflow(h, l) => arm_body,
}
ランタイムは op_expr の真の結果を i128 で計算し、それを上位半分と下位半分に分割して (high, low, flag) をオペランドスタックにプッシュします。コンパイラは flag によって 3 つの結果クラス(ok、overflow、underflow)のいずれかにディスパッチし、その分岐の本体内のパターン変数を対応するスロット値に束縛します。ok の分岐は範囲内の結果に対して単一の Word を束縛します。overflow と underflow の分岐は、上位半分と下位半分に対して 2 つの Word 値を束縛します。
上位半分は、加法系の演算では桁上げ出力であり、乗算では真の積の上位 64 ビットです。下位半分(ラップされた i64 の結果)と合わせると、これは連鎖する多桁算術を表現するのに十分です。
パターン:完全な 64x64 -> 128 ビット乗算
fn mul_full(a: Word, b: Word) -> (Word, Word) {
a * b {
ok(v) => (0, v),
overflow(h, l) => (h, l),
underflow(h, l) => (h, l),
}
}
真の積が Word に収まる場合は ok の分岐が発火し、上位半分は定義により 0 になります。積が 64 ビットを超える場合、この構文は overflow の分岐へ経路付けし、真の積の上位 64 ビットを h に束縛します。
実例:2^32 * 2^32 = 2^64。真の積はビットパターン 0x0000000000000001_0000000000000000 を 128 ビット値として解釈したものです。上位半分は 1、下位半分は 0 です。スクリプトの main は 1 を返し、この分解を確認します。
パターン:桁上げ出力を伴う加算
fn add_with_carry(a: Word, b: Word) -> (Word, Word) {
a + b {
ok(v) => (0, v),
overflow(_, l) => (1, l),
underflow(_, l) => (1, l),
}
}
桁上げ出力は、上位半分から直接ではなく overflow クラスから導出されます。符号付きの Word 加算では、i128 中間結果の上位半分は i64 のラップの符号拡張であり、符号なしの桁上げではありません。よりクリーンな抽象化は、結果クラスから桁上げを読み取ることです。ラップされた結果は下位スロットに残ります。
連鎖する 2 桁の加算は、桁上げを次に高い位置へ伝播します。
fn add_two_digits(a_hi: Word, a_lo: Word, b_hi: Word, b_lo: Word) -> (Word, Word) {
let (carry_lo, sum_lo) = add_with_carry(a_lo, b_lo);
let (_, partial_hi) = add_with_carry(a_hi, b_hi);
let (_, sum_hi) = add_with_carry(partial_hi, carry_lo);
(sum_hi, sum_lo)
}
完全な 256 ビット加算では、同じ手順を 4 つの Word 位置にわたって繰り返し、桁上げを各手順に通していきます。
注意点
Word 型は符号付きの i64 です。多桁算術においてこれを符号なしの u64 桁として扱うには注意が必要です。
-
i128 中間結果の上位半分は符号付き算術を反映します。和が
i64::MAXを超える 2 つの非負オペランドの場合、上位半分は0であり、下位半分はラップ(そのビットパターンが符号なしの和の最上位ビットに一致する負のi64)です。桁上げ出力はいずれにせよ 1 であり、overflow クラスから導出できます。 -
和が 65 ビットを超える必要のある 2 つのオペランド(
i64の加算では不可能ですが、乗算を通じて到達可能)の場合、上位半分は真のビット 64〜127 を保持します。乗算の例がこのケースを直接示しています。 -
除算と剰余は、専用の
Op::CheckedDivおよびOp::CheckedModオペコードを経由します。これらは真の結果をi128で計算し、他の検査付き演算と同じ(h, l, flag)の形状にディスパッチします。i64::MIN / -1のコーナーケース(真の結果2^63、high=0, low=i64::MIN, flag=1として分解される)は overflow の分岐へ経路付けされ、i64::MIN % -1のコーナーケース(真の結果0、除算ステップがオーバーフローする)も同様にhigh=0, low=0で overflow の分岐を通してフラグ付けされます。ゼロ除算は、分岐ディスパッチが走る前にオペコードが失敗するため、引き続きVmError::DivisionByZeroでトラップします。
このパターンが適切な場合と適切でない場合
この構文は次の場合に適切です。
- プログラム内の明確に定義された地点で、算術演算が
Word範囲のオーバーフローを検出または回復する必要がある場合。 - 乗算の上位半分が有用な情報を持つ場合(真の 64x64 -> 128 積における最も重要なケース)。
- 加算の桁上げ出力を、より上位の桁に引き継ぐ必要がある場合。
この構文は、任意精度の BigInt 型の代替ではありません。この構文による実行時の多桁算術は機能しますが、人間工学的にゼロコストというわけではありません。将来のイテレーションでは、内部で連鎖したチェック付き演算にコンパイルされるネイティブ算術演算子を備えた、専用の BigInt 標準ライブラリ型が導入される可能性があります。
相互参照
- GRAMMAR.md、セクション 7.5 — 数値オーバーフロー構文の形式文法。
- LANGUAGE_DESIGN.md、「部分演算の処理」 — 設計上の根拠。チェック付き算術はその構文ファミリーの一員です。
examples/scripts/09_big_numbers.kel— このガイドが解説する実践例。tests/big_number_arithmetic.rs— この例をコンパイルして結果を検証する統合テスト。
ピアノロールマニュアル
この文書は piano_roll の例に対する長編の解説書です。この例は、16分音符のティックを駆動する Keleusma スクリプトと、Simple DirectMedia Layer 3 を通じて8ボイスのポリフォニック出力を合成する Rust オーディオホストとを結合しています。この例は一度に読み通せる程度に小さく、かつ Keleusma ホストアプリケーション全体で繰り返し現れるパターンを実践する程度に濃密です。
目次
この文書は3つの主要な節を持ち、それぞれ異なる読者に向けられています。
- 楽曲を作る は、この例またはその応用を通じて再生する新しい
.kelの楽曲を書きたい人に向けたものです。読者はメンタルモデル、データセグメントの慣習、ティックごとの本体構造、および利用可能なホストネイティブ関数呼び出しを学びます。 - 例を発展させる は、この例をより大きなアプリケーションにコピーしたい人に向けたものです。2つの道があります。1つ目はホストループをゲームや音楽エディタなどの別のプログラムに組み込むものです。2つ目は例をそのまま拡張してより本格的なツールに仕上げるものです。両方の道を扱います。
- 組み込みパターン は、Keleusma を別の制御ループアプリケーションに組み込むためのパターンとしてアーキテクチャを学びたい人に向けたものです。ピアノロールが低リスクの標準例として選ばれたのは、オーディオが監査しやすく、ここで機能するパターンがより厳しい領域にも転用できるためです。
この文書とソースの関係
examples/piano_roll.rs 内のモジュールレベルのドキュメントコメントには、ホストネイティブ関数、パラメータ範囲、デフォルト値、波形コード、およびデータセグメントのスロットレイアウトの権威あるカタログが記載されています。この文書はそのカタログを取り巻く説明を行います。ドックストリングが何が利用可能かを列挙するのに対し、この文書はその使い方となぜそのように構成されたのかを説明します。特定のネイティブの引数の形を調べたい読者はドックストリングを参照すべきです。アーキテクチャを理解したい、または新しい楽曲を書きたい読者はこの文書を読むべきです。
バンドルされたロスターには 10 曲が含まれています。曲 0 と 1 は、ホストを紹介する 3 チャンネルのコード進行スクリプトです。曲 2 は、ホストのネイティブ ADSR とリトリガーを実演する 5 チャンネルのバッハのプレリュード編曲です。曲 3 は、ニ短調による長尺の 8 チャンネルのボステーマ負荷試験であり、7/8 拍子への転換、全音音階のスナップダウンのジェスチャー、3 つの重ね合わせ技法(デチューンによるステレオユニゾン、デチューンされたオクターブ重ね、ヘ長調での平行音程ハーモナイゼーション)、および 90 BPM から 250 BPM の間をランプするティックごとの BPM 更新を含む、10 セクションのデュアルピークループにわたって、すべてのホストネイティブをアクティブ、非アクティブ、および動的な状態で行使します。曲 4 は 2 つ目のフルマトリクス実演であり、1024 ティックのループボディにわたって 60 BPM から 300 BPM の間で連続的な正弦波変調のもとでテンポを走らせ、一定のニ短調コードのスケルトン上でループカウントに応じて循環する 4 つの反復バリエーション(Awakening、Descent、Malfunction、Apocalypse)を、アルゴリズミックなシャコンヌの様式で提示します。曲 5 はフェーズミュージックの伝統によるミニマリストのプロセス作品であり、8 チャンネルがニ自然短調の同じ 12 音のパターンを異なる進行速度で演奏し、数分から数時間のタイムスケールにわたってドリフトするチャンネル間のカノン的関係を生み出します。曲 6 はト調ドリアンによるポリメトリックカノンであり、4 つのカノン声部が同じ 4 音の主題を共有しながら異なるティックストライド(1 主題位置あたり 4、3、5、7 ティック、4/4、3/4、5/4、7/4 拍子に対応)で進行し、1680 ティックの拍節上位周期において真の 4 声部ポリフォニック対位法を生み出します。曲 7 は微分音のドローン作品であり、8 声部が A2 基音の純正律による倍音列の部分音 1、2、3、5、7、9、11、13 を、12 平均律の MIDI ピッチと整数セント単位のデチューンオフセットを通じて実現し、ホストの set_detune ネイティブを連続的な全スペクトルのピッチ制御メカニズムとして実演します。曲 8 は 108 BPM のハ長調による教科書的な主流ポップソングであり、平行短調のブリッジと最終コーラスに向けた変ニ長調への半音転調を伴い、実装エンジンが実験的な楽曲を扱うのと同じ容易さで従来の商業ポップの作曲を扱うことを実演します。曲 9 はチップチューンのコアを持つ半実験的なループ作品であり、スケール(ハ長調、イ短調、ニ調ドリアン、ニ調フリギアンドミナント)とリード波形(ノコギリ波、矩形波、パルス波、三角波)の 4 かける 4 のマトリクスにわたる 16 の反復バリエーションを提示し、各反復は混乱ゾーン、ブリッジ、転調、および最終コーラスを備えた 12 セクションのポップ形式です。テンポは区分化されたランプと反復ごとに 1 つの連続正弦波混乱ゾーンを通じて 60 から 300 BPM を移動し、フルメタループあたり約 50 分です。完全な実装仕様については docs/extras/SONG_3_SPEC.md、docs/extras/SONG_4_SPEC.md、docs/extras/SONG_5_SPEC.md、docs/extras/SONG_6_SPEC.md、docs/extras/SONG_7_SPEC.md、docs/extras/SONG_8_SPEC.md、および docs/extras/SONG_9_SPEC.md を参照してください。
メタノート
この文書は、Keleusma ホストアプリケーションのための具体的なドキュメント例としても役立ちます。その節の形、散文の深さ、およびマニュアルとソースドックストリングの関係そのものがパターンです。別の領域で Keleusma ホストを構築するチームは、独自のマニュアルに同じ形を採用できます。
作曲論と音楽理論は対象外です。スクリプト作者向けの節の締めくくりでは、プログラム的作曲をさらに追求したい読者のために、いくつかの持続的なカテゴリ名を列挙しています。
楽曲を作る
楽曲は、エントリーポイント loop main(input: Word) -> Word を持つ Keleusma プログラムです。ホストは起動時に一度 main を呼び出し、その後16分音符のティックごとに Vm::resume を呼び出します。スクリプトの本体は現在のティック値に対して実行され、0個以上のホストネイティブ関数を呼び出し、制御を譲ります。イテレーション間でホストアリーナがリセットされるため、イテレーションごとのアリーナ割り当ては、スクリプト作者にコストをかけずに解放されます。
メンタルモデル
スクリプトはオーディオを合成しません。スクリプトはイベントをスケジュールします。各ボイスの合成状態はホストが所有します。スクリプトはホストネイティブ関数呼び出しを通じてその状態に書き込みます。オーディオスレッドはその状態から読み取り、Keleusma 仮想マシンに一切入ることなくサンプルをレンダリングします。
したがって、楽曲は連携して動作する3つの異なる状態から成ります。データセグメントは、ティックをまたいで持続するチャンネルごとの位置カウンタとシーケンサレベルの状態を保持します。ホストボイス状態は、波形、エンベロープ、スピーカーごとの音量などの楽器パラメータを保持します。スクリプト本体は各ティックで、どのボイスを再生、消音、または再構成するかを決定します。
init ブロック
同梱されるすべての楽曲は、state.init という名前のスロットで保護された一回限りのセットアップブロックで loop main 本体を開始します。このスロットは起動時にゼロであり、ホストが楽曲のロードごとにデータセグメントをゼロクリアするため、ホットスワップをまたいでもゼロのままです。init ブロックは、その楽曲のボイス状態を構成するすべてのホストネイティブを呼び出し、その後 state.init を1に設定します。
init ブロックは、楽器パラメータが構成されるスクリプト内の唯一の場所です。チャンネルは無効な状態で始まります。init ブロックは楽曲が使用するチャンネルを有効化し、その波形、エンベロープ、音量を構成します。init ブロックで言及されていないチャンネルは無効なままで、音を出しません。
データセグメントの慣習
ホストはデータセグメントに23個のスロットを予約します。最初の7スロットはシーケンサレベルの状態を保持します。残りの16スロットは、8ボイスのチャンネル全数に対するチャンネルごとの位置カウンタと残りティックカウンタです。
スロット0、init は、上記で説明した一回限りのセットアップゲートです。
スロット1、loop_count は、進行がラップしたときにスクリプトによって加算されます。楽曲はこれを使用して、後続のループでの動作を変化させます。初回のみのイントロセクションは、loop_count がゼロのときにのみ実行できます。フェードアウトは、loop_count が選択された閾値に達すると開始できます。移調は奇数ループごとに適用できます。
スロット2、section は、楽曲セクションのポインタです。複数部構成を持つ楽曲は、これを使用して現在どのセクションがアクティブかを追跡します。値0は最初のセクションを、1は次のセクションを、という具合に表します。スクリプトはこの値を読み取って正しい音符テーブルにディスパッチします。
スロット3から6、user0 から user3 は、ホストが関知しない状態のための汎用スロットです。適切な用途には、乱数シード、移調オフセット、チャンネルごとのミュートマスク、フィルパターンセレクタ、その他楽曲が追跡する必要のあるものが含まれます。
スロット7から14は idx: [Word; 8]、8ボイスのチャンネル全数に対するチャンネルごとの位置カウンタを保持します。スロット15から22は rem: [Word; 8]、対応するチャンネルごとの残りティックカウンタを保持します。スクリプトは、ch が [0, 8) の範囲の Word である場合、インデックス付き配列形式 state.idx[ch] または state.rem[ch] を通じて各カウンタにアクセスします。コンパイラは、基盤となるフラットスロット領域に対する境界チェック付きのインデックス付き読み書きを発行します。範囲外のインデックスは、別のカウンタを静かにアドレス指定するのではなくトラップします。すべてのチャンネルを走査する必要のあるスクリプトは、for ch in 0..8 { ... state.idx[ch] ... } を使用でき、コンパイラは Value::Array を具体化することなく、そのイテレーションを直接的なインデックス付きスロット読み取りに落とし込みます。
データセグメントは、スキーマレベルではホスト所有、意味レベルではスクリプト所有です。ホストはスロットを予約してゼロクリアします。各スロットが何を意味するかはスクリプトが決定します。上記の慣習は同梱されるすべての楽曲によって守られ、スキーマが一覧全体で一貫して保たれます。
ティックごとの本体構造
init ブロックの後、各チャンネルブロックは同じ形に従います。スクリプトは、チャンネルの残りティックカウンタがゼロかどうかを確認します。ゼロの場合、チャンネルの音符テーブルで次の音符を調べ、その音符が休符かどうかに基づいて host::play または host::silence を呼び出し、残りティックカウンタを音符の長さに設定し、チャンネルの位置カウンタを進めます。そうでない場合、スクリプトは残りティックカウンタをデクリメントします。
このパターンはティックごとのコストを有界に保ちます。各ティックは、一定数のネイティブ関数呼び出しと少量のデータセグメント算術を実行します。Keleusma が言語レベルで提供する有界ステップ保証は、全体を通じて成り立ちます。
シーケンサ状態の扱い
loop_count を使用する楽曲は、チャンネル0の位置カウンタと同じ境界でスロットを加算すべきです。なぜなら、チャンネル0は通常最も長いパートを保持するためです。チャンネル0の位置カウンタがゼロにラップすると、その楽曲は1つの完全な進行を完了しています。インクリメントはラップの直後に行われます。
section を使用する楽曲は、楽曲作者が定義するセクション境界でスロットを進めるべきです。セクションは小節数、特定の loop_count 値、または手動のスケジュールに結び付けられる場合があります。その後 section の読み取りがチャンネルごとの音符テーブルの検索を駆動し、各セクションが独自の進行を持てるようになります。
ホットスワップと楽曲名のアナウンス
ホストは、host::song_name を通じてロードごとに一度、楽曲のタイトルをアナウンスします。init ブロックは静的な文字列リテラルでこのネイティブを呼び出します。同じ文字列での後続の呼び出しはホストによって静かに無視されます。ホットスワップのたびに、ホストは追跡している名前をクリアするため、次の楽曲は無条件にアナウンスされます。
プログラム的作曲のためのリソース
作曲理論と音楽的実践はこの文書の対象外です。プログラム的作曲をさらに追求したい読者は、チップチューンコミュニティが維持するトラッカーモジュールのドキュメント、アルゴリズム作曲に関する概説、および Music Macro Language のドキュメントを参照するとよいでしょう。これらの伝統はいずれも長い歴史と活発なコミュニティを持ち、この文書が及ばない深さを提供できます。
例を発展させる
この節は、この例を自身のプロジェクトに取り込みたい Rust ホスト開発者に向けたものです。2つの道を扱います。1つ目はこの例をより大きなアプリケーションに組み込むものです。2つ目はこの例をより本格的なツールに拡張するものです。この2つの道は関心事の大部分を共有しており、まとめて扱います。
main と run の分離
この例は、アプリケーションの外装を組み込み可能なホストループから分離しています。main 関数はコマンドライン引数の解析やその他のプロセスレベルの関心事を担います。run 関数は、Keleusma 仮想マシンの構築、オーディオデバイスのオープン、ホストネイティブ関数の登録、およびティック・アンド・イールドのサイクルの駆動という、実際のホスト作業を担います。
この例を別のプログラムに組み込む開発者は、run の本体を自身のホストコードにコピーします。この関数は現在引数を取りません。楽曲一覧、アリーナ容量、デフォルトテンポ、または代替のホストネイティブ登録を受け入れるように拡張することは局所的な変更です。
この例をより本格的なツールに拡張する開発者は、run をそのまま保ち、main を成長させます。開始楽曲の選択、代替テンポ、または別のオーディオデバイスのためのコマンドラインフラグは、run に触れることなく main に着地します。この2つの関数は別々に保たれるため、ソースを読む組み込み者は、どの部分をコピーしどの部分を破棄すべきかを認識できます。
ネイティブ登録の境界
register_natives 関数は、この例が提供するすべてのホスト・スクリプト間の橋渡しを担います。各エントリは、共有状態をキャプチャするクロージャを伴う別々の vm.register_native_closure 呼び出しです。このパターンは意図的に冗長です。読者は任意のネイティブを、その名前からその効果まで2回の読みでたどることができます。
本番のホストは、おそらくこれをマクロまたは登録ヘルパーを通じて短縮するでしょう。組み込みガイドで説明されている同梱の register_library トレイトが、そのステップのためにサポートされている抽象です。この例では、データフローがページ上で明示的に保たれるように、それを使用していません。
演習への案内
この例のモジュールレベルのドキュメントコメントは、例が製品ではなく例のままであるように意図的に省かれた10の実質的な機能を列挙しています。このリストには、トレモロ、フィルタエンベロープ、ディレイ、リバーブ、アルペジオ、ポリフォニックボイス割り当て、サンプル再生、周波数変調合成、ウェーブテーブル合成、リアルタイムビジュアライザ、および Musical Instrument Digital Interface 入力についての、おおまかな Rust 側のコード行数見積もりが記載されています。この例を拡張する開発者は、これらのいずれかを出発点として選べます。見積もりはおおまかであり、正確な数を約束するためではなく作業を範囲付けするためのものです。
データセグメント拡張の注意点
すべての楽曲スクリプトで宣言される data state ブロックはデータセグメントスキーマを定義し、一覧内のすべての楽曲は同じスキーマを宣言しなければなりません。スクリプトはレイアウトを宣言します。ホストは空の initial_data ベクタを replace_module に渡すため、セグメントはスワップのたびにゼロに再初期化されます。data state スキーマが現在ロードされているものと異なる楽曲は、ホストが replace_module_unchecked を選択しない限り、replace_module のスキーマハッシュチェックによってホットスワップ時に拒否されます。
推奨は、いずれの楽曲を書く前にデータセグメントスキーマを事前に確定することです。ホスト作者と楽曲作者は、シーケンサ状態、チャンネルごとのカウンタ、およびアプリケーション固有の記録管理にどのスロットが必要かを協働して決めます。スキーマが定まれば、すべての楽曲はそれを対象にします。
しかし、プロジェクト途中での変更は起こります。ホスト作者は、新しいシーケンサ機能をサポートするためにスロットを追加する必要があるかもしれません。そのコストはホスト作者にとっては小さいものの、すでに書かれたすべての楽曲にとっては無視できないものです。なぜなら、各楽曲の data state ブロックを新しいスキーマに一致するように更新しなければならないためです。緩和策には、スキーマ変更をより広範なコンテンツ改訂と一致するようにスケジュールすること、スキーマの成長がスキーマレベルではなくそれらのスロット内で起こるように寛大な user スロットを前もって予約すること、およびスキーマバージョンをどこか目に見える場所に記録することが含まれます。すべての data state ブロックの先頭にバージョン刻印のコメントを付けるのは1つのアプローチです。
Cargo フィーチャの要件
ピアノロールの例は、ビルドに sdl3-example Cargo フィーチャを必要とします。このフィーチャは Simple DirectMedia Layer 3 依存を取り込み、cmake で SDL3 をソースからビルドします。例の Cargo.toml 内の required-features 宣言は compile、verify、sdl3-example を列挙しており、最初の2つはデフォルトで有効です。したがってビルドコマンドは cargo run --release --example piano_roll --features sdl3-example です。
静的な文字列リテラル(同梱楽曲が host::song_name 呼び出しで使用)は V0.2.0 では無条件です。廃止された V0.1.x の text cargo フィーチャはもはや存在しません。異なるオーディオバックエンドを持つ、この例から派生したホストは、sdl3-example の要件を自身のバックエンドが必要とするものに置き換えます。
組み込みパターン
この節は、Keleusma を別の制御ループアプリケーションに組み込むための参考としてこの例を学びたい開発者に向けたものです。オーディオが選ばれた領域であるのは、オーディオが監査しやすく、リアルタイムの期限のプレッシャーがほとんどの開発者にとって馴染み深いためです。ここで機能するパターンは、期限の逸失や状態の破損のコストが著しく高くなり得る他の制御ループにも転用できます。
なぜこの例が標準例として選ばれたか
ピアノロールは Keleusma ホストの全表面を実践します。エントリーポイントとして Stream ブロックを使用します。データセグメントを通じてティックをまたいで状態を持続させます。loop main を通じて決定論的ステップのイテレーションを実行します。ホットコードスワップを乗り切ります。2つのスレッド、1つは Keleusma 仮想マシンを実行し、もう1つは異なるレートで出力をレンダリングするスレッドを協調させます。スクリプトの論理的なイベントとホストの物理的な状態を橋渡しするためにホストネイティブ関数を使用します。
これらのパターンはいずれもオーディオに固有ではありません。同じアーキテクチャは、ホスト所有の状態に対して規則的な周期でイベントをスケジュールする、任意のレートで実行される制御ループに役立ちます。
状態の分離
この例は状態を2つの領域に分割します。ホスト所有の領域は、オーディオボイス、マスター音量、ティック間隔、および楽曲名の重複排除キャッシュを保持します。この状態は同期プリミティブの背後にある Rust 型に存在します。オーディオスレッドがそれを読み取ります。スクリプトはホストネイティブ関数呼び出しを通じてそれに書き込みます。
スクリプト所有の領域は、チャンネルごとの位置カウンタ、ループカウント、セクションポインタ、およびアプリケーション固有のユーザースロットを保持します。この状態は Keleusma データセグメントに存在します。スクリプトはそれを直接読み書きします。ホストはロードのたびにそれをゼロクリアします。
この分離には原則があります。ホスト所有の状態は、Keleusma 仮想マシン呼び出しを行うことなくホストのホットパスが読み取る必要のあるすべてです。スクリプト所有の状態は、その意味論をホストが関知することなく、スクリプトがイテレーションをまたいで推論する必要のあるすべてです。
この分離は一般化されます。Keleusma プログラムが何が起こるべきかを決定し、Rust スレッドがその決定を実行する任意の制御ループは、状態を同じように分割すべきです。スクリプトの不変条件はデータセグメントに存在します。ホストの不変条件は、適切な同期の背後にある Rust 型に存在します。ネイティブ関数の境界が唯一の橋渡しです。その橋渡しは有界で、型付けされ、監査可能であり、これらは真剣なホストがそのアプリケーションの他のすべての境界に望むのと同じ性質です。
ティック・アンド・イールドの境界
スクリプトはオーディオサンプルごとではなく、16分音符のティックごとに一度制御を譲ります。この決定は意図的でした。
サンプルレートでのイールドは細かすぎます。毎秒4万8000サンプルでは、スクリプトはイールドごとに約20マイクロ秒の予算しか持たず、これはジッタから逃れるのが難しく、メインスレッドでも実行されるホスト作業に余裕を残しません。
非常に粗い粒度のイールドも間違っています。それはスワップ、再起動、再構成の機会の間にホストに長い間隔を残し、その間隔中にホストが受け取ったあらゆる入力は、現在のティック境界ではなく次のティック境界に着地することになります。毎分120拍での16分音符のティックは、イールド間で125ミリ秒に着地します。これはスクリプトの作業にとって快適な予算であり、ホットスワップとコマンド処理にとって許容できる遅延です。
一般的な規則は明快です。ティックレートは、スクリプトが決定を行う最も高い有意な周波数であるべきです。10ミリ秒ごとに決定を行う制御ループは、1ミリ秒ごとでも100ミリ秒ごとでもなく、10ミリ秒ごとに制御を譲るべきです。誤った粒度を選ぶホストは、遅延か予算のプレッシャーのいずれかで代償を払います。
ホットスワップの意味論
ホストは、仮想マシンが VmState::Reset 状態にあるときにのみ Vm::replace_module を呼び出します。Reset 状態は Stream ブロックのイテレーション間の境界です。その時点でスクリプトのスタックは空であり、データセグメントが唯一の生きているスクリプト所有の状態です。
ホストは、新しいゼロ初期化されたベクタを replace_module に渡すことでデータセグメントをリセットします。ホストはまた、スワップを発行する前にホスト所有のボイス状態と楽曲名の重複排除キャッシュをリセットします。したがって、入ってくるスクリプトの init ブロックは、両領域のクリーンな状態に対して実行されます。
関連する原則は、ホットコードスワップは、アプリケーションの不変条件が有界でホストが読み取り可能な領域に存在する場合にのみ安全である、ということです。Keleusma は、スワップが Reset 境界で起こることを要求することでこれを強制します。音楽以外の領域に Keleusma を組み込むホストアプリケーションは、同じ制約を尊重すべきです。スワップを乗り切る必要のあるあらゆる状態はデータセグメントに属し、ホストは同じ境界でホスト所有の領域をリセットして、入ってくるスクリプトが古い状態を観測しないようにすべきです。
並行性の選択
この例は、オーディオスレッドとメインスレッド間で共有される単一の Mutex<[Voice; 8]> を使用します。ロックはオーディオコールバックごとに1回のスナップショットコピーのために取得されます。競合ウィンドウはマイクロ秒単位です。
この選択は明快さのために行われました。読者は1回の読みでデータフローをたどります。このパターンは、ロックが競合点となる数百のボイスを持つホストでは通用しないでしょう。その領域で動作する本番のホストは、ボイスごとのアトミック型、ロックフリーキュー、またはトリプルバッファ構成のいずれかに移行するでしょう。
一般的な規則は、期限の予算を満たす最も単純な同期プリミティブを選ぶことです。より複雑なプリミティブへの昇格は、プロファイリングが競合を示すときに正当化できます。抽象的なスケーラビリティの懸念だけでは正当化されません。より単純なプリミティブはデータフローを可視のまま保ち、これは例にとってより重要であり、設計段階で認められる以上に本番でもしばしばより重要です。
ネイティブ登録
この例は、起動時にすべてのネイティブ関数を一度登録します。Keleusma 仮想マシンは、いずれのスクリプトがロードされる前にのみネイティブ関数の登録を受け付けます。したがって登録の境界は、ホストの初期化とホストの運用の間の境界です。
異なるスクリプトに異なるネイティブ関数セットを見せたいホストは、単一の仮想マシンインスタンス内でそれを行うことはできません。利用可能な選択肢は、上位集合を登録してスクリプトがどれを呼ぶかを選べるようにすること、複数の仮想マシンインスタンスを使用すること、またはスクリプト変更の間でホストを再ロードすることです。この例は最初の選択肢を取ります。すべての楽曲は完全なネイティブ関数の表面を見て、楽曲は必要な部分集合を使用します。
トレードオフは、後でネイティブ関数を追加すると、ロードされているすべてのスクリプトが、その新しい関数を使用する場合には再コンパイルを必要とすることです。このトレードオフは、スクリプト一覧が事前に既知でネイティブが早期に安定するホストにとっては許容できます。ネイティブの表面が真に動的なホストは、複数仮想マシンのパターンを検討すべきです。
リセットの慣習
ホストがリセットを所有します。スクリプトは自身をリセットしません。ホストが新しいモジュールをロードするとき、ホストはデータセグメントをクリアし、ホスト所有のボイス状態をクリアし、その他のホスト側のロードごとのキャッシュをクリアします。その後スクリプトの init ブロックが、スクリプトが必要とする値を書き込みます。
この慣習はスクリプトを単純に保ちます。スクリプト作者は、以前の楽曲がステートマシンを予期しない構成に残した場合のための防御的なコードを書きません。ホストがクリーンな状態を保証し、スクリプト作者はその保証を信頼できます。
一般的な原則は、リセットはホストの責任である、ということです。それをスクリプトに押し付けるのが適切なのは、ホストがどの状態をクリアすべきか判断できない場合のみであり、これは実際にはまれです。ほとんどのホスト側の状態は既知の形と既知のリセット値を持ち、ホストはそれを直接クリアできます。
締めくくり
ピアノロールの特定のオペコードは他の領域には転用できません。それらを取り巻くパターンは転用できます。状態の分離、ティック・アンド・イールドの規律、Reset で境界付けられたホットスワップ、単純な同期、ホスト所有のリセット、フラグ付き init ブロックを通じた一回限りのスクリプト初期化は、すべて別の領域の組み込み者が直接採用できるこの例の特徴です。この例は、読者が各パターンを独立して吸収し、それらを別のアプリケーションに合うホストへと組み立てられるように、大きさと形が整えられています。
ローグライクマニュアル
目次
- この文書とソースの関係
- この例が実演すること
- ビルドと実行
- 操作
- ゲームプレイのルール
- ホストとスクリプトのアーキテクチャ
- ゲームティックスクリプトを読む
- ダンジョンジェネレータを読む
- ホットリロード
- プレイヤー AI スクリプトを読む
- 戦闘スクリプトを読む
- 人工知能のアーキタイプを読む
- アイテム効果スクリプトを読む
- ベスティアリスクリプトを読む
- consume スクリプトと descend スクリプトを読む
- 読者のための演習
- 総まとめプロジェクト
- 参照テーブル
この文書とソースの関係
ローグライクの例は、そのソースを2つのディレクトリに分割しています。Rust ホストコードは examples/rogue/ の下にあります。24個の Keleusma スクリプトは examples/scripts/rogue/ の下にあります。examples/rogue/main.rs 内の include_str! 行は相対パスを通じてスクリプトディレクトリを参照し、同じファイル内の SCRIPT_DIR 定数はホットリロードパスのためにそこを指します。このマニュアルは例に対する長編の解説書です。ゲームのルール、ホストのアーキテクチャ、各スクリプトの責務、および読者が組み込みパターンへの習熟を深めるために試みることのできる段階的な演習セットを記述しています。
ベスティアリ、アイテム、ステータスのテーブルは、このマニュアルに再掲するのではなく、ホストソース内にインラインで定義されています。ゲームプレイの節で引用される数値は安定した設計上のデフォルトですが、それらがいつか食い違った場合はソースが権威を持ちます。
この例が実演すること
この例はシンクライアントの哲学を軸に構築されています。Rust ホストは3つのこと、それのみを行います。すなわち、ユーザー入力の取得、ユーザー出力の表示、そして初期化とネイティブの接続を含む Keleusma スクリプト呼び出しの管理です。すべてのゲームプレイのルールは Keleusma スクリプトに存在します。ホストのネイティブは、表示と入力とゲームロジックの間のアプリケーションプログラミングインターフェースの境界です。
7つのパターンが示されています。
第一に、すべてのゲームティックを駆動する loop main スクリプトです。rogue_game.kel はこの例のターンごとのオーケストレータです。ホストはプレイヤー入力ごとに一度これを再開します。本体はプレイヤーコマンドを適用し、すべてのモンスターを反復処理し、各モンスターのアーキタイプをディスパッチし、ターン終了時の記帳処理をティックします。ゲームティックスクリプトを読むを参照してください。
第二に、ワンショットのジェネレータスクリプトです。rogue_dungen.kel はホストネイティブを通じてマップを書き込みます。このスクリプトはフロアへの降下ごとに一度完了まで実行されます。これは副作用のあるネイティブを伴う fn main の自然な使い方です。
第三に、イベントごとの純粋関数です。8つの人工知能アーキタイプのうち7つは、モンスターとワールドの状態のスナップショットを受け取り、アクションタプルを返す fn main スクリプトです。スクリプトはワールドを直接変更しません。ホストが返されたアクションを検証し、変更をコミットします。いくつかのモンスター種は各アーキタイプを共有し、ステータスの差はホスト側の図鑑テーブルに由来します。
第四に、呼び出しをまたいで状態を保持する loop main スクリプトです。ボスのアーキタイプは yield を伴うストリームチャンクの形を使います。データセグメントのターンカウンタが呼び出しをまたいで持続するため、ボスは複数ターンにわたる攻撃パターンを実行します。ボスの loop main の形を参照してください。
第五に、薄いスクリプトを伴うホスト駆動のディスパッチです。アイテム効果スクリプトは、効果識別子を差分とステータスコードに対応付ける小さな match テーブルです。ホストが差分を適用し、ステータスアクションを実行します。この分割により、エンジンに触れるコードはホストに、ゲームプレイのルールはスクリプトに保たれます。
第六に、ホットリロードです。F5 キーバインドは、すべてのスクリプトをディスクから再読み込みし、再コンパイルし、新しいモジュールを実行中の仮想マシンにアトミックにスワップします。ワールドの状態はスワップを生き延びます。ホットリロードを参照してください。
第七に、アクターとしてのプレイヤーです。rogue_player_ai.kel は他のすべての人工知能スクリプトと同じ形をしています。ホストは、モンスターに使うのと同じアクターごとのパスを通じてこれをディスパッチします。プレイヤーの違いは意図の出所であり、ディスパッチの形ではありません。戦闘計算も rogue_combat.kel を通じてスクリプトに存在します。プレイヤーAIスクリプトを読むおよび戦闘スクリプトを読むを参照してください。
この例は保守的検証の規律も示しています。すべてのスクリプトのすべてのループには、静的に抽出可能な反復回数の上限があります。動的な上限は、条件付き本体を持つ固定上限のループとして書かれています。再帰はありません。検証器は出荷されるすべてのスクリプトを受理します。
既知の先送り項目
これらの挙動は、出荷される機能ではなく、意図的に演習として残されています。それぞれは読者のための演習セクションに、該当するエントリ番号とともに記載されています。
- Sleep、Confusion、Remove Curse の巻物はプレースホルダのメッセージを出力します。スクリプト側のディスパッチは正しいステータスコードを生成しますが、ホスト側の適用は先送りされています。演習3.7を参照してください。
- 飢餓の調整が行き過ぎました。 半減された空腹の周期と死体のドロップの組み合わせにより、飢餓は事実上不可能になっています。演習5.1を参照してください。
- 図鑑と装備のスクリプトはF5ホットリロードのパス上にありません。 どちらも起動時に一度読み込まれます。これらのスクリプトを編集するモッダーは再起動しなければなりません。このパターンはリロードを許容しますが、配線はまだ整っていません。
- 武器と防具の名前はホスト側の定数
WEAPON_NAMESとARMOR_NAMESに存在します。 モンスター名はすでに図鑑スクリプトに移されています。装備スクリプトについての同等の移行は機械的であり、演習4.3として登録されています。 - プレースホルダのポーション効果(Speed、Levitation、See Invisible)にはスクリプト側のハンドラがありますが、ホスト側の応答がありません。 ステータスコードは伝播しますが、ホストはそれらを汎用的なメッセージを伴う何もしない操作として扱います。
これらのいずれも通常のプレイを妨げません。出荷される構成で、ゲームは第1フロアから第100フロアの出口まで到達可能です。
ビルドと実行
cargo run --release --example rogue --features sdl3-example
この例には sdl3-example Cargoフィーチャが必要であり、これはウィンドウとイベントループを支える Simple DirectMedia Layer 3 の依存関係を引き込みます。アイテムメッセージシステムが使用する静的な文字列リテラルは V0.2.0 では無条件です。廃止された V0.1.x の text cargoフィーチャはもはや存在しません。
ホストは64×40タイルのグリッドウィンドウを開きます。2行のヘッドアップディスプレイがグリッドの上に、メッセージ行が下に配置されます。各表示タイルは16ピクセル四方なので、ウィンドウは1024×712ピクセルとなり、これはマップ領域の16対10のアスペクト比に一致し、標準的なノートパソコンのディスプレイに無理なく収まります。手続き的なスプライトアートは24ピクセルで作成され、コピー時に16ピクセルに縮小されるため、より大きい作成サイズが元のスプライトの詳細を保ちます。
操作
| キー | アクション |
|---|---|
矢印キー、h、j、k、l | 上下左右の移動、1回の押下につき1タイル。 |
y、u、b、n | 斜め移動。 |
| ピリオド、スペース | その場で1ターン待機。 |
Q | 持っている薬を飲む。薬の効果は即座に解決され、スロットは空になります。 |
R | 持っているスクロールを読む。スクロールの効果は即座に解決され、スロットは空になります。 |
F5 | ディスクからすべてのKeleusmaスクリプトをホットリロード。ホットリロードを参照。 |
| エスケープ | 例を終了。 |
インベントリ管理サーフェスはありません。食料は接触で食べます。金の山は接触でスコアに加算されます。武器と防具はアップグレードが踏み超えられたとき自動装備されます。非アップグレードの武器と防具は、セルをブロックして残されるのではなく接触で破壊されます。薬とスクロールは対応するスロットが空のとき自動拾得されます。スロットがいっぱいの場合、メッセージが地面のアイテムを変装した名前で、持っているアイテムを変装した名前で説明します。
ヘッドアップディスプレイは2行に分かれます。トップ行はヒットポイントピップストリップだけです。プレイヤーのヒットポイント上限は階段の降下ごとに3伸びるので、深い階ではストリップがウィンドウのほとんどを横断できます。それに行全体を与えると、以前の単一行設計が高い階数で生んでいたレイアウトの衝突がなくなります。2行目は左から右に、装備武器のアイコンとティアピップストリップ、装備防具のアイコンとティアピップストリップ、中央のシアン深さティック、現在の階数とプレイヤーの金スコアカウンターを表示するテキスト読み出し、ランごとの外観色で着色された持っている薬とスクロールのアイコン、右の琥珀色の空腹ピップを運びます。ティアピップストリップは0〜19スケールで装備レベルごとに1ピップを埋めます。ビットマップフォントテキストレンダリングはexamples/rogue/text.rsを通じて例にローカルです。
死または勝利時、ゲームはゲームプレイ入力をブロックし、結果のタイトル、最終階、金、ターン数を表示する中央のパネルをオーバーレイします。パネル表示中のキー押下で例を終了します。
ゲームプレイのルール
戦闘
- モンスターに歩み寄ると近接攻撃が始まります。
- ヒットロールは
1d20 + attacker_skill >= 10 + defender_evasionです。 - ナチュラル1は自動的なミス。ナチュラル20は自動的なヒット。クリティカルヒットは防具を引く前に攻撃者のダメージ入力を倍にします。
- ダメージは
attacker_damage - defender_armorで、1で底打ちです。 - プレイヤーの防御回避はプレイヤーの現在のレベルです。プレイヤーの防御armorは装備armorの防御値です。
- Fastアーキタイプのモンスターはターンごとに2回行動します。
ヒットポイント、空腹、回復
- プレイヤーは12/12ヒットポイントで始まります。
- 空腹は100で始まり、2ターンごとに1ティックダウンします。食料は40空腹を回復します。空腹0で、プレイヤーは飢餓から1ターンごとに1ヒットポイントを失います。
- プレイヤーは空腹が正で現在のヒットポイントが最大より低いとき、10ターンごとに1ヒットポイントを回復します。
レベルアップ
- プレイヤーは新しい階に階段を降りるとレベルを得ます。最大ヒットポイントが3増えます。スキルが1増えます。現在のヒットポイントは最大と同じデルタを得るので、新しく追加されたスロットは満杯で入りますが、既存のダメージは持続します。
階とbestiaryの分布
- 100階。下への階段がより深くにつながります。100階は下への階段ではなく出口タイルを持ちます。
- 各階には好みのモンスター種があります。階で生成されるモンスターの半分は好みの種です。残りの半分は、プレイヤーがすでに以前の階で遭遇したモンスター種のプールから等しい重みでサンプルされます。
- 1階は1階の好みのみを持ちます。2階以降は累積プールから抽選します。
アイテム
- 持っている薬スロットと持っているスクロールスロットはそれぞれ1つのアイテムを運びます。持っているアイテムを飲むまたは読むと、効果を解決してスロットを空にします。
- アイテム効果スクリプトが効果を決めます。ホストはスクリプトの返したデルタとステータスアクションを適用します。
- 各薬とスクロールは安定したランごとの隠された identity を持ちます。薬瓶の色やスクロールの模擬タイトルが、プレイヤーがその種のアイテムを最初に使うまでメッセージに表示されます。最初の使用のあと、その種への将来のすべてのメッセージはその真の名前で参照します。
- 倒されたモンスターは倒れたセルに死体を残す確率があります。ドロップ確率と死体の空腹とヒットポイントへの効果は bestiary エントリの shape から来ます。大きな生き物はより多くの肉を産みます。蛇、昆虫、魔導士の shape は毒で、食べると小さなヒットポイントペナルティを与えます。骸骨、幽霊、スライムは何も残しません。死体に足を踏み入れると、同じターンで自動拾得して食べます。毒の死体を避けたいプレイヤーは、攻撃する生き物を廊下ではなく開けた部屋で殺すべきです。そうすれば死体を回避できます。
勝利と死
- 階段の下タイルに足を踏み入れると自動的に降下します。100階の出口タイルに足を踏み入れるとゲームに勝ちます。
- 0ヒットポイントに達するとゲームが終わります。
プレイヤーが移動ではなくテレポートを通じて階段に到着した場合、テレポートが移動リゾルバを通らないので、自動降下は発火しません。階段から離れてまた乗ると、降下が通常通り起動します。
ホストとスクリプトのアーキテクチャ
ホストがすべての可変ゲーム状態を所有します。マップ、プレイヤー、モンスターテーブル、アイテムテーブル、視野バッファ、乱数生成器の状態はすべてRustに住みます。スクリプトは関数パラメータを通じて入力を読み、戻り値を通じて出力を書きます。数少ない変更を行うネイティブはダンジョン生成器のサーフェスに限定されています。
+-------------------+ +-------------------+
| examples/rogue/ | Arc<> | World state |
| host code | <-----> | (map, player, |
| (Rust + SDL3) | | monsters, ...) |
+-------------------+ +-------------------+
|
| per-virtual-machine `register_native_closure` and `vm.call(...)`
v
+------------------------------------------+
| Twenty-four Keleusma virtual machines |
| - rogue_game.kel (loop main) |
| - rogue_dungen.kel (one-shot) |
| - rogue_player_ai.kel (pure fn) |
| - rogue_combat.kel (pure fn) |
| - rogue_book_keeping.kel (pure fn) |
| - rogue_pickup.kel (pure fn) |
| - rogue_move_resolve.kel (pure fn) |
| - rogue_ai_idle.kel (pure fn) |
| - rogue_ai_chaser.kel (pure fn) |
| - rogue_ai_wander.kel (uses rng) |
| - rogue_ai_sleeper.kel (pure fn) |
| - rogue_ai_ranged.kel (pure fn) |
| - rogue_ai_fast.kel (pure fn) |
| - rogue_ai_smart.kel (pure fn) |
| - rogue_ai_boss.kel (loop main) |
| - rogue_ai_tracker.kel (loop main) |
| - rogue_ai_hunter.kel (loop main) |
| - rogue_item_potion.kel (pure fn) |
| - rogue_item_scroll.kel (pure fn) |
| - rogue_descend.kel (pure fn) |
| - rogue_consume.kel (uses natives)|
| - rogue_scroll_apply.kel (uses natives)|
| - rogue_bestiary.kel (startup load)|
| - rogue_gear.kel (startup load)|
+------------------------------------------+
ホストの役割は意図的に狭いです。Simple DirectMedia Layer 3イベントポンプを通じてユーザー入力をキャプチャし、Simple DirectMedia Layer 3レンダリングを通じて世界の状態を表示し、仮想マシンを管理します(ソースをコンパイル、プールを構築、ネイティブを登録、ディスパッチを駆動)。ゲームプレイのルールはスクリプトに住みます。戦闘の数学、プレイヤー入力の解釈、モンスターの振る舞い、アイテム効果、ダンジョン生成はすべてスクリプト側です。ホストのネイティブはほとんどがプリミティブアクセサと、スクリプトが検証器の境界内で複製できないオーケストレーション接着剤です。
各仮想マシンには独自のアリーナがあります。アリーナはプログラムの存続期間中生きます。スクリプトは呼び出しごとにvm.call(...)を呼びます。マシンは呼び出しの間にリセットされます。
ホストソースのモジュール。
| モジュール | 責任 |
|---|---|
main.rs | エントリポイント、SDL3セットアップ、スクリプトコンパイル、イベントループ。 |
world.rs | マップ、プレイヤー、モンスター、アイテム、メッセージログ、視野バッファ。 |
bestiary.rs | 100種のモンスター、易しいから難しいに整理。 |
items.rs | 武器、防具、薬、スクロールのテーブル。ランごとにシャッフルされた外観。 |
tiles.rs | SDL3テクスチャ上のプリミティブから構築されたプロシージャルスプライトアトラス。 |
render.rs | ヘッドアップディスプレイ、視野シェーディングのあるタイルグリッド、モンスターとアイテムの描画、メッセージバー。 |
input.rs | キーボードからコマンドへの翻訳。 |
fov.rs | 8つの八分円での再帰的シャドウキャスティング。 |
combat.rs | d20ロールをサンプルし、戦闘仮想マシンをディスパッチし、ダメージを世界に適用する薄いラッパー。 |
ai.rs | AI、アイテム効果、プレイヤー、戦闘仮想マシンのプール。 |
natives.rs | スクリプトが呼ぶホストネイティブ。ゲームティックネイティブ(host::run_player_turn、host::monster_count、host::run_monster_ai、host::tick_book_keeping)とダンジョン生成器ネイティブを含む。 |
ゲームティックスクリプトを読む
rogue_game.kelはホストがプレイヤー入力ごとに1度再開するloop mainスクリプトです。各ターン本体はプレイヤーのコマンドを適用し、階のすべてのモンスターを反復し、各モンスターのAIスクリプトをディスパッチし、ターン終わりの帳簿をティックします。スクリプトはターンごとに1つの結果コードをyieldし、ホストがそれを読んでプレイを続けるか、次の階を生成するか、勝利でランを終わらせるか、プレイヤーの死でランを終わらせるかを決めます。
loop main(cmd: Word) -> Word {
let player_outcome = host::run_player_turn(cmd);
let outcome = if player_outcome == 0 {
let count = host::monster_count();
for i in 0..24 {
if i < count {
host::run_monster_ai(i);
};
}
host::tick_book_keeping()
} else {
player_outcome
};
let _ = yield outcome;
0
}
これは例の中で2つのパターンを同時に最も直接実演するものです。
1つ目、loop mainと持続状態。スクリプトはホスト呼び出しごとに本体が再実行されるコルーチンです。データセグメントはスクリプトが呼び出し間で覚えていたい任意の状態を運びます。ボスAIスクリプトはターンカウンターを通じて非自明な状態を実演します。ゲームティックスクリプトはデータセグメントを空のままにします。各ターンはネイティブを通じて照会された世界の状態から新たに計算されるからです。
2つ目、each monster forパターン。スクリプトは固定境界forループですべてのモンスタースロットを反復し、現在のモンスター数を超える反復をスキップします。本体の内側で、host::run_monster_ai(i)がモンスターごとの作業を行います。ネイティブが重い作業を行います。モンスターのアーキタイプを調べ、AIプールをロックし、モンスターの現在の位置とプレイヤーの位置とline-of-sightフラグで一致する仮想マシンをディスパッチし、プールロックを解放し、返されたアクションを世界に対して適用します。スクリプトは反復をオーケストレーションするだけです。
スクリプトが消費する4つのネイティブ。
| ネイティブ | 効果 |
|---|---|
host::run_player_turn(cmd) | プレイヤーAIスクリプトをプレイヤーの現在位置と提供されたキー押下でディスパッチし、返されたアクションをモンスターアクションを扱うのと同じアクターごとリゾルバを通じて経路指定。0を返してターンを続行、1で階段が降りられた、2で100階の出口に到達、3でプレイヤーが死んだ。 |
host::monster_count() | 階の現在のモンスター数。 |
host::run_monster_ai(idx) | モンスターidxのAIをディスパッチして、返されたアクションを適用。Fastアーキタイプの2アクションターンを内部で扱う。 |
host::tick_book_keeping() | 2ターンごとに空腹を1進め、空腹なら飢餓ダメージを適用し、条件が許せば1ヒットポイントを回復し、視野を再計算。生きていれば0、空腹で死んだら3を返す。 |
スクリプトとホストが合意するコマンドコード。
| コード | 意味 |
|---|---|
| 0 | 待つ |
| 1〜8 | 北、南、西、東、北西、北東、南西、南東に移動 |
| 9 | 階段を降りるか出口に踏み込む |
| 10 | 持っている薬を飲む |
| 11 | 持っているスクロールを読む |
固定ループ境界は24です。検証器は境界がリテラルなのでループを受け入れます。動的なモンスター数は本体内のif i < countガードによって強制されます。
ダンジョン生成器を読む
rogue_dungen.kelは階の降下ごとに1度呼ばれる1ショットfn main(floor: Word) -> Wordです。スクリプトはホストネイティブを通じてroomsと corridorsのマップをレイアウトします。
高レベルの形。
host::clear_floorを呼んでマップとエンティティリストをリセット。- ランダムな位置に8つの長方形部屋を置く。部屋の寸法は軸ごとに4〜9タイル。
- 連続する部屋を配置順に、中心の間のL字型回廊で接続する。7つの回廊のあと、すべての部屋は部屋0から到達可能。
- プレイヤーを部屋0の中心に置く。
- 階段の下を部屋7の中心に置く。100階では出口タイルを代わりに置く。
- 階の分布に従ってモンスターを生成する。半分は階の好み種で、残りの半分は前の階のプールから抽選される。
- アイテムを生成する。食料3〜5、薬2〜4、スクロール2〜4、武器と防具のアップグレード0〜1、金の山4〜7。
連鎖は正しさで安全です。carve_roomは内部を floor として書くだけで、部屋のアウトラインのためにhost::clear_floorが残した固体の壁に依存しています。2つの重なる部屋は床を 1 つの連結領域にマージし、重なるセルに対して争うのではありません。早期のバージョンのカーバは内部を carve する前に部屋の長方形全体に壁を書いていて、それは重なる部屋を分割して、chain corridor が突破できない小さな到達不能ポケットを生んでいました。その破壊的な壁充填を取り除くと、1 行のコードのコストで連結性が復元されました。
検証器駆動のイディオム。スクリプト内のすべてのループは固定上限と条件付き本体を使うので、構造検証器が反復境界を受け入れます。スクリプトが動的なカウントを欲しがる場合、ループは可能な最大カウントまで走り、本体はif i < countでガードされます。部屋ストレージはデータセグメントで宣言された固定サイズ配列を使います。検証器が動的成長を却下するからです。
スクリプトが消費するホストネイティブ(一部抜粋)。
| ネイティブ | 効果 |
|---|---|
host::clear_floor() | すべてのマップセルを wall にリセット、すべてのモンスターとアイテムを落とす。 |
host::map_set(x, y, tile) | (x, y) のタイル識別子を設定。 |
host::map_get(x, y) | (x, y) のタイル識別子を読み取ります。 |
host::map_w()、host::map_h() | マップの寸法。 |
host::place_player(x, y) | プレイヤーを配置します。 |
host::place_stairs(x, y)、host::place_exit(x, y) | 下り階段または出口。 |
host::spawn_monster(kind, x, y) | 図鑑からスポーンします。 |
host::spawn_item(kind, subtype, x, y) | アイテムテーブルにスポーンします。 |
host::rng_range(lo, hi) | [lo, hi) の範囲のランダムな整数。 |
host::floor() | 現在のフロア番号。 |
タイル識別子は安定しています。0が床、1が壁、2が閉じたドア、3が開いたドア、4が下り階段、5が出口です。スクリプトはこれらを整数リテラルとして使います。
ホットリロード
F5 を押すと、すべての Keleusma スクリプトをディスクから再読み込みし、それぞれを再コンパイルし、実行中の仮想マシンをアトミックに置き換えます。ワールドの状態には触れません。プレイヤーは現在のヒットポイント、空腹度、装備、フロアを保ちます。次のモンスターのターンは新しく再読み込みされた人工知能スクリプトに対してディスパッチされ、次の階段の降下は新しく再読み込みされたダンジョンジェネレータを呼び出します。
リロードは、concat!(env!("CARGO_MANIFEST_DIR"), "/examples/scripts/rogue") を通じてコンパイル時に記録されたディレクトリから読み込みます。初回のスクリプト読み込みは include_str! を使うため、この例はファイルシステムへのアクセスなしで動作します。ホットリロードには、記録されたパスにスクリプトファイルが存在することが必要です。
リロードはアトミックです。すべてのスクリプトが読み込まれ、その後すべてのスクリプトがコンパイルされます。いずれかのソースの読み込みまたはコンパイルが失敗した場合、どの仮想マシンにも触れず、メッセージログが問題のあるスクリプト名とともに失敗を記録します。すべてのソースがコンパイルされれば、すべての仮想マシンが一度にスワップされます。
これは Keleusma 駆動のモッディングワークフローにおける主要なメカニズムです。作者は別のウィンドウでスクリプトを編集し、実行中のゲームで F5 を押し、新しい挙動を即座に観察します。一般的なワークフローの例を示します。
rogue_ai_chaser.kelでモンスターの追跡挙動を調整し、ファイルを保存し、F5を押して、現在画面上にいるモンスターに対して変更を検証します。rogue_item_potion.kelを編集して新しいポーション効果を追加し、対応するポーションを飲んで新しい分岐をテストします。rogue_dungen.kelでダンジョン生成のパラメータを調整し、階段を降りて新しいルールで次のフロアを生成します。
プレイヤーAIスクリプトを読む
この例は、プレイヤーを独自の人工知能スクリプトを持つアクターとして扱います。rogue_player_ai.kel はモンスターのアーキタイプと形が対称的です。ホストは、すべてのモンスターをディスパッチするのと同じアクターごとのパターンを通じて、ターンごとに一度これをディスパッチします。唯一の違いは意図の出所です。モンスターの意図はアーキタイプごとのロジックに由来します。プレイヤーの意図はキーボードに由来し、小さな整数として符号化されます。
fn main(mx: Word, my: Word, cmd: Word) -> (Word, Word, Word) {
// cmd 0..=8: wait or move (eight directions)
// cmd 9: descend stairs
// cmd 10: quaff held potion
// cmd 11: read held scroll
// Returns (action, tx, ty) in the same shape monster archetypes use.
}
ホストの host::run_player_turn(cmd) ネイティブはこのスクリプトをディスパッチし、返されたアクションをモンスターのアクションを処理するのと同じリゾルバに通します。移動と近接攻撃は同じ MoveOrMelee のパスを流れます。プレイヤー専用のアクション(降下、飲む、読む)は、モンスターのアーキタイプが決して発行しない追加のアクションコードです。
この対称性の利点は機械的というよりは概念的です。モンスターのターンがどのように動作するかを理解した読者は、プレイヤーのターンがどのように動作するかを自動的に理解します。
戦闘スクリプトを読む
rogue_combat.kel は命中とダメージのルールを保持します。ホストは乱数生成器から d20 のロールをサンプリングし、攻撃者の技能、攻撃者のダメージ、防御者の回避、防御者の防具、そしてロールを伴って戦闘仮想マシンをディスパッチします。スクリプトは (hit_kind, damage) を返します。ここで hit_kind は、ミスなら0、通常の命中なら1、クリティカルヒットなら2です。
fn main(
attacker_skill: Word,
attacker_damage: Word,
defender_evasion: Word,
defender_armor: Word,
roll: Word,
) -> (Word, Word)
戦闘計算は現在、読者がホストに触れることなく再調整できる単一の Keleusma ファイルです。戦闘のRust側は examples/rogue/combat.rs の薄いラッパであり、ロールをサンプリングし、スクリプトをディスパッチし、返されたダメージをワールドに適用し、メッセージを掲示します。
人工知能アーキタイプを読む
図鑑の各モンスター種は人工知能アーキタイプを指名します。ホストはアーキタイプごとに1つの Keleusma 仮想マシンをインスタンス化します。モンスターのターンごとに、ホストはモンスターの位置、プレイヤーの位置、そして視線フラグを伴ってアーキタイプの仮想マシンを呼び出し、アクションタプルを読み返します。
呼び出し規約。
fn main(mx: Word, my: Word, px: Word, py: Word, sees_player: Word)
-> (Word, Word, Word)
戻り値のタプル (action, tx, ty)。
| アクション | 意味 |
|---|---|
| 0 | 待つ。(tx, ty) は無視。 |
| 1 | セル(tx, ty)に移動または近接攻撃。ホストは歩行可能なら移動を適用、セルがプレイヤーのものなら近接攻撃を解決、隣接でないターゲットを却下。 |
| 2 | セル(tx, ty)で遠距離攻撃。ホストは(tx, ty)がプレイヤーのセルである場合のみ攻撃を適用。 |
例で出荷される8つのアーキタイプ。
| スクリプト | 振る舞い |
|---|---|
rogue_ai_idle.kel | 待つ。固定生物が使う。 |
rogue_ai_chaser.kel | プレイヤーが見えるとき貪欲な1ステップ追跡。 |
rogue_ai_wander.kel | プレイヤーが見えないときランダムなカーディナルステップ。見えるとき貪欲な追跡。 |
rogue_ai_sleeper.kel | プレイヤーが視線に入るまで待ち、それから追跡。 |
rogue_ai_ranged.kel | 隣接で後退。視界内で近接外のとき遠距離攻撃。 |
rogue_ai_fast.kel | chaserのように振る舞う。ホストがターンごとにスクリプトを2回呼び出す。 |
rogue_ai_smart.kel | プレイヤーが見えるとき、優勢な軸でのヒューリスティックステップ。 |
rogue_ai_boss.kel | 遠距離と追跡を交互する4ターン攻撃パターン。データセグメントのターンカウンターを持つloop mainとして実装され、phaseが呼び出し間で持続。現在は100階のBalrog Lordにのみ束縛。ボスのloop main形を参照。 |
rogue_ai_tracker.kel | データセグメントでプレイヤーの最後に見られた位置を覚える。プレイヤーが見えるとき直接追跡、視界外のとき覚えているセルに向かって移動。3スロットデータセグメントを持つloop mainとして実装。レイス、スペクター、バンパイア・スポーン、マインドフレイヤー、ボーンデビルに束縛。 |
wanderスクリプトはランダム方向のためにhost::rng_rangeを使います。ボス以外のすべてのアーキタイプは入力の純粋関数です。
アーキタイプは意図的に最小で、パターンが読みやすいです。より精巧な振る舞いを欲しがる読者は新しいスクリプトを書いて、それをAiKind列挙を通じて bestiary エントリに割り当てます。下の演習セクションでこのケースを通します。
ボスのloop main形
Balrog LordのAIは例のloop mainの実演です。他のすべてのアーキタイプが1ショットfn mainであるのに対し、ボススクリプトはターンカウンターを持つデータセグメントを宣言し、ホスト呼び出しごとに1つのアクションをyieldします。
data state {
turn: Word,
}
loop main(input: (Word, Word, Word, Word, Word)) -> (Word, Word, Word) {
// 本体はアクションを計算する。
// ...
state.turn = state.turn + 1;
let _ = yield action;
(0, 0, 0)
}
5タプル入力は、他のアーキタイプが別々の引数として受け取るのと同じ5フィールドをパックします。タプルへのパックは必要です。vm.resumeがちょうど1つのValueを受け取るからです。本体はstate.turn % 4に基づいて標準の遠距離-そして-追跡判断を実行し、カウンターを増やし、アクションをyieldします。
ホストはボスを他のアーキタイプと違ってディスパッチします。最初のターンはvm.call(...)を呼びます。それ以降のすべてのターンはvm.resume(...)を呼びます。各callまたはresumeは本体を現在の位置から1度走らせます。yieldのあとの末尾の(0, 0, 0)式に到達すると、仮想マシンはVmState::Resetを発します。ホストはResetを歩き越して、再びvm.resumeを呼んで本体を次の反復まで駆動します。結果はホスト駆動の論理的ターンごとに1つのyieldされたアクションです。
データセグメントは呼び出し間で持続するのでstate.turnは単調に増加します。ホットリロードはデータセグメントをリセットするので、スクリプトが置き換えられるたびに4ターンパターンがphase 0から再起動します。
将来のマルチターン振る舞いを持つアーキタイプは同じパターンに従います。ウェイポイント間をパトロールするガーディアン、定期的にミニオンを生成するネクロマンサー、ダメージを取るごとに新しい頭を生やすヒドラは、すべて状態を持つloop mainに自然に適合します。
ゲームオーバー後の loop main スクリプトの再起動
ターンごとのケイデンスを駆動するのと同じReset意味論が、例にゲームオーバーでのクリーンなリプレイ経路も与えます。ホストはプレイヤーがRを押して新しいランを始めたとき、ボス、トラッカー、ハンター仮想マシンを再構築しません。それらのデータセグメントをゼロ化して、仮想マシンを現在のyield点に駐車したままにします。次のディスパッチで本体が再開し、新たにリセットされた状態を観測し、新しいランの最初のアクションを元のランの最初のアクションと形が同じものを生みます。末尾のタプルから本体の先頭への巻き戻しは、すでに 1 つのターンを次から分離している既存の Reset 境界を通して起こります。
データセグメントのリセットが必要な唯一のステップです。本体は反復ごとに世界をネイティブを通じて再読み込みするからです。持続状態は2つの場所だけに住みます。bestiaryエントリはホスト所有でランの間同じです。データセグメントは仮想マシンごとで、リセットがクリアするものです。本体のローカル変数はyieldを越えて持続しません。スタックフレームはスクリプト全体ではなく1つの本体実行の活性化レコードだからです。新鮮なWorldと新しいダンジョンと組み合わさって、リセットされた仮想マシンに対する次の呼び出しは、最初のランと等価のクリーンなリプレイを生みます。
実装は例のai.rsのAiPool::reset_loop_main_dataです。ボス、トラッカー、ハンターのデータセグメントに 3 つのスロット ゼロ化ポインタを歩き、各々に対してゼロを書きます。データセグメントを運ばないモンスターごとのfn mainアーキタイプには等価のリセットは必要ありません。
アイテム効果スクリプトを読む
rogue_item_potion.kelとrogue_item_scroll.kelは、プレイヤーが持っているアイテムを飲むか読むときホストが呼ぶ1ショットfn mainスクリプトです。スクリプトは効果識別子でディスパッチし、5要素タプル(hp_delta, max_hp_delta, skill_delta, status_code, status_arg)を返します。ホストはデルタをプレイヤーに適用し、ステータスアクションを実行します。
ステータスコード(抜粋)。
| コード | アクション |
|---|---|
| 0 | なし。 |
| 1 | Magic mapping。階全体を探索済みとして明らかにする。 |
| 2 | Teleport。プレイヤーをランダムな歩行可能セルにワープ。 |
| 3 | Identify。すべての薬を識別済みとマーク。 |
| 4 | Enchant weapon。装備武器ティアを status_arg だけ進める。 |
| 5 | Enchant armor。装備armorティアを status_arg だけ進める。 |
| 6 | Light。プレイヤーの周りの小さな半径を探索済みとマーク。 |
| 7 | モンスターを検知します。フロアのモンスター数をメッセージログに掲示します。 |
| 8 | Sleep。プレースホルダ、効果なし。 |
| 9 | Confusion。プレースホルダ、効果なし。 |
| 10 | Remove curse。プレースホルダ、効果なし。 |
| 11 | Restoration。プレイヤーをヒットポイント満タンまで回復します。 |
効果ロジックをスクリプトに、ステータスアクションをホストに置くという分割は意図的です。スクリプトは効果が生み出すものを記述します。ホストはエンジン固有の変更を適用します。差分のみの挙動を持つ効果はスクリプトに留まります。視野バッファ、乱数生成器、または装備テーブルに触れる効果は、ステータスアクションの仕組みを通じて処理されます。
ステータスアクションの仕組み自体は2つ目のスクリプトで実行されます。rogue_scroll_apply.kel は (status_code, status_arg) のペアを受け取り、8つのきめ細かいネイティブのうちの1つにディスパッチします。すなわち host::set_explored_all、host::set_explored_radius、host::teleport_player_random、host::identify_all_potions、host::change_weapon_tier、host::change_armor_tier、host::sense_monsters、host::scroll_placeholder です。各ネイティブはそのワールド変更を適用し、そのメッセージをプッシュします。この分割は、モッダーがどのステータスコードがどのネイティブをトリガーするかを変更したり、新しいステータスコードを追加したり、ステータスコードごとに複数のネイティブを組み合わせたりすることを、すべてスクリプトの編集で可能にすることを意味します。ホストのネイティブは小さく直交したまま保たれます。
図鑑スクリプトを読む
rogue_bestiary.kel はモンスターシステムのデータ側の半分です。ホストは起動時にモンスターIDごとに一度このスクリプトを実行し、スクリプトのデータセグメントから解決された値を読み取ります。結果は examples/rogue/bestiary.rs の OnceLock の背後にキャッシュされるため、bestiary::kind(idx) を通じた実行時アクセスは Vec<MonsterKind> に対する単純な読み取りです。
データローダーパターン
図鑑スクリプトは、この例が Keleusma について示すイディオムの実践例です。このパターンはKeleusma クックブックのデータローダのレシピで詳細に記載されています。以下に短い要約を示します。クックブックは最小限の例、バリエーション、そしてこのパターンに手を伸ばすべきタイミングを扱っています。
このパターンは3つの技法を組み合わせます。それぞれは個別に知られています。この組み合わせは Keleusma の特定の制約(モジュールスコープの定数がないこと、固定サイズのデータセグメント、有界な実行)に特によく適合し、この組み合わせこそがここでイディオムたらしめているものです。
第一に、多頭関数ディスパッチが定数テーブルを符号化します。Keleusma にはレコードの配列のためのモジュールスコープの const はありませんが、検証器は整数パターンを伴う多頭関数定義を受理します。エントリごとに1つの頭を持ち、各本体がそのエントリのフィールドを代入することは、機能的に定数配列と等価です。この符号化は、あらゆる本体が直線的なコードであるため検証器に優しく、整数キーが密である場合、ディスパッチ自体はジャンプテーブルにコンパイルされます。
第二に、データセグメントがホストとスクリプトの入出力構造体として機能します。データセグメントは通常、スクリプトが loop main の再開をまたいで保存する状態に使われます。ここでは、ワンショットの純粋関数の出力フィールドを運びます。ホストは vm.call_with_shared を通じて共有バッファを貸し出し、入力(エントリのインデックス)を呼び出し引数として書き込み、呼び出しが返った後に vm.get_shared を通じてバッファから出力(エントリのフィールド)を読み取ります。共有バッファと get_shared/set_shared はホスト境界の一部であるため言語の変更は不要です。転用はスクリプト側にあります。
第三に、負のインデックス規約がテーブルサイズを発見します。fn main(-1) を呼び出すと、スクリプト内部でインデックスが MONSTER_COUNT - 1 に解決され、最後のエントリのフィールドをデータセグメントに書き込み、返ります。ホストは id スロットを読み取って1回の呼び出しでテーブルサイズを知ります。したがってホストは、並行するホスト側の定数からではなくスクリプトからキャッシュのサイズを決めることができ、あらゆる乖離をアサーションが捕捉します。
このパターンは「整数の小さな固定サイズ構造体、序数でインデックス付け」という形を共有する他のテーブルにもよく適します。図鑑の形状ごとの死体ステータスもこのパターンを使います。同じスクリプト内の fn corpse_fill(N) ディスパッチャは、fn fill(N) のステップがすでに書き込んだスロットから形状の序数を読み取り、12エントリのテーブルからさらに3つのスロットを埋めます。
実践例
データセグメントは出力列ごとに1つのフィールドを宣言します。fill(N) の頭が直接設定するフィールド(id、shape、3つのプライマリカラーチャンネル、3つのアクセントカラーチャンネル、5つの戦闘ステータス、aiアーキタイプ、初出フロア、スコア)。corpse_fill(shape) のステップが間接的に設定するフィールド(ドロップ確率、満腹度、ヒットポイント差分)。100個の多頭 fill(N) 関数がエントリごとの定数を書き込みます。12個の多頭 corpse_fill(N) 関数が形状ごとの死体ステータスを書き込みます。fn main(n) エントリポイントは負のインデックスを MONSTER_COUNT + n に解決し、fill(i) を呼び出し、それから corpse_fill(state.shape) に連鎖します。
data state {
id: Word,
shape: Word,
primary_r: Word, primary_g: Word, primary_b: Word,
accent_r: Word, accent_g: Word, accent_b: Word,
max_hp: Word, skill: Word, evasion: Word, damage: Word, armor: Word,
ai: Word,
first_floor: Word,
score: Word,
corpse_drop_chance: Word,
corpse_satiation: Word,
corpse_hp_delta: Word,
}
fn main(n: Word) -> Word {
let count = 100;
let i = if n < 0 { count + n } else { n };
fill(i);
corpse_fill(state.shape);
0
}
fn fill(0) -> Word {
state.id = 0; state.shape = 0;
state.primary_r = 120; state.primary_g = 90; state.primary_b = 60;
state.accent_r = 60; state.accent_g = 40; state.accent_b = 30;
state.max_hp = 3; state.skill = 0; state.evasion = 1;
state.damage = 1; state.armor = 0;
state.ai = 2; state.first_floor = 1; state.score = 1;
0
}
// ninety-nine more fill heads
fn fill(_n: Word) -> Word { 0 }
fn corpse_fill(0) -> Word {
state.corpse_drop_chance = 50; state.corpse_satiation = 8; state.corpse_hp_delta = 0;
0
} // Tiny
// eleven more corpse_fill heads
fn corpse_fill(_n: Word) -> Word { 0 }
モンスター名もスクリプトに存在します。3つ目の多頭ディスパッチャ fn name(N) -> Text は、各エントリの名前を静的文字列として返します。図鑑スクリプトの fn main は最後の式として name(i) を返すため、ホストはデータセグメントが数値フィールドを運ぶ一方で、名前を呼び出しの Finished(StaticStr(...)) ペイロードとして受け取ります。ホストは起動時に一度、返された文字列をリークして、キャッシュ用の &'static str を得ます。Keleusma のデータセグメントは現在ソースで文字列フィールドを受け付けませんが、関数の戻り値はきれいな代替であり、ホスト側の名前テーブルを持たないという結果を許容します。ホストの MONSTER_COUNT 定数はスクリプトの count リテラルを反映します。起動時のアサーションが両者の間のあらゆる乖離を捕捉します。
出荷されるスクリプトは、3つのディスパッチャを合わせておよそ380行になります(100個の fill 頭、12個の corpse_fill 頭、100個の name 頭)。以前のRustの MonsterKind 構造体リテラル形式は、エントリごとに14行に加えて並行する100行の名前配列、加えて3つの12腕の死体メソッドを要していました。図鑑の移行は、エントリごとの密度が最も重要だった箇所に集中した明確な正味の削減です。
消費と降下のスクリプトを読む
2つの短いスクリプトが繰り返し発生するワールド変更をラップするため、ホストの自動拾得ドライバと階段降下のパスを狭いまま保つことができます。
rogue_consume.kel は種類ごとの消費テーブルです。rogue_pickup.kel が consume を返した後、ホストはアイテムの種類とサブタイプを伴って rogue_consume.kel を呼び出します。スクリプトは7つのきめ細かいネイティブのうちの1つにディスパッチします。すなわち host::consume_food、host::take_gold、host::equip_weapon、host::equip_armor、host::stash_potion、host::stash_scroll、host::eat_corpse です。各ネイティブは対応するワールド変更を適用し、対応するメッセージをプッシュします。新しいアイテムの種類を追加するには、1つの新しいネイティブとスクリプトの1つの新しい腕が必要です。
rogue_descend.kel はフロアごとのレベルアップ計算器です。ホストはプレイヤーの現在のレベル、ヒットポイントの上限、ヒットポイント、技能、そして現在のフロアをスナップショットします。スクリプトは降下後の5要素タプルを返します。出荷されるスクリプトは、ヒットポイントの上限に3を、現在のヒットポイントに3を加え、技能に1を加え、レベルを1増分します。モッダーは、ホストに触れることなく成長曲線を変更したり、増分をステータス間で分割したりできます。
読者のための演習
以下の演習は深さで段階分けされています。第一階層の演習は既存のスクリプトやテーブルの値を変更します。第二階層の演習は既存のディスパッチの形に合う新しいコンテンツを導入します。第三階層の演習はディスパッチの形自体を変更します。
各演習は、何を変更するか、どこで変更するか、そして検証の提案を挙げています。ほとんどはテストを書かずに答えられます。徹底したい読者は、追加内容をカバーするように tests/rogue_scripts.rs を拡張できます。
第一階層:パラメータの調整
演習 1.1. プレイヤーの初期ヒットポイントを12から20に引き上げてください。変更箇所を examples/rogue/world.rs の中に見つけ、新しい実行を開始してヘッドアップディスプレイに20個のピップが描画されることを確認してください。検証すべき仮説。ヘッドアップディスプレイの行はウィンドウの全幅にわたるため、ヒットポイントゲージのレイアウトは依然として収まります。
演習 1.2. 空腹の周期を調整してください。出荷時の構成では空腹が2ターンごとに1ずつ減少します。examples/scripts/rogue/rogue_book_keeping.kel の中でティック箇所を見つけ、より速い周期(毎ターン)またはより遅い周期(3ターンごと)を試してください。フロア数が10、20、50の場合の実行時間への影響を観察してください。仮説。速い周期は死体を得るために積極的なモンスター討伐を強います。遅い周期は空腹を無意味にします。いつ戦い、いつ逃げ、いつ毒のある死体を食べるかについて最も興味深い意思決定を生む周期を選んでください。
演習 1.3. ダメージ42の「soulrender」と呼ばれる9番目の武器ティアを追加してください。武器テーブルは examples/rogue/items.rs にあります。ダンジョン生成器のティアクランプ式が、最深フロアに依然として正しく配置することを確認してください。
演習 1.4. Sewer Rat の初出時のヒットポイントを3ではなく5から始まるようにしてください。モンスター図鑑テーブルは examples/rogue/bestiary.rs にあります。新しいゲームを実行し、プレイヤーがラットを倒すのに何回の攻撃を受けるかを観察してください。
演習 1.5. ダンジョン生成器の部屋数を8から12に変更してください。rogue_dungen.kel の部屋ストレージは固定サイズ配列を使用しています。配列宣言は for i in 0..8 ループ内のリテラルと歩調を合わせて変更しなければなりません。推論。配列の境界とループのリテラルが食い違う場合、検証器はスクリプトを拒否します。
ティア2: 形の定まった追加
演習 2.1. Coward と呼ばれる新しい人工知能アーキタイプを追加してください。これはヒットポイントが半分を上回るときにプレイヤーを追跡し、下回るときに逃走します。このスクリプトは他のすべてのアーキタイプと同じ5つの入力を取りますが、monster_hp > monster_max_hp / 2 のときにプレイヤーへ向かう移動アクションを返し、それ以外の場合には離れる移動アクションを返します。スクリプトは現在モンスターのヒットポイントを受け取らないため、この演習には2つの部分があります。第一に、ヒットポイントを追加のパラメータとして受け入れるよう呼び出し規約を拡張してください。第二に、スクリプトを書いてください。調整が必要なホストの呼び出し箇所は examples/rogue/natives.rs::run_one_monster_turn と examples/rogue/ai.rs::AiPool::dispatch にあります。推論。パラメータの追加はすべてのアーキタイプスクリプトに影響するため、呼び出し規約の変更のほうが大きな取り組みとなります。
演習 2.2. プレイヤーの回避を1増加させる「agility」と呼ばれる新しいポーション効果を追加してください。ステータスコードシステムは、コード12を導入してホスト側でそれを適用すれば、すでにこれをサポートしています。スクリプトの変更は rogue_item_potion.kel にあります。ホストの変更は examples/rogue/natives.rs::apply_potion_status にあります。プレイヤーには専用の回避ステータスがありません。examples/rogue/world.rs::Player に1つ追加し、examples/scripts/rogue/rogue_combat.kel にそれを反映させる必要があります。
演習 2.3. ダンジョン生成器にドアを追加してください。廊下が部屋の壁を横切るとき、交差点のタイルは0(床)ではなくタイル識別子2(閉じたドア)にすべきです。rogue_dungen.kel の廊下掘削ヘルパーは現在、壁の上に無条件で床を書き込みます。この演習は、壁の横断ケースを検出して代わりにドアを書き込むことです。ホストはすでに開いたドアと閉じたドアの両方のスプライトをレンダリングします。
演習 2.4. 次の20ターンの間、フロア上のすべてのモンスターを明らかにする「rangedeyes」ステータスコードを追加してください。detect-monsters の巻物は現在、フロアのモンスター数に関する単一のメッセージを返します。本物の全モンスター表示効果は、ホスト側のタイマーを設定し、タイマーがカウントダウンする間、視野に関係なくすべてのモンスターをレンダリングします。ホストの作業は、タイマーフィールドについては examples/rogue/world.rs に、条件付きモンスター描画については examples/rogue/render.rs にあります。
演習 2.5. 標準的な部屋と廊下のレイアウトを、中央に宝を配置し対称的に配置したプレハブ部屋で時折置き換えるヴォールト生成器を追加してください。ヴォールトは10より深いフロアで5分の1の確率で出現すべきです。この演習は、rogue_dungen.kel に vault_floor(floor) 分岐を書き、それをフロアとランダムな抽選でゲートすることです。
演習 2.6. バックグラウンドミュージックを追加してください。examples/piano_roll.rs の例は、Keleusma の loop main スコアシーケンサに対する完全な SDL3 オーディオパイプラインをすでに実演しています。その設計は、SDL3 オーディオデバイスを開き、8ボイスの配列を SDL3 オーディオコールバックと Arc<Mutex<_>> の下で共有し、オーディオスレッド上で矩形波と三角波のサンプルをレンダリングし、16分音符のティックで Keleusma スクリプトからノートトリガーを駆動します。この演習は、そのパターンをローグライクのホストに適応させることです。ソースコードのフックは、SDL3 初期化箇所における examples/rogue/main.rs の add audio processing here コメントです。スクリプトの作業は、ティックごとにノートトリガーを yield する新しい rogue_music.kel の loop main スクリプトです。ホストの作業は、オーディオデバイスを開き、オーディオスレッドを起動し、選択した周期(ターンごと、秒ごと、または新しいフロアのスティングのためのフロア降下ごと)で rogue_music.kel を再開することです。対処すべき懸念。オーディオスレッドとゲームティックは独立して動作します。スコアは、ゲームスレッドをブロックすることなくオーディオスレッドによってサンプリングされるべきです。ピアノロールのミューテックス保護されたボイス配列はこれに対応します。仮説。フロアの深さとともに変化する遅いベースラインは、このターン制の例が現在依存しているプレイヤー入力間の静寂から気を散らすことなく雰囲気を運ぶでしょう。
ティア3: ディスパッチとアーキテクチャの変更
演習 3.1. シャドウキャスティングの視野アルゴリズムを、プレイヤーから半径内のすべてのセルへ光線をたどる対称的なレイキャスティング実装で置き換えてください。同じダンジョン上で2つのアルゴリズム間の可視範囲を比較してください。アルゴリズムの置換は examples/rogue/fov.rs にあります。トレードオフに関する推論。シャドウキャスティングはより高速で、薄い壁をきれいに扱います。レイキャスティングは読みやすく、ホストがすでに個別に実行しているモンスターがプレイヤーを見るチェックのために対称にするのが容易です。
演習 3.2. セーブとロードの面を追加してください。ワールドレコードは、1つ書けば Serialize 可能になります。この演習は、専用のキーバインドでワールド全体をファイルにダンプし、次回の実行時にそれをロードし直すことです。Keleusma スクリプトを変更する必要はありません。ホストの作業は examples/rogue/main.rs と examples/rogue/world.rs にあります。未対処の懸念。アリーナの状態はワールドの一部ではありません。データセグメントに呼び出しごとの状態を保持するスクリプトは、リロード時にそれを失うでしょう。この例に同梱されているダンジョン生成、人工知能、アイテムスクリプトは呼び出しをまたいでステートレスであるため、現在の顔ぶれではこの懸念は理論上のものです。
演習 3.3. ホスト駆動のモンスターターンループを、ターンごとのドライバスクリプトで置き換えてください。現在のアーキテクチャでは、ホストがモンスターを反復処理してそのアーキタイプをディスパッチします。代替案は、ホストがターンごとに1回ワールド状態を入力として呼び出す rogue_game.kel スクリプトであり、ホストはスクリプトが発行したコマンドをコミットします。この演習はマニュアル中で最大のものです。なぜなら、ワールドに触れるすべてのホストネイティブが新しいスクリプトからの呼び出しを受け入れる必要があるからです。見返りは、ターンループのシーケンス全体がユーザー編集可能なコードになることです。
演習 3.4. 手続き的スプライトアトラスを、Portable Network Graphics ファイルから読み込んだスプライトシートで置き換えてください。ホストはすでに SDL3 サーフェスを通じてテクスチャを構築します。Portable Network Graphics ファイルを読み込んでタイルごとのサブテクスチャに切り分けることは、examples/rogue/tiles.rs における機械的な作業です。この演習の本当の課題は、スプライトシートを選ぶことです。モンスター図鑑には100のエントリがあります。
演習 3.5. プレイヤーの遠隔攻撃を追加してください。現在のルールでは、プレイヤーには近接攻撃のみが与えられています。遠隔攻撃の追加は、ワンドまたは弓のアイテムタイプ、照準攻撃のキーバインド、プレイヤーから対象セルへの視線判定、そして距離を考慮したダメージ計算式を意味します。ホストの作業は、新しいアクションコードを認識してそれをリゾルバに通すために examples/rogue/natives.rs にあります。スクリプトの作業は、遠隔アクションを発行するために examples/scripts/rogue/rogue_player_ai.kel に、そして距離減衰を適用するために examples/scripts/rogue/rogue_combat.kel にあります。仮説。モンスター図鑑の遠隔アーキタイプのモンスターは、プレイヤーがその攻撃範囲外から反撃できる場合、不釣り合いに簡単になります。したがってバランスを保つために、ダメージ計算式には距離減衰が必要かもしれません。
演習 3.6. 対称的なモンスターの視線ルールを、モンスターごとのシャドウキャストで置き換えてください。examples/rogue/natives.rs::monster_sees_player における現在の実装は、プレイヤーの視野ビットマップを真実として扱います。プレイヤーがモンスターを見ることができれば、モンスターはプレイヤーを見ることができます。これは構成上対称的ですが、モンスターの知覚をプレイヤーの視点に結びつけます。モンスターのセルから発する同じ8タイル半径の独立したモンスターごとのキャストは、柱のような壁の構成では異なる結果を生むでしょう。キャストを実装して比較してください。仮説。独立したキャストはより「公平」に感じられますが、より高コストです。現在の規模ではコストは無視できます。
演習 3.7. プレースホルダのポーションと巻物の効果を実装してください。ステータスコードのディスパッチ基盤はすでに整っています。残っている部分は次のとおりです。
- Potion of Speed(効果7)。プレイヤーに追加のターンを与えます。プレイヤー状態に
extra_turnsカウンタを追加してください。各ティックで、正であればデクリメントし、yield する前にプレイヤーターンを実行します。仮説。プレイヤーは実質的に2回動きます。ポーションは消費されるため、コストは釣り合っています。 - Potion of Levitation(効果8)。プレイヤー状態に
levitatingタイマーを追加してください。正である間、罠を無視し(将来のリビジョンで罠が実装されたとき)、落とし穴の部屋のユースケースのためにセルを歩行可能として扱います。現在罠は存在しないため、この効果は現在のコンテンツに対しては何もしませんが、メッセージ上では正しく読めます。 - Potion of See Invisible(効果9)。モンスターの種類とインスタンスに
invisibleフラグを追加してください。See Invisible が有効な間、レンダラは不可視のモンスターを可視であるかのように表示します。モンスター図鑑が不可視フラグと、それを使用するモンスターのティアを得ることが必要です。 - Scroll of Sleep(効果8ステータスコード)。各モンスターに
sleeping_turnsフィールドを追加してください。正である間、ホストのモンスターごとのディスパッチはただちに Wait を返し、アーキタイプの仮想マシンを呼び出すことなくカウンタをデクリメントします。読んだときの有効範囲は2です。 - Scroll of Confusion(効果9ステータスコード)。各モンスターに
confused_turnsフィールドを追加してください。正である間、ホストは人工知能が返したアクションを混乱させます。50パーセントの確率で、アクションはランダムな隣接ステップとして再抽選されます。 - Scroll of Remove Curse(ステータスコード10)。武器と防具に呪いフラグを追加してください。一部の武器は呪われた状態で出現します。呪われた装備は外すことができません。Remove Curse はフラグを解除します。
各効果はホスト側で数行です。既存のステータスコード値を再利用すれば、スクリプト側を変更する必要はありません。
ティア4: 研究的な問い
演習 4.1. 100フロアの降下を通じて、依然として認識可能なローグの感触を生み出す人工知能アーキタイプスクリプトの最小集合は何でしょうか。現在の集合は9つです。一度に1つずつ削除してみて、ゲームプレイが目立って劣化するかどうかを評価してください。推論。ボスアーキタイプは単一のモンスターに結び付けられているため、最も置き換えやすいものです。
演習 4.2. 単一フロア上のダンジョン生成器の最悪実行時間は何でしょうか。構造検証器はスクリプトを受け入れますが、測定されたプロファイルは支配的なコストを特定するでしょう。スクリプトのループごとの本体を読み、ホストネイティブ呼び出しを数えることは、公正な出発点です。アリーナに境界づけられたテキストサイズ解析はすでに最悪メモリ使用量が有界であることを証明しています。この演習はその時間版を求めます。
演習 4.3. モンスター図鑑は現在、モンスター名を含めて完全に rogue_bestiary.kel にあります。上記の モンスター図鑑スクリプトを読む の節を参照してください。名前はデータセグメントではなく、スクリプトの戻り値を通じて流れます。残る未解決の問いは、rogue_gear.kel の武器と防具の名前です。同じ「戻り値を名前とする」パターンをギアスクリプトに適用し、examples/rogue/items.rs から WEAPON_NAMES と ARMOR_NAMES を削除してください。移行は、モンスター図鑑の前例があるので機械的です。
演習 4.4. Keleusma の例において、ホストとスクリプトの間の正しい責任分担は何でしょうか。ピアノロールの例はほとんどすべてをスクリプトに置きます。ローグライクの例は行数でおおよそ半々に分けます。両方の例にわたって一方の極端かもう一方かを論じる短いエッセイを書いてください。
ティア5: ゲームバランス
演習 5.1. 飢餓を再び本物の脅威に調整してください。最初のプレイテストではフロア4より前に食料が尽き、不公平に感じられました。出荷時の修正は2つの変更を組み合わせました。空腹は現在、毎ターンではなく2ターンごとに1ずつ減少し、単一の初期糧食での実行時間を倍にしました。倒されたモンスターは現在、プレイヤーが踏んで食べられる死体を残す、形状に由来する確率を持ちます。この組み合わせは行き過ぎました。飢餓はもはやまったく本物の脅威ではありません。とりわけ死体の拾得は、最初の数回の戦闘を過ぎるとプレイヤーを無限に飢えさせないようにします。この演習は、序盤の飢餓を不可避にすることなく、プレイヤーを再び危険地帯に置く構成を見つけることです。調整する候補。examples/scripts/rogue/rogue_book_keeping.kel の空腹周期。examples/rogue/natives.rs::autopickup の食料回復量。examples/scripts/rogue/rogue_dungen.kel::spawn_items の食料出現数。examples/rogue/bestiary.rs の死体ドロップ確率と形状ごとの満腹度。ゲームバランスの技巧は、いつ毒のある死体を食べるか、空腹のティックを節約するために戦いをスキップするか、そして食料を探すのではなく次のフロアへ進むかについて、興味深い意思決定を生む組み合わせを見つけることです。
演習 5.2. フロアの難易度曲線を較正してください。5回の新しい実行をプレイし、フロア20までの各フロアでプレイヤーが何ターンと何ヒットポイントを失うかを記録してください。検証すべき仮説。難易度は滑らかに上昇すべきです。実際には、曲線には新しい形状を導入するフロア境界(サーペントが現れるフロア12あたり、ドラゴンが現れるフロア36あたり)で目に見える不連続性があります。この演習は、モンスター図鑑の first_floor フィールドとダンジョン生成器のモンスター数スケーリングのタイミングを取り直すことで曲線を滑らかにすることです。推論。滑らかな曲線はプレイヤーに成長する力の感覚を与えます。とげとげした曲線はスパイクフロアで苛立ちを生みます。
演習 5.3. 戦利品の分布を監査してください。5フロアの実行でプレイヤーが遭遇する各アイテム種類の数を数えてください。出荷時の構成は、ポーションと巻物を合わせたよりも多くの金塊を生み出す傾向があります。金は現在のルールでは純粋にスコアカウンタであるため、金ドロップの氾濫は無意味に感じられます。この演習は、プレイヤーが食料、ポーション、巻物、金塊にほぼ等しい数で遭遇するようダンジョン生成器の spawn_items を再調整することです。仮説。等しい数はすべての拾得を意図的に感じさせます。偏った数は、稀なドロップを不釣り合いに興奮させます。望むプレイ感触に合う構成を選んでください。
演習 5.4. 戦闘ダメージのスケーリング。出荷時の武器テーブルはティア0のダメージ2からティア19のダメージ118まで、防具テーブルは防御0から40まで進みます。モンスター図鑑のモンスターヒットポイントは害獣の2からボスの200までの範囲です。この演習は、実行をプレイして、プレイヤーの現在の武器が満足のいくものでなくなるフロアを特定することです。武器のダメージ進行またはモンスターのヒットポイントスケーリングのいずれかを調整して、フロア30から50がフロア1から10と同じくらい決定的に感じられるようにしてください。仮説。決定的な感触は、絶対的なダメージではなく攻撃あたりの撃破数から生まれます。そのフロアでモンスター図鑑の半分を一撃で倒す武器は、撃破ごとに3回の振りを要する武器とは異なるリズムを生みます。
総合プロジェクト
これらのプロジェクトはそれぞれ、数日間の集中した作業を要します。複数のティア3の演習を1つの完全な機能に統合します。
総合プロジェクト A. 遠隔戦闘アップデート。 プレイヤーの遠隔攻撃を実装し(演習 3.5)、ヘッドアップディスプレイに矢筒の矢数を追跡する弓のアイテムタイプを追加し、弓を射るキーバインドを追加し、新しい戦闘オプションが意味を持つが支配的にならないようにフロア20から40のモンスター図鑑エントリを再調整してください。成果物。遠隔攻撃を少なくとも10回使用する、フロア1からフロア20までのプレイ可能な実行。
総合プロジェクト B. 実行ごとの永続化。 セーブとロードを実装し(演習 3.2)、階段の降下ごとにオートセーブを追加し、ゲームオーバーを生き延びるハイスコアテーブルを追加してください。成果物。フロアの途中で終了して再開できる、洗練された実行。
総合プロジェクト C. アセット駆動のレンダラ。 手続き的スプライトアトラスを置き換え(演習 3.4)、人型モンスターとプレイヤーに4方向対応のスプライトを追加し、水、溶岩、そしてフロア100の出口タイルにタイルごとのアニメーションフレームを追加してください。成果物。手続き的スプライト版とは目に見えて異なる、レンダリングされた実行。
総合プロジェクト D. クエストシステム。 フロア入場時にホストが問い合わせるクエストボードのネイティブを追加してください。各フロアのクエストは文字列と達成条件(ある種類を10体倒す、ある種類のアイテムを収集する、特定のタイルに到達する)です。プレイヤー構造体にクエスト追跡フィールドを追加してください。達成時に小さな報酬を追加してください。成果物。フロア3のクエストが目に見えて追跡され達成される実行。
参照テーブル
タイル識別子
| ID | タイル |
|---|---|
| 0 | 床 |
| 1 | 壁 |
| 2 | 閉じたドア |
| 3 | 開いたドア |
| 4 | 下り階段 |
| 5 | 出口 |
アイテム種類識別子
| ID | 種類 |
|---|---|
| 0 | 食料 |
| 1 | 金 |
| 2 | 武器 |
| 3 | 防具 |
| 4 | ポーション |
| 5 | 巻物 |
| 6 | 死体 |
人工知能アクションコード
| コード | アクション |
|---|---|
| 0 | 待つ |
| 1 | 対象セルへの移動または近接攻撃 |
| 2 | 対象セルへの遠隔攻撃 |
アイテム効果ステータスコード
完全な表については上記の アイテム効果スクリプトを読む を参照してください。
デフォルトの調整パラメータ
| パラメータ | デフォルト |
|---|---|
| マップサイズ | 64×40タイル |
| 表示タイルサイズ | 16×16ピクセル。スプライトアートは24ピクセルで作成され、コピー時に16へ縮小されます。 |
| 視野半径 | 8タイル |
| 初期ヒットポイント | 12 |
| 初期空腹 | 100 |
| 空腹ティック | 2ターンごとにマイナス1 |
| 食料回復 | 空腹40 |
| ヒットポイント回復 | 空腹が正のとき10ターンごとに1 |
| 飢餓ダメージ | 空腹ゼロのとき毎ターン1 |
| レベルごとのヒットポイント増加 | 3 |
| レベルごとのスキル増加 | 1 |
| フロアごとの部屋数 | 8 |
| フロアごとのモンスター数 | フロア数プラス4、上限12 |
| フロアごとの食料 | 3から5 |
| フロアごとのポーション | 2から4 |
| フロアごとの巻物 | 2から4 |
| フロアごとの武器 | 0から1 |
| フロアごとの防具 | 0から1 |
| フロアごとの金塊 | 4から7 |
メトリクス
人気のスクリプト言語と比較した V0.2.1 Keleusma CLI のリソースフットプリントと実行メトリクス。以下の数値は、組み込みまたは制約された配備を計画する運用者を対象としています。
測定方法論
すべての数値は、2026年5月に単一の Apple M1(arm64-apple-darwin23)ホスト上で測定されました。各ランナーは同じ概念的なワークロードを実行しました。ランタイムを読み込み、些細なプログラムをパースまたはロードし、整数42を印字し、終了します。ランナーごとに5回の実行。典型的な中間値が報告されています。
- バイナリサイズ:
ls -lによって報告される、ストリップされたまたはデフォルトのリリースビルドのサイズ。存在する場合はパスをreadlinkを通じて解決します。 - 最大常駐セットサイズ: macOS 上で
/usr/bin/time -lによって報告されるピーク RSS。ランタイム自身のメモリに加え、任意の時点でページ常駐しているすべての共有ライブラリを含みます。 - ピークメモリフットプリント:
/usr/bin/time -lによって報告されるピークのダーティ + 匿名メモリ。共有ライブラリのテキストページを除外するため、最大 RSS よりも低くなります。 - 経過サイクル: プロセス開始からプロセス終了までに消費された CPU サイクル。
- 実時間: プロセス開始から終了までの実時間。測定ツールによって10 msに丸められています。
Keleusma の測定は、暗号化機能スタック(X25519 + AES-256-GCM + HKDF-SHA-256)を含む、完全な厳格モード対応の CLI バイナリを実行します。測定されたバイナリは、運用者が配備するであろうものです。
結果
| ランナー | バイナリサイズ | 最大 RSS | ピークフットプリント | サイクル数 | 実時間 |
|---|---|---|---|---|---|
| bash 5.x (システム) | 1.3 MB | 1.85 MB | 1.26 MB | 6.3 M | < 10 ms |
| Keleusma 0.2.1 | 2.1 MB | 2.85 MB | 1.49 MB | 8.4 M | < 10 ms |
| Lua 5.4 (一般的な公表値) | 0.3 MB | ~2.0 MB | ~1.5 MB | ~7 M | < 10 ms |
| Python 3.13 (MacPorts) | 34 KB のランチャー + フレームワーク | 11 MB | 5.7 MB | 54.8 M | 20 ms |
| Ruby 3.1 (MacPorts) | 34 KB のランチャー + フレームワーク | 30.7 MB | 25.7 MB | 128 M | 40 ms |
| Node.js (MacPorts) | 77 MB | 42.5 MB | 13.7 MB | 108 M | 40 ms |
Python と Ruby のランチャーバイナリは共有ライブラリを読み込む小さなシムであり、実質的なコードはディスク上の Python/Ruby フレームワークの動的ライブラリに存在します。ランチャーのサイズはインストール全体の占有量を過小に見せています。Node.js は V8 とランタイムの大部分を静的リンクするため、バイナリが大きくなります。
Lua の行は、このマシンでは Lua が利用できなかったため、実測値ではなく Lua 5.4 リファレンスビルドの公表値を用いています。数値は、liblua を静的リンクした標準的な ./configure && make ビルドを代表するものです。
主要な調査結果
Keleusma のリソース消費は本質的に bash と同等の水準です。 バイナリサイズ、RSS、ピーク時の占有量、サイクル数はいずれも、同一の些細なワークロードにおいて bash の 30 パーセントから 60 パーセントの範囲に収まります。いずれのランナーも 10 ms 未満で読み込みと実行を完了します。
他のインタプリタ型スクリプト言語 (Python、Ruby、Node.js) は、同一のワークロードに対して 5 倍から 20 倍のメモリ圧迫と 7 倍から 15 倍の CPU サイクルを要します。それらの利点はエコシステムであり、その代償が占有量です。
Keleusma は bash や Lua と同じ運用上のクラスに位置づけられながら、そのいずれよりも大幅に多くの保証を提供します。次の節では、このわずかなオーバーヘッドと引き換えに運用者が得られるものを説明します。
運用者が得られるもの
bash に対しておよそ半メガバイトの追加バイナリと 1 メガバイトの追加 RSS と引き換えに、Keleusma のデプロイには次のものが含まれます。
- 検証済みバイトコード: スクリプトはメモリの問題、無限ループ、無限再帰によってクラッシュすることがありません。構造検証器は、境界を打ち破るようなプログラムを拒否します。
- 静的に算出された WCMU および WCET の境界: アリーナのメモリは、バイトコードが宣言した境界とちょうど同じ大きさに確保されます。bash と Lua には同等の仕組みがありません。
- Ed25519 による署名付き配布: スクリプトはリリース鍵に対して暗号的に認証されます。bash スクリプトは別途のツール (gpg、codesign) を用いて外部で署名できますが、実行時の検証は組み込まれていません。
- X25519 と AES-256-GCM による暗号化配布: スクリプトは特定の宛先ホストに対して暗号化されます。成果物単体から平文を読み取ることはできません。
- 情報フローラベル: 型システムレベルのデータフロー追跡により、ポリシー違反をコンパイル時に検出します。bash や他のスクリプト言語には静的な情報フロー制御 (IFC) がありません。
- 厳格モードのポリシーゲート: CLI は、署名済みかつ復号可能な成果物のみが実行されることを強制します。ファイルシステムと環境変数を通じて設定可能です。スクリプト側のいかなる仕組みでも回避することはできません。
これらの機能はいずれも運用者に大きな占有量の負担をかけません。暗号スタックはバイナリに約 200 KB を追加します (合計 2.1 MB のうち)。厳格モードのポリシー機構は残りのうちのわずかな部分です。
機能ごとの占有量の内訳 (概算)
| 機能 | バイナリへの寄与の概算 |
|---|---|
| コアのコンパイラと仮想マシン (パーサ、型検査器、検証器、実行器) | ~1.4 MB |
signatures 機能 (Ed25519) | ~150 KB |
encryption 機能 (X25519、AES-GCM、HKDF、SHA-256) | ~200 KB |
shell 機能 (プロセス生成、環境変数、終了) | ~50 KB |
| CLI の表層 (引数解析、REPL、鍵生成、厳格モードのポリシー) | ~250 KB |
| ストリップ済みリリースバイナリの合計 | 2.1 MB |
ある機能を必要としない組み込み者は、Cargo の機能フラグを通じてそれを省略できます。--no-default-features --features compile,verify による最小限の組み込みビルド (暗号化なし、署名なし、シェルなし) は約 1.2 MB です。ホストアプリケーションにリンクされるライブラリのみのビルドは CLI の表層を持たず、追加バイナリは約 800 KB から 1 MB です。
ループデーモンのワークロード
無期限の loop main を 1 ミリ秒あたり 1 反復で 1 分間 CLI 下で実行すると、およそ次のリソースを使用します。
- 一定の 2.9 MB の RSS (実行を通じて増加しません。アリーナは起動時に大きさが確保されます)
- M1 コア 1 個の 1 パーセントから 2 パーセント (ティックレートが支配的であり、反復あたりのコストはマイクロ秒単位です)
- アロケータ圧迫ゼロ (アリーナの一時領域は yield-resume サイクルごとにリセットされます)
Python や Ruby における同等の定常状態のデーモン挙動は、空のループ本体に対して 15 MB から 30 MB の RSS と、同等かそれ以上の CPU 使用率で動作します。デーモンの稼働時間が延びるにつれて、この優位性は累積的に大きくなります。
スリープ間隔での定常状態
--tick-interval 1s の下で 1 秒あたり 1 ティックの場合、デーモンの CPU は実質的にゼロまで低下します (反復あたりの計算はマイクロ秒単位で、約 999.9 ms はレートリミッタのスリープでアイドル状態です)。アリーナは起動時に大きさが確保されて再利用されるため、RSS は高レートのワークロードから変化しません。ドリフト補償されたスリープにより、典型的な稼働期間にわたる累積ドリフトは 1 パーセント未満に抑えられます。
長間隔のデーモン (--tick-interval 1h、--tick-interval 1d) では、CPU 使用率は Keleusma 自体ではなく OS スケジューラのウェイクアップ機構が支配的です。ランタイムは反復の合間に真にアイドル状態です。1 時間あたり 1 ティックのメモリ常駐型デーモンは、運用上 2.9 MB の常駐メモリマッピングと同じリソースを消費します。そのコストは計算ではなく、ページフォルトの回避です。メモリ常駐型デプロイの運用者ガイドについては SECURITY_POLICY.md を参照してください。
比較に関する注記
この比較は意図的に選択的です。各ランナーは設計目標が異なり、直接的な機能比較は公正ではありません。
- bash: シェルインタプリタ。一般的なプログラミングではなくシェルパイプライン向けに設計されています。文ごとの検証が少ないため Keleusma より起動が速いものの、静的な型検査、境界付きリソース解析、署名検証はありません。
- Lua: 組み込み可能なスクリプト言語。運用上の占有量において Keleusma に最も近い類似物です。Lua は無限再帰と無限ループを許容し、実行時エラーが起こり得ます。WCMU や WCET の境界はありません。組み込みの署名や暗号化もありません。
- Python、Ruby: 豊富な標準ライブラリを備えた汎用の動的言語。運用上より重く、型エラーは実行時にのみ検出されます。境界付きリソース解析はありません。署名と暗号化はサードパーティのパッケージを通じて利用できますが、組み込みではありません。
- Node.js: V8 と Node ランタイム。高スループットのサーバワークロード向けに設計されており、JIT が長時間稼働するサービス全体で起動コストを償却します。単発のスクリプトには重すぎます。コードの組み込みの署名や暗号化はありません。
Keleusma が真に優位となる場面: 検証済みの実行特性を必要とするデプロイ (規制産業、組み込み)、および CLI が多数の短命なスクリプトを実行するデプロイ (スクリプトごとのオーバーヘッドが低い)。比較対象が優位となる場面: 豊富なエコシステムを必要とし、起動コストが長時間稼働するサービス全体で償却されるデプロイ。
再現性
上記の測定値は 2026 年 5 月 22 日に次のコマンドで生成されました。運用者は自身のハードウェアで再現できます。
# Build keleusma CLI release binary
cargo build --release -p keleusma-cli
# Trivial source program
echo 'fn main() -> Word { 42 }' > /tmp/hello.kel
# Measure
/usr/bin/time -l ./target/release/keleusma run /tmp/hello.kel
# Comparators (where installed)
/usr/bin/time -l bash -c 'echo 42'
/usr/bin/time -l lua -e 'print(42)'
/usr/bin/time -l python3 -c 'print(42)'
/usr/bin/time -l ruby -e 'puts 42'
/usr/bin/time -l node -e 'console.log(42)'
数値はホスト CPU、OS バージョン、インストールされたランタイムのバージョンによって変動します。相対的な順序 (bash と Keleusma が下位、インタプリタ言語が上位) は、私がテストしたマシン全体で安定しています。
LLM の利用
Keleusma のソースやスクリプトに対して AI コーディングアシスタント (Claude Code、Codex、Cursor、Aider など) を使用する運用者向けのガイダンスです。以下の助言は、一般的な Rust コードで訓練された AI ツールが最初の試行で誤りがちなパターンを扱い、加えて反復時間を短縮する実用的なプロンプトを示します。
この文書は AI を操作する運用者向けに書かれています。作業開始時に AI セッションへ直接与えるコンテキストとしても有用です。より広いエコシステムにおける AI 直接投入用コンテキストファイルの慣例は、プロジェクトルートの AGENTS.md と llms.txt です。いずれもより深いガイダンスとしてこの文書を参照しています。
想定読者
2 つの運用者プロファイルがこのガイドの恩恵を受けます。
- 組み込みアプリケーション向けに Keleusma スクリプトを書く組み込み者。Keleusma の表層は十分に特異であるため、LLM が生成したスクリプトは最初の試行でしばしば検証に失敗します。
- Rust で Keleusma ランタイムを編集する貢献者。ランタイムの
no_std + allocの姿勢と保守的検証の立場は、いくつかの一般的な Rust の慣用に反します。
いずれのプロファイルも同様のコンテキストを必要とします。以下で指摘するパターンは、この区分を越えて当てはまります。
あらゆる AI セッションでの読解順序
変更を加える前に、セッションの開始時に AI にこれらの文書を読ませてください。
- プロジェクトの慣例とセッションごとのプロトコルについては
AGENTS.md。 - AI 固有のパターンの落とし穴については、この文書。
- 設計の背後にある理由については
docs/architecture/LANGUAGE_DESIGN.mdを参照してください。 - アーキテクチャ上の決定の歴史的記録については
docs/decisions/RESOLVED.mdを参照してください。「なぜこのように行われたのか」という質問のほとんどはここで回答されています。 - 現在のスプリントの状態については
docs/process/TASKLOG.mdを、最新の AI から人間への引き継ぎについてはdocs/process/REVERSE_PROMPT.mdを参照してください。
あらゆる AI セッションに与える有用な最初のプロンプト:
Please read AGENTS.md, llms.txt, docs/guide/LLM_USAGE.md, the process documents
at docs/process/, then walk the knowledge graph under docs/. Summarise what
you learned in three to five paragraphs before proceeding to any task.
「進める前に要約する」ことで、AI が斜め読みではなく実際に読んだことを示すよう強制します。
AI ツールが誤りがちなパターン
以下の項目は、このコードベースでの AI 支援作業において繰り返し表面化してきました。パターンは文書化されており、AI はそれを読むことが期待されます。
no_std + alloc のみ
Keleusma のランタイムクレートは alloc を伴う no_std を対象とします。標準ライブラリは利用できません。
AI の最初の反射はいくつもの点で誤りがちです。それぞれを指定された alloc の等価物に置き換えてください。
| 誤り (std のみ) | 正しい (no_std + alloc) |
|---|---|
std::collections::HashMap | alloc::collections::BTreeMap |
std::collections::HashSet | alloc::collections::BTreeSet |
std::fs、std::io、std::process | 利用不可。ホストが登録するネイティブ関数として表面化する |
std::sync::Mutex、std::sync::RwLock | 利用不可。ランタイムはシングルスレッド |
Box::leak、Box::pin | alloc::boxed::Boxから利用可能だが、リークパターンは却下される |
std::time::Instant、std::time::SystemTime | 利用不可。クロックアクセスはホストがネイティブで提供 |
println!、eprintln! | 利用不可。printlnネイティブはホストが登録 |
keleusma-cliクレートはstdにリンクするので、バイナリレイヤでこれらのAPIを使えます。ランタイムクレートでは使えません。
stdが使える場所でも決定論が大事
テストやCLIでも、HashMapよりBTreeMapを使ってください。テストスイートが強制するバイト同一のバイトコード性質は、安定したイテレーション順に依存しています。ハッシュマップのイテレーション順はRustバージョンを跨いで安定せず、1つのバージョン内でも容量が増えるとずれることがあります。1つの機械で通るテストが、イテレーション順が観測可能な出力に漏れる場合、別の機械で失敗するかもしれません。
非決定論の他の源にも同じことが適用されます — 浮動小数点の縮約、std::hash::DefaultHasher、システム時間、明示的にシードされていない乱数生成器、read_dirによるファイルイテレーション順。AIがこれらのどれかを導入したら、プロンプトが何を求めていたかに関わらず、その変更は間違いです。
保守的検証スタンス
安全な検証器はWCETとWCMUの解析を打ち破る構文を却下します。具体的には:
- すべての再帰はコンパイル時に却下されます — 再帰呼び出しが証明可能に終了する場合でも。解析は有向非巡回コールグラフを必要とします。
- クロージャは型検査の段階で却下されます — クロージャ呼び出しによる動的ディスパッチが呼び出しごとのコストモデルを打ち破るからです。
dyn Traitはほとんどの位置で却下されます — 仮想ディスパッチが呼び出し地点ごとのコスト解析を打ち破るからです。- 平坦な制御フローのオペコード(
Jmp、Branch)は命令セットに存在しません。ブロック構造化された形式(If、Else、EndIf、Loop、EndLoop、Break、BreakIf)だけが認められています。 - 静的に抽出できない反復回数を持つループは、厳格モードの有界反復解析(R38)によって却下されます。
AIの自然な反射は、アルゴリズムがそう要求するように見えたときに、これらの構文を使うことです。正しい応答は、アルゴリズムを書き換えてワークスタックパターン(再帰ではなく明示的なスタック)、列挙型ベースのディスパッチ(dyn Traitではなく)、または固定反復ループ(無界ではなく)を使うことです。
検証器の診断メッセージは却下された構文に名前を付けて、docs/guide/WHY_REJECTED.mdの対応するガイダンスを指し示します。検証器を緩めて診断を黙らせないでください。却下は設計の一部です。
トレイト境界付きジェネリクス対トレイトオブジェクト
AIが&dyn Traitに手を伸ばすところでは、fn foo<T: Trait>(x: T)またはfn foo<T: Trait>(x: &T)を好んでください。モノモーフ化する形式は認められ、動的形式は保守的検証スタンスによってほとんどの位置で却下されます。
これはRust組み込みのベストプラクティスと同じ姿勢ですが、AIはパターンを認識しないかもしれません。作業が新しいトレイト面を伴うなら、プロンプトの最初にこの慣習を引用してください。
永続状態はdataブロックに置く。モジュールレベルのstaticではなく
RustコードをポートするときのAIの一般的な反射は、実行ごとの状態のためにモジュールレベルのstaticまたはlazy_staticを導入することです。Keleusmaでは、ループ反復を跨ぐ永続状態はプログラムが宣言したdataブロックに属し、GetDataとSetDataを通してアクセスされます。ホストが下にあるストレージを所有し、スクリプトはスロット番号で読み書きします。言語レベルのモジュールレベル可変状態はありません。
セッションごとのプロトコル
docs/process/PROCESS_STRATEGY.md に従い、各セッションは特定の形式を持ちます。
- 現在のタスク状態については
docs/process/TASKLOG.mdを読んでください。 - 最後の AI から人間への引き継ぎについては
docs/process/REVERSE_PROMPT.mdを読んでください。 - 進める前に人間のプロンプトを待つ。
各タスクを完了したあと:
TASKLOG.mdのタスク状態を更新する。- 検証、質問、懸念、意図する次のステップで
REVERSE_PROMPT.mdを上書きする。 - 運用者が明示的に頼んだときだけコミットする。そうでなければ変更は未コミットのままにする。
このプロトコルはAGENTS.mdとCLAUDE.mdに文書化されていますが、再度述べる価値があります。このプロジェクトでのAIセッションは厳格なセッションごとの契約で動きます。
スクラッチディレクトリ
tmp/を一時ファイル(下書き、プローブ出力、スクラッチスクリプト、統合待ちの設計仕様)に使ってください。tmp/とsecret/の内容は慣習でgitignoreされています。secret/は権限状態に関わらず決してコミットされてはいけないファイルに使ってください。
AIの一般的な反射は、作業をリポジトリのルートかdocs/の新しいサブディレクトリに置くことです。下書き資料にはtmp/が正しいです。完成した資料には、適切なdocs/サブディレクトリが正しいです。パターンは — 下書きはtmp/、レビュー後にdocs/に昇格、です。
役立つプロンプトパターン
下記のパターンは、開放的なプロンプトよりも高品質な出力を生みます。
「まず読み、要約し、それから進める」パターン
Please read [specific document paths]. Summarise what you learned in three
to five paragraphs. Then [the actual task].
要約-最初ステップは、AIが実際にドキュメントを読んだことを示すよう強制します。また、関連するコンテキストが読み込まれたことを運用者が見える形で示します。
「実装の前に設計」パターン
Please produce a design document at tmp/<topic>.md before writing any code.
The design should cover [the specific concerns]. Length budget two pages.
Identify any places where the design does not map cleanly onto existing
patterns in the codebase.
非自明な作業のために、設計-前-実装はレビュー可能な成果物を生み、方向転換のコストを減らします。このパターンはtmp/enrolled_keys_execution.mdとtmp/call_site_identifier.mdで作業例として確立されています。
アルゴリズム作業のための「構造境界」パターン
Please implement [algorithm] using the work-stack pattern documented in
docs/research/r3_1_recursion_to_iteration.md. The verifier rejects
recursion; do not use recursive helper functions. Declare the stack
capacity statically.
ワークスタックパターン(または他のKeleusma特有のパターン)への明示的な参照は、AIが再帰的なRustイディオムにデフォルトで戻るのを防ぎます。
「完了と主張する前に検証する」パターン
After implementing, run the full verification suite:
cargo test && cargo clippy --tests -- -D warnings
Do not report the task complete until both pass.
検査に基づいて成功を主張するのではなく、AIに実際に検証を走らせるよう強制します。
このガイドが何でないか
このガイドはKeleusmaのチュートリアルではありません。言語が初めての運用者はGETTING_STARTED.mdから始めるべきです。このガイドはAIツールのチュートリアルでもありません。使っているAIアシスタントへの運用者の慣れを前提としています。
このガイドは網羅的ではありません。新しいパターンはAI支援セッションごとに表面化します。ここに文書化されていない繰り返しのAI失敗モードが見つかった場合、セクションを追加してください。これは生きたドキュメントです。
関連する先行作業
LLM-ターゲットのガイダンスドキュメントを公開するという考えは、Rexプロジェクト(https://github.com/peterkelly/rex)から借りています。同じ目的でdocs/src/LLMS.mdを公開しています。2つのプロジェクトはアーキテクチャパターン(ホスト注入ネイティブを介してRustに組み込まれた純粋関数言語)を共有していて、LLM-ターゲットのフレーミングはきれいに移植できます。RexのLLMS.mdに慣れている運用者はこのガイドの形を認識できるでしょう。
より広いllms.txtの慣習は、AI読み取り可能なプロジェクト入口を宣言する構造化マークダウンの慣習として、2024年にJeremy Howardらが提案しました。Keleusmaのプロジェクトルートのllms.txtがその慣習に従っています。