IMAKITA for nodejs

IMAKITA for nodejs


2020/09/27(初版)
2021/01/05(改訂)
藤田昭人


対話機能の話、まずは道具立てから…

もちろん「書籍やブログと(擬似的に)対話する」がコンセプトのBookBotの場合、 返事はできるだけ書籍からの文章をそのまま使いたいところです。 書籍の中から話しかけられた質問に答えている箇所(たぶん、どこかの文節になると思いますが)を探し出せたとして、 それをギュッと一言にまとめる自動要約の機能が必要になります。

本稿ではそのためのアルゴリズのIMAKITAを紹介します。


抽出型文書要約アルゴリズム IMAKITA

この名前、日本語の音感を感じるので、最初は「なんの語呂合わせ?」と思ったのですが、 Importance Aligned Key Iterative Algorithm (IMAKITA) と言うことのようです。

この自動要約アルゴリズムGIGAZINEの次の記事を見つけました。 これは日本語文の要約結果が日本語として整っていることを期待して 「日本人が開発した自動要約アルゴリズム」を条件に探しまくった結果です。

gigazine.net

この記事、用例として「くろがね四起」だとか「松浦雅也*1だとかが出てくるので、 「これならオタク的文章でもいけるかも…」と考えた次第です。

開発者が書いたショートメモはこちら。 オリジナルは Python バージョンだったようです。

qhapaq.hatenablog.com

…が、その後 Chrome 拡張バージョンも公開されました。

qhapaq.hatenablog.com

こっちはもちろん JavaScript で書かれていますし、ソースも公開されています。 ソースを見るとこのバージョンは日本語と英語に対応しているみたいです。 論文によれば、自動要約には 抽出的要約(extractive summarization)と 抽象的要約(abstractive summarization)があるそうでが、 IMAKITAは抽出的要約を採用しているので、 マルチリンガルに(容易に)対応できるとのこと。

本稿では、この IMAKITA on Chrome をベースに以降の話を進めます。


IMAKITA for nodejs

もちろん Chrome 拡張された IMAKITA は便利なんですが、 このままでは nodejs では使えないのでヘッド部分を nodejs 用に書き換えました。 以下の手順でオリジナルコードにパッチを当てることができます。

1.IMAKITA on Chrome のコードをダウンロードする

GithubIMAKITA on Chrome のページからコードをダウンロードします。 Download ZIP が便利。

2.ソースファイルを取り出す

ダウンロードしたコードから、 preproc_ja.jsimakita_body.jstiny_segmenter-0.2.js を取り出します。

3.ソースファイルにパッチを当てる

preproc_ja.jsimakita_body.js については nodejs 向けに修正が必要です。
次のパッチを当ててください。

$ diff -u imakita-master/preproc_ja.js imakita4nodejs/preproc_ja.js
--- imakita-master/preproc_ja.js    2019-05-22 21:02:31.000000000 +0900
+++ imakita4nodejs/preproc_ja.js    2021-01-06 08:15:08.000000000 +0900
@@ -5,6 +5,9 @@
    m[a] = (m[a] || []).concat(i);
    return m;
     }, {});
+
+  const TinySegmenter = require('./tiny_segmenter-0.2');
+  const imakita = require('./imakita_body');

     var segmenter = new TinySegmenter();
     var word_weight = {};
@@ -24,7 +27,7 @@

     for(var i=0; i<sentences.length; ++i){
    var word_list_temp = segmenter.segment(sentences[i]);
-   console.log(word_list_temp);
+   //console.log(word_list_temp);
    var sens = {"id":i, "word_list":[], "importance":0};
    for (var j=0; j< word_list_temp.length; ++j){
        if (!(word_list_temp[j].toLowerCase() in not_word)){
@@ -42,13 +45,13 @@
    sens_list.push(sens);
     }

-    set_importance(sens_list, word_weight, word_list_minimum);
+    imakita.set_importance(sens_list, word_weight, word_list_minimum);
     /*
     console.log(sentences);
     console.log(sens_list);
     console.log(word_weight);
     */
-    var summary_id = binary_search(sens_list, word_weight, summary_number, 5);
+    var summary_id = imakita.binary_search(sens_list, word_weight, summary_number, 5);
     //console.log(summary_id);
     var summary_txt = []
     for (var i=0; i<summary_id.length; ++i){
@@ -58,3 +61,4 @@
     return summary_txt;
 }

+module.exports = preproc_ja;
$ diff -u imakita-master/imakita_body.js imakita4nodejs/imakita_body.js
--- imakita-master/imakita_body.js  2019-05-22 21:02:31.000000000 +0900
+++ imakita4nodejs/imakita_body.js  2021-01-05 11:56:23.000000000 +0900
@@ -114,3 +114,8 @@
     });

 }
+
+module.exports = {
+  binary_search:  binary_search,
+  set_importance: set_importance
+}
$ 


IMAKITA for nodejs を試してみる

サンプルプログラムを示します。テストデータは 「対話分野の研究をしたいときに気をつけたいこと」 から冒頭部分を借用しました。

var sections = [
  '身近なものに対話システムを組み込もうとする流れが来ているのか、機械学習の性能向上や、データの充実などの研究地盤が整ってきたのか、対話システムの研究が暖かみを帯びてきました。\n私はここ数年対話系の研究面白そうだなぁとなり、昨年度辺りから(突発的に卒業研究をしなければならないという話もあり)論文読みや実験を行っているのですが、この研究・発表など大変苦労したので、同じ轍を踏まないようにここに色々書いておこうと思います。\n尚、この分野は皆さんが想像している以上に広いので、どんなに頑張ってもこの手の解説は一説の域を出ることができないことを補足しておきます。(逆に、これが対話分野の研究のすべてだ!と言っているテキストなんかは注意したほうが良いと思います。自分は大分痛い目を見ました。)'
];


const preproc_ja = require('./preproc_ja');

var summary_number = 3;
var minimum_length = 10;
var separator = [". 。 .","\n . 。 ."];

for (var i = 0; i < sections.length; i++) {
  var summary = [];
  summary = preproc_ja(sections[i],
               summary_number,
               minimum_length,
               separator);
  console.log("------\n%s\n------\n", sections[i]);
  for (var j = 0; j < summary.length; j++) {
    console.log("\n%d: %s。", j, summary[j]);
  }
}

preproc_jaの引数の意味は次のとおりです。

引数 説明
(text) 要約対象の文章
summary_number 生成する要約文の数
minimum_length 要約文の最小文字数
separator 文を区切る文字

このプログラムを実行すると次のような結果が表示されます。

$ node summarization.js
------
身近なものに対話システムを組み込もうとする流れが来ているのか、機械学習の性能向上や、データの充実などの研究地盤が整ってきたのか、対話システムの研究が暖かみを帯びてきました。
私はここ数年対話系の研究面白そうだなぁとなり、昨年度辺りから(突発的に卒業研究をしなければならないという話もあり)論文読みや実験を行っているのですが、この研究・発表など大変苦労したので、同じ轍を踏まないようにここに色々書いておこうと思います。
尚、この分野は皆さんが想像している以上に広いので、どんなに頑張ってもこの手の解説は一説の域を出ることができないことを補足しておきます。(逆に、これが対話分野の研究のすべてだ!と言っているテキストなんかは注意したほうが良いと思います。自分は大分痛い目を見ました。)
------


0: 身近なものに対話システムを組み込もうとする流れが来ているのか、機械学習の性能向上や、データの充実などの研究地盤が整ってきたのか、対話システムの研究が暖かみを帯びてきました。

1: 尚、この分野は皆さんが想像している以上に広いので、どんなに頑張ってもこの手の解説は一説の域を出ることができないことを補足しておきます。

2: (逆に、これが対話分野の研究のすべてだ!と言っているテキストなんかは注意したほうが良いと思います。
$

5行の文章を3行に要約するのでは意味がないので summary_number を1にしてみたら…

$ node summarization.js
------
身近なものに対話システムを組み込もうとする流れが来ているのか、機械学習の性能向上や、データの充実などの研究地盤が整ってきたのか、対話システムの研究が暖かみを帯びてきました。
私はここ数年対話系の研究面白そうだなぁとなり、昨年度辺りから(突発的に卒業研究をしなければならないという話もあり)論文読みや実験を行っているのですが、この研究・発表など大変苦労したので、同じ轍を踏まないようにここに色々書いておこうと思います。
尚、この分野は皆さんが想像している以上に広いので、どんなに頑張ってもこの手の解説は一説の域を出ることができないことを補足しておきます。(逆に、これが対話分野の研究のすべてだ!と言っているテキストなんかは注意したほうが良いと思います。自分は大分痛い目を見ました。)
------


0: (逆に、これが対話分野の研究のすべてだ!と言っているテキストなんかは注意したほうが良いと思います。
$

ちゃんと一番大事な文が残りました。


さいごに

無作為に選んだ文章を、 まるでその意味を理解しているかのように要約するIMAKITAの実力に 正直ちょっとビックリしたのですが…

これがWord2vecに代表されるベクトル空間モデルのトリックなんでしょうね。

種明かしは、いずれまた…

以上

*1:松浦雅也の紹介で PSY・S が出てこないってどう言うこと?

BookBot: Chatbot for reading Book

BookBot:読書のためのチャットボット(英訳)


2020/1/1
Akito Fujita


This document has been translated from the Japanese version using DeepL.


It's been a very long time.
What have I been doing after four months of silence?
...I was making this.

bookbot.glitch.me

Contrary to many people's expectations, it is a pure JavaScript web application. In this article, I will give an overview of this system, BookBot.


BookBot: Chatbot for reading Book

BookBot, as the name suggests, is a chatbot for reading, and it aims to be a chatbot that users can chat with about books. This idea came about when I was talking about chatbots with a close friend of mine, who seems to be a prolific reader, and he said, "If the book itself talks about what it says, it saves me the trouble of reading.

In fact, when we try to use machine learning techniques to develop a dialogue system, the dialogue corpus becomes a problem. A dialogue corpus is a pair of questions and answers that must be prepared as training data. It is said that in order for a dialogue system using machine learning to be able to carry out a reasonable conversation, about 100,000 question-answer pairs must be collected as training data. Such a long dialogue record is not very common.

However, if you want to prepare a new corpus of dialogues manually to develop a chatbot, you don't need such a large amount of dialogues. According to a New York Times interview with Loebner Prize winner Richard Wallace, in the early development of Alicebot, the bot was first piloted in an office, and Wallace filled in each unanswered question from users (in the office). That's right.

If he taught Alice a new response every time he saw it baffled by a question, he would eventually cover all the common utterances and even many unusual ones. Wallace figured the magic number was about 40,000 responses. Once Alice had that many preprogrammed statements, it -- or ''she,'' as he'd begun to call the program fondly -- would be able to respond to 95 percent of what people were saying to her.

He says that if you assume a chatbot's personality and prepare appropriate response text for it, the chatbot will be able to respond appropriately without using machine learning techniques. In other words, "We have failed to master human conversation, not because we are too complex, but because we are too simple. This is an insight that Richard Wallace has discovered.

However, "manually inputting 40,000 questions and responses"...

...and that's when my friend's idea of "personifying books" came into my mind.

To begin with, books (and blogs, for that matter) are the author's own statement. If you look for a chatbot response in a book, that character becomes the author's own character. And if the author has written a significant amount of text, wouldn't he be able to find the most appropriate sentence for any given question without having to write up a new response?

In the end, his comment led me to the idea of using the original text of books as a dialogue corpus. In a nutshell, the idea is to create a pseudo-dialogue corpus by applying information retrieval techniques.


System Overview

Here is a system that is already in place.

The first platform is Glitch. Basically, it is a typical web application using node.js + express.js JavaScript. It is based on hello-express and hello-sqlite provided by Glitch, so the basic structure has been retained. I also used BotUI because I wanted a chatbot-like UI*1, and it made it look like a chatbot.

Unlike most chatbots, BookBot starts out as a book browser. The startup screen looks like this.


f:id:Akito_Fujita:20201228103437p:plain
BookBot

Thanks to BotUI, when you view it in your phone's web browser, it looks like a running phone app. You can then use BotUI's button feature to navigate to the next page, previous page, or previous/next heading.


Blog posts currently included in the collection

Unfortunately, it's still a prototype at the moment, so you can only view blog posts I've written in the past. At the moment, I have the following seven included.

I'm sorry. It's all written in Japanese.

Chapter Title
1 When did AI (Artificial Intelligence) start? The Inside Story of the Dartmouth Conference
2 Do you know what Artificial Brainlessness is?
3 ELIZA (1) What is a program that "mimics listening"?
4 ELIZA (2) What is Client-Centered Therapy?
5 ELIZA (3) Scripting - A mechanism to create a response
6 ELIZA (4) DOCTOR Scripts
7 ELIZA (5) What is the background of the development?

I have about 30 blog posts, and I plan to add more as needed. I also plan to implement a function to call the chatbot on any page.


Script generation from Markdown documents

I spent a lot of time designing the script for BookBot. In conclusion, I decided to use a flat structure based on the original article's chapter structure, or "chain of sections". The main reason for this is that it is "mechanically transformable from the original article", but it is also heavily influenced by Weizenbaum's second paper.*1

For now, I'm assuming that articles are written in Markdown, and I've written a program to automatically generate BookBot scripts from Markdown documents. For interpreting Markdown, I used 'Remark', which I introduced earlier. The source of the module used for interpretation, parse-markdown.js, is attached at the end.

The module parseMarkdown expands the Markdown document into a syntax tree called Markdown Abstract Syntax Tree(MDAST), but there are two plugins to keep in mind.

One is 'remark-gfm'. remark's Markdown parser interprets Markdown documents based on CommonMark Spec, but this plugin also interprets Markdown used on Github, i.e. GitHub Flavored Markdown Spec.

The other is 'remark-footnotes', a plugin that supports the following three types of footnote notation in Markdown.

Here is a footnote reference,[^1]
another,[^longnote],
and optionally there are inline
notes.^[you can type them inline, which may be easier, since you don’t
have to pick an identifier and move down to type the note.]

[^1]: Here is the footnote.

[^longnote]: Here’s one with multiple blocks.

    Subsequent paragraphs are indented to show that they
belong to the previous footnote.

        { some.code }

    The whole paragraph can be indented, or just the first
    line.  In this way, multi-paragraph footnotes work like
    multi-paragraph list items.

This paragraph won’t be part of the note, because it
isn’t indented.


To create a glossary

The underlined part of the response from BookBot is a link. For example, when you see the following...

f:id:Akito_Fujita:20201228125712p:plain
Before Click

Click on the underlined "smart speaker"...

f:id:Akito_Fujita:20201228125744p:plain
After Click

...and a description of the "smart speaker" will be displayed.

I'm actually using Markdown's Link Reference Definition for this. (I think it's called "indirect link" in Japanese?).

Example 161

[foo]: /url "title"

[foo]

...is the format, but BookBot uses "title" for "glossary" as shown below.

[スマートスピーカー]: https://ja.wikipedia.org/wiki/%E3%82%B9%E3%83%9E%E3%83%BC%E3%83%88%E3%82%B9%E3%83%94%E3%83%BC%E3%82%AB%E3%83%BC "用語: スマートスピーカー(英: Smart Speaker)とは、対話型の音声操作に対応したAIアシスタント機能を持つスピーカー。内蔵されているマイクで音声を認識し、情報の検索や連携家電の操作を行う。日本ではAIスピーカーとも呼ばれる。"

The URL in this notation is a link to Wikipedia, and the "Glossary" contains the first sentence of the Wikipedia page.

Note that "term" at the top of the "glossary" is a category that is aware of the Named-entity tag. This link definition is not displayed when Markdown is converted to HTML or PDF, but the term is linked to. For example, if you write the following in Markdown...

突如、[人工無脳][]に執着し始めた理由は[スマートスピーカー][]の登場でした。

If you open it in a normal browser, it will look like this

突如、人工無脳に執着し始めた理由はスマートスピーカーの登場でした。

In the case of BookBot, it displays the "term description" written in the title.

There is another use for this Link Reference Definition, for example, if you write the following in Markdown...

[Alexa][スマートスピーカー]のアプリ([スキル][]って言うのかな?)の開発者の世代では「対話」と言うとチャットボットのイメージが立ち上がるのでしょうか?

In the browser...

Alexaのアプリ(スキルって言うのかな?)の開発者の世代では「対話」と言うとチャットボットのイメージが立ち上がるのでしょうか?

...and it only shows "Alexa", but clicking on it will take you to the Wikipedia page on "smart speakers". In fact, BookBot uses this notation to define synonyms. In this way, BookBot uses a little trick to create a glossary.


Future work

The development of BookBot is about to reach its climax since it lacks the essential dialogue function.

I would like to start by investigating word2vec.

parse-markdown.js

'use strict'

var vfile = require('to-vfile');
var report = require('vfile-reporter');
var unified = require('unified');
var parse = require('remark-parse');
var gfm = require('remark-gfm');
var footnotes = require('remark-footnotes');


// rmposition -- ポジションデータを削除する

function rmposition() {
  return transformer;

  function transformer(tree, file) {
    traverse(tree);
  }

  function traverse(tree) {
    if (tree.position) delete tree.position;
    if (!tree.children) return
    for (var i = 0; i < tree.children.length; i++) {
      traverse(tree.children[i]);
    }
  }
}


// pluginCompile -- 終端処理

function pluginCompile() {
  this.Compiler = compiler;

  function compiler(tree) {
    //console.log(tree);
    return(tree);
  }
}


function parseMarkdown(name) {
  var processor = unified()
      .use(parse)
      .use(gfm)
      .use(footnotes, {inlineNotes: true})
      .use(rmposition)
      .use(pluginCompile);

  //console.log("# %s", name);
  processor.process(vfile.readSync(name), done);

  var result;
  function done(err, file) {
    //console.error(report(err || file));
    //console.log(file.result);
    result = file.result;
  }

  return(result);
}

module.exports = parseMarkdown;

*1:In his second paper, Weizenbaum examines the structure of conversation by comparing "cocktail party chatter" with "discussion between two physicists".

Cocktail party chatter, for example, has a rather straight line character. Context is constantly being changed -- there is considerable chaining of nodes -- but there is hardly any reversal of direction along already established structure. The conversation is inconsequential in that nothing being said has any effect on any questions raised on a higher level. Contrast this with a discussion between, say, two physicists trying to come to understand the results of some experiment. Their conversation tree would be not only deep but broad as well, i.e., they would ascend to an earlier contextual level in order to generate new nodes from there. The signal that their conversation terminated successfully might well be that they ascended (back to) the original node, i.e., that they are again talking about what they started to discuss.

This thought has inspired me to spend a lot of time thinking about the "conversation of reading. I have spent a lot of time thinking about the "conversation of reading. For example, if we think of reading as an activity to gain new knowledge, we can replace it with a conversation between two people with different levels of knowledge, a conversation between a teacher and a student. (A typical example is the so-called "Zen conversation"). In this case, the conversation proceeds based on the teacher's knowledge. Unlike a "cocktail party chat," the conversation is not aimless, but the level of the student's knowledge is taken into account, so it is not as broad or deep as a "discussion between two physicists.

Also, in the case of reading, the knowledge of the higher level speaker is static (not a line of dialogue spun up on the spot), so if you call the act a conversation, it is characterized by less divergence than others. I believe that authors of books make deliberate decisions on how to organize their chapters in order to promote reader understanding, but since the knowledge level of readers is not as flat as authors assume (they know this, but not that), they may not always follow the table of contents. It is also important to take into account the so-called reader's guide.

As I began to think about how to structure the script so that it would be able to handle these problems without difficulty, I found that I could not find the best solution. In the end, I decided to follow the principle of how most people read, i.e., the chapter structure of a book, and assume that if you don't understand the passage (section) you are reading at the time, you will jump to another passage (section) in the book that describes it.

In terms of response generation, I felt the need to be able to set up a combined strategy for each clause (section), such as rule-based like ELIZA, machine generation by machine learning, and procedural response by automaton. This idea is also an extension of the "hierarchy of scripts" proposed by Weisenbaum.

This implementation of the scripting area is still in flux, so we won't be actively releasing the code for the time being. If you want to see it, please use Glitch's editor to view the source with 'AS-IS'.

BookBot:読書のためのチャットボット

BookBot: Chatbot for reading Book


2020/12/31
藤田昭人


大変ご無沙汰してしまいました。
4ヶ月間も沈黙して何をしていたのか?
…というとこれを作ってました。

bookbot.glitch.me

多くの人の予想に反して pure JavaScript のウェブ・アプリケーションです。 本稿ではこのシステム、BookBot の概要を説明します。


BookBot:読書のためのチャットボット

BookBotはその名のとおり「読書のためのチャットボット」でして、 利用者が「書籍について雑談をすることができるチャットボット」を目指しています。 このアイデア、親しい友人とチャットボットについて話していたときに 「書籍自身が書いてあることをかいつまんで話してくれたら、読書する手間が省ける」 と多読家らしい彼が語った一言が「対話コーパスとして書籍原文を使う」キッカケになりました。

実際、対話システムの開発に機械学習の技術を活用しようとなると対話コーパスが問題になります。 質問文と回答文のペアを学習データとして用意しなければならない対話コーパス機械学習を使った対話システムがそれなりに会話が成立するようになるには、 学習データとして10万程度の質問文&回答文ペアを集めなければならないとか。 そんな長大な対話録はあまり見かけません。

しかし、チャットボットを開発するために 新たに人手で対話コーパスを用意する場合には、 そこまで大量の対話録は必要ないようです。 ローブナー賞の覇者である リチャード・ウォレスへのニューヨーク・タイムズのインタビューよれば、 Alicebot の初期の開発では、まずはオフィス内でボットを試験運用を始めて、 (オフィス内の)利用者からの答えられなかった質問には その都度ウォレスが回答文を埋めていったそうです。

もしアリスが質問で混乱しているのを見ればそのたびに、 彼がアリスに新しい答えを教えたとしたら、 彼は最終的には一般的な発言すべてをカバーするだけなく、 さらには多くの変わった発言もカバーするだろう。 ウォレスは、マジックナンバーは約4万件の応答だと考えている。 これだけプログラムされた発言ができるようになれば、 アリスに向かって(あるいは「彼女は」と呼びはじめたときには) 人々が言っていることの95%に反応できるようになる。

チャットボットの人格を想定し、 それに相応しい回答文を用意すれば 機械学習の技術を使わなくても チャットボットは相応の応答ができるようになるそうです。つまり 「人間の会話をマスターできなかったのは、人間が複雑すぎるからではなく、単純すぎるからだ」 という。これはリチャード・ウォレスが発見した見識でしょう。

とはいえ「手作業で4万件の質問&応答の入力する」というのはねぇ…

…とモヤモヤしていたところへ飛び込んできたのが 友人の一言の「書籍を擬人化する」アイデアだったのです。

そもそも書籍(ブログもそうですが)とは著者自身の主張でもあります。 もしチャットボットの応答を書籍の中から探せば、 そのキャラクターは著者自身のキャラクターとなります。 そして、著者がまとまった量の文章を書いておれば、 新たに応答文を書き起こさなくても 任意の質問にもっとも相応しい1文を見つけ出すこともできるのでは?

結局、彼の一言が「対話コーパスとして書籍原文を使う」という思いつきに結び付くことになりました。 一言で説明すれば「情報検索の技術を応用して擬似的な対話コーパスを作る」というアイデアです。


システムの概要

既に出来上がっているシステムを紹介します。

まずプラットホームはGlitchを使っています。 基本的にはnode.jsexpress.jsJavaScriptによる 典型的なウェブ・アプリケーションです。 Glitchが用意してくれるhello-expresshello-sqliteを ベースに作成しましたので、基本的な構造はそのまま踏襲しています。 それからチャットボット風のUIが欲しかったのでBotUIも使っています*1。おかげで、チャットボットらしい見た目になりました。

一般的なチャットボットとは異なり、 BookBotは最初は書籍ブラウザーとして 起動します。起動画面はこんな感じ。


f:id:Akito_Fujita:20201228103437p:plain
BookBot

BotUIのおかげで、 スマホのウェブ・ブラウザーで表示すると スマホ・アプリが動いているように見えます。 それからBotUIのボタン機能を使って 次ページや前ページ、前後の見出しに移動できます。


現在収録しているブログ記事

残念ながら、今のところまだプロトタイプなので 僕が過去に書いたブログ記事しか閲覧できません。 現時点で収録してるのは下記の7本です。

タイトル
AI(人工知能)っていつから始まったの? ー ダートマス会議の内幕
人工無脳って知ってる?
ELIZA(1)「傾聴」を模倣するプログラムとは?
ELIZA(2)クライアント中心療法とは?
ELIZA(3)スクリプト ー 応答を作り出す仕掛け
ELIZA(4)DOCTOR スクリプト
ELIZA(5)開発の背景は?

僕のブログ記事は30本ほどあるので、 随時追加していく予定です。 また、任意のページでチャットボットを呼び出す 機能を実装する予定です。(詳細は後述)


Markdownドキュメントからのスクリプト生成

BookBotスクリプトの設計にはかなり悶絶し、結果的長い時間を使ってしまいました。 結論としては元になる記事の章立て、 つまり「文節(セクション)の連鎖」に基づくフラットな構造を採用しました。 その最大の理由は、まず「元となる記事から機械的な変換が可能」であるということなのですけども、 それだけではなくワイゼンバウム第2論文 から大きな影響を受けています*2

今のところ、記事はMarkdownで記述されていることを仮定していて、 MarkdownドキュメントからBookBotスクリプトを自動生成するプログラムを書きました。 Markdown の解釈には、以前紹介した 'Remark' を使っています。解釈に使っているモジュール parse-markdown.js のソースを末尾に添付しておきます。

モジュール parseMarkdown はMarkdownドキュメントを Markdown Abstract Syntax Tree(MDAST) という構文木に展開してくれますが、ここで留意すべきプラグインが2つあります。

1つは 'remark-gfm' 。remark のMarkdownパーサーは CommonMark Spec に基づくMarkdownドキュメントを解釈してくれますが、 このプラグインを使うとGithubで使われているMarkdown、すなわち GitHub Flavored Markdown Spec まで解釈してくれます。

もうひとつは 'remark-footnotes' で、これはMarkdownでの次の3種類の フットノートの記法に対応するプラグインです。

Here is a footnote reference,[^1]
another,[^longnote],
and optionally there are inline
notes.^[you can type them inline, which may be easier, since you don’t
have to pick an identifier and move down to type the note.]

[^1]: Here is the footnote.

[^longnote]: Here’s one with multiple blocks.

    Subsequent paragraphs are indented to show that they
belong to the previous footnote.

        { some.code }

    The whole paragraph can be indented, or just the first
    line.  In this way, multi-paragraph footnotes work like
    multi-paragraph list items.

This paragraph won’t be part of the note, because it
isn’t indented.

まぁ、はてなのマークダウンの記法とはちょっと違うので、 そこは困りもんなんですけども。


用語集を作るために

ちなみに、BookBotからの応答の下線部分はリンクになっています。 例えば、次のように表示されている時に…

f:id:Akito_Fujita:20201228125712p:plain
クリック前

下線付きの「スマートスピーカー」をクリックすると…

f:id:Akito_Fujita:20201228125744p:plain
クリック後

…と「スマートスピーカー」の説明が表示されます。

これ、実はMarkdownLink Reference Definition (日本語では「間接リンク」というのかな?) を活用してます。

Example 161

[foo]: /url "title"

[foo]

…というのが書式なんですけども、 BookBotでは次のように「タイトル」を「用語解説」に使ってます。

[スマートスピーカー]: https://ja.wikipedia.org/wiki/%E3%82%B9%E3%83%9E%E3%83%BC%E3%83%88%E3%82%B9%E3%83%94%E3%83%BC%E3%82%AB%E3%83%BC "用語: スマートスピーカー(英: Smart Speaker)とは、対話型の音声操作に対応したAIアシスタント機能を持つスピーカー。内蔵されているマイクで音声を認識し、情報の検索や連携家電の操作を行う。日本ではAIスピーカーとも呼ばれる。"

この記法の URL は Wikipedia へのリンク、 「用語解説」には Wikipedia ページの冒頭の文言を納めています。

ちなみに「用語解説」の先頭にある「用語」というのは、 固有表現タグを意識したカテゴリです*3。 このリンク定義はMarkdownをHTMLやPDFに変換すると表示されず、 その用語にはリンクが貼られます。 例えばMarkdownで次のように記述すると…

突如、[人工無脳][]に執着し始めた理由は[スマートスピーカー][]の登場でした。

普通にブラウザーで開くと次のように表示されます。

突如、人工無脳に執着し始めた理由はスマートスピーカーの登場でした。

で「スマートスピーカー」をクリックすると該当するWikipediaページに移動しますが、 BookBot の場合はタイトルに書かれている「用語説明」を表示する訳です。

この Link Reference Definition にはもう1つ使い道があって、 例えばMarkdownで次のように記述すると…

[Alexa][スマートスピーカー]のアプリ([スキル][]って言うのかな?)の開発者の世代では「対話」と言うとチャットボットのイメージが立ち上がるのでしょうか?

ブラウザでは…

Alexaのアプリ(スキルって言うのかな?)の開発者の世代では「対話」と言うとチャットボットのイメージが立ち上がるのでしょうか?

…と「Alexa」としか表示されませんが、 クリックするとWikipediaの「スマートスピーカー」のページに移動します。 実はBookBotではこの記法を同義語の定義に使っています。 このようにBookBotは、 ちょっとしたトリックを使って用語集を作ってます。


これからの作業

肝心の対話機能がないのでBookBotの開発はこれからが佳境です。

いわゆるAIチャットボットと呼ばれるサービスは 既にたくさん存在していますが、 その核技術として頻繁に登場するのがword2vecです。 その仕組みについては次のようなドキュメントが幾つも見つかりますが…

qiita.com

そこで示される実装例はPythonだけで JavaScriptの実装例は皆無といった状況です。 JS版のword2vecというと下記のパッケージがありますが

www.npmjs.com

これはオリジナルの Google の word2vec にJS向けのラッパーを付けただけなので困りもんです。 また日本語文章にword2vecを適用する場合には、 固有の問題がある そうなので、これまた悩みどころです。

一方、対話機能の実装という観点では、最近良い教科書が出版されました。

www.ohmsha.co.jp

これまたPythonなんですけども、ソースをチラチラ見てると 「JavaScriptに書き直すのはそれほど難しくないのでは?」などと考えています。 (そういえば、昔JavaのコードをC++に書き直しながら 両方の言語を覚えるという無茶をしたなぁ…)

というわけで…

しばらくはBookBotネタでブログを書くことにします。

以上

parse-markdown.js

'use strict'

var vfile = require('to-vfile');
var report = require('vfile-reporter');
var unified = require('unified');
var parse = require('remark-parse');
var gfm = require('remark-gfm');
var footnotes = require('remark-footnotes');


// rmposition -- ポジションデータを削除する

function rmposition() {
  return transformer;

  function transformer(tree, file) {
    traverse(tree);
  }

  function traverse(tree) {
    if (tree.position) delete tree.position;
    if (!tree.children) return
    for (var i = 0; i < tree.children.length; i++) {
      traverse(tree.children[i]);
    }
  }
}


// pluginCompile -- 終端処理

function pluginCompile() {
  this.Compiler = compiler;

  function compiler(tree) {
    //console.log(tree);
    return(tree);
  }
}


function parseMarkdown(name) {
  var processor = unified()
      .use(parse)
      .use(gfm)
      .use(footnotes, {inlineNotes: true})
      .use(rmposition)
      .use(pluginCompile);

  //console.log("# %s", name);
  processor.process(vfile.readSync(name), done);

  var result;
  function done(err, file) {
    //console.error(report(err || file));
    //console.log(file.result);
    result = file.result;
  }

  return(result);
}

module.exports = parseMarkdown;

*1:この二つを手名付けるのに苦労しました(笑)
実は、いろいろ初めての経験が重なったため 開発は予定より2ヶ月遅れてしまいました。
BotUI の使い方、特に JavaScriptの async/await の使い方はそのうち書きます。

*2:ワイゼンバウム第2論文 で、会話の構造について「カクテルパーティでのおしゃべり」と 「二人の物理学者の議論」の比較で考察しています。

例えば、カクテルパーティーのおしゃべりは直線的な傾向があります。 コンテキストは絶えず変更されています(ノードにはかなりの連鎖があります)が、 すでに確立された構造に沿った方向の逆転はほとんどありません。 会話は、何も言われても、より高いレベルで提起された質問に影響を与えないという点で重要ではありません。 これを、たとえば、ある実験の結果を理解しようとする2人の物理学者間の議論と比較してみましょう。 彼らの会話ツリーは、深いだけでなく広いものになります。 つまり、そこから新しいノードを生成するために、以前のコンテキストレベルに上昇します。 会話が正常に終了したというシグナルは、元のノードに戻った(元に戻った)こと、 つまり、(最初に)話し始めた内容について再び話していることです。

この考察に触発されて「読書という会話」について何度となく考えることに、 多くの時間を費やしてしまいました。例えば、読書を「新たな知識を得る活動」と捉えると、 知識のレベルの異なる2者間、「先生と生徒の会話」に置き換えることできます (典型的な事例はいわゆる「禅問答」という会話ですね)。 この場合、会話は先生側の知識を前提に会話が進むことになります。 「カクテルパーティでのおしゃべり」とは違い無目的ではないですが、 生徒の知識レベルが考慮されるので 「2人の物理学者の議論」ほど広さや深さは自ずと抑制されます。

また読書の場合、レベルの高い方の話者の知識は静的である (その場で捻り出されるセリフではない) ことから、その行為を会話と呼ぶなら 他よりも発散する度合いは小さいことも特徴になります。 書籍の著者は読者の理解を促すために熟慮の上で章立てを決めていると思うのですが、 読者の知識レベルが著者が想定しているほどフラットではないので (これは知っているがこれは知らないといった) 目次どおりに読み進められるとは限らない、 いわゆる(ウェブ・ページではよく議論される)読者の導線も考慮しなければなりません。

こういった問題に無理なく対応できるスクリプトの構造を考え始めた結果、 ベストの解は見出せなくなってしまいました。 結局、多くの人の読書の導線、つまり書籍の章立てを原則とし、 その時に読んでいる文節(セクション)が理解できなければ、 それについて書いてある書籍の他の文節(セクション)にジャンプするのだろうと 仮定することにしました。

応答文生成に関しても、 ELIZAのようなルールベース、 機械学習による機械的な生成、 さらにオートマトンによる手順的な応答など 複合的な戦略を、 各文節(セクション)毎に設定できる必要を感じました。 これもワイゼンバウムが提案する 「スクリプトの階層化」の延長上にあるアイデアです。

この、スクリプト界隈の実装は仕様がまだまだ揺れそうなので、 当面コードを積極的に公開する事はなさそうです。 どうあってもみたい人は Glitch のエディターで ソースを 'AS-IS' で見てください。

*3:ここで「固有表現タグ」にニヤッと反応した方はわかってらっしゃる(笑)
そうでない方が、これあたりを読んでいただける良いかと思います。

heat02zero.hatenablog.com

自然言語処理の世界では IREX というのがあるんだそうで、 論文 も出てました。要は次のように固有名刺を分類するのだそうです。

略号 説明 用例 IPADIC
ART 固有物名 ノーベル文学賞Windows7 41
LOC 地名 アメリカ、千葉県 46, 47
ORG 組織 自民党NHK 45
PSN 人名 安倍晋三メルケル 42, 43, 44
DAT 日付 1月29日、2016/01/29
TIM 時間 午後三時、10:30
MNY 金額 241円、8ドル
PNT 割合 10%、3割

ちなみにIPADICでも、一部の固有名詞には ID が振られています。

固有表現とは「意味的な最小単位」で、 MeCab などで出力される「形態素」よりは少し大きい というのが僕の理解です(正確な定義はよくわからんが)。

例えば、普通の日本人なら「ウィーン」と言えば地名、 「アインシュタイン」と言えば人物名と直感的に認識しますよね? もしチャットボットに同じことをさせるためには、 単語の属性として固有表現タグを貼っておかなければならない訳です。

Remark(5)MeCab による日本語トークナイザー

parse-japanese-mecab: Japanese tokenizer with MeCab


2020/09/11
修正 2020/09/13
藤田昭人


2020/09/13 追記しました

ソースコード に誤りを見つけたため修正版に更新しました。 結局、nlcst 構文木は自前で構築することになりました。 すいません。

僕にとって JavaScript は5つ目のプログラミング言語なんですが、 相変わらず習得に苦労しています。 今回どハマりしているのは prototype です。 もちろん JavaScriptC++Java のような class による記法をサポートしているのですが、 困った事に remark / unified では prototype を使って クラスを実装しているので読み下すのにてこずってます。

この種のトラブルには「わかってしまえばなんて事ない」原因が付き物なのですけども、 それに気付くまでに費やす時間たるや… またもや「ほんと我ながら言語のセンスがない」ことを再確認させられています。 悪戦苦闘して作ったのは、 前回 紹介した parse-latin のラッパーのサブクラスのバージョンです。 コードの方は後ほど…

パーサーをプラグイン・チェーンに挿入するには

何故サブクラスのバージョンを作ったかというと、 プラグイン・チェーンに挿入できるパーサーにする必要があったからです。

remark には各トークンに品詞情報を付与するための retext-pos が用意されてますが、 これを呼び出すためには retextプラグイン・チェーンを構成する必要があります。 例えば、次のコードで構文木には品詞情報が埋め込まれます。

var unified = require('unified');
var japanese = require('./retext-wrapper');
var pos = require('retext-pos');
var stringify = require('retext-stringify');
var inspect = require('unist-util-inspect');


function plugin() {
  return(transformer);

  function transformer(tree) {
    //console.log(inspect(tree));
    console.log("{ type: '%s' }", tree.type);
    console.log("{ type: '%s' }", tree.children[0].type);
    console.log("{ type: '%s' }", tree.children[0].children[0].type);
    console.log(tree.children[0].children[0].children[0]);
    console.log(tree.children[0].children[0].children[1]);
    console.log(tree.children[0].children[0].children[2]);
    console.log(tree.children[0].children[0].children[3]);
    console.log(tree.children[0].children[0].children[4]);
    console.log(tree.children[0].children[0].children[5]);
  }
}


var str = "Professor Bob Fabry, of the University of California at Berkeley, was in attendance and immediately became interested in obtaining a copy of the system to experiment with at Berkeley.";

var processor = unified()
    .use(japanese)
    .use(pos)
    .use(plugin)
    .use(stringify);

processor.process(str, function (err) {
  if (err) {
    throw err;
  }
});

このコードで登場するプラグイン・バージョンの パーサー retext-wrapper は次のようなコードになります。 retext-english をほんの少しだけ修正しました。

'use strict'

var unherit  = require('unherit');
var Wrapper = require('./parse-wrapper');

module.exports = parse;
parse.Parser = Wrapper;

function parse() {
  this.Parser = unherit(Wrapper);
}

ここで 前回 紹介したラッパーバージョンのパーサー本体を使うと プラグイン・チェインの実行時のチェックにひっかかって異常終了します。 まるで「風が吹けば桶屋が儲かる」みたいにトラブルが出現しました(笑)


ラッパーからサブクラスへの書き直し

やむなく unified のコードを読んでみたところ、 パーサーはクラスとして実装されていることを仮定してるように見えました。 前回紹介したようにパーサーの核をなし nlcst 構文木を生成してくれる parse-latin のコードはそのまま残したいので、 次のようにそのサブクラスとしてラッパーを定義することにしました。

'use strict'

var ParseLatin = require('parse-latin');

// Inherit from `ParseLatin`.
ParseLatinPrototype.prototype = ParseLatin.prototype;

// Constructor to create a `ParseWrapper` prototype.
function ParseLatinPrototype() {
}

ParseWrapper.prototype = new ParseLatinPrototype();

// Transform Japanese natural language into an NLCST-tree.
function ParseWrapper(doc, file) {
  if (!(this instanceof ParseWrapper)) {
    return(new ParseWrapper(doc, file));
  }

  ParseLatin.apply(this, arguments)
  ParseLatin.prototype.position = false;
}

ParseWrapper.prototype.parse = function(value) {
  return(ParseLatin.prototype.parse.call(this, value));
}

ParseWrapper.prototype.tokenize = function(value) {
  return(ParseLatin.prototype.tokenize.call(this, value));
}

module.exports = ParseWrapper;

実は parse-english のコードから継承する部分のみを切り出したものです。 もっとも少しだけ変更も加えてます。 15行目の ParseLatin.prototype.position を false にして 位置情報を記録しないようにしてあります。

以上のコードを組み合わせると…

$ node pos.js
{ type: 'RootNode' }
{ type: 'ParagraphNode' }
{ type: 'SentenceNode' }
{ type: 'WordNode',
  children: [ { type: 'TextNode', value: 'Professor' } ],
  data: { partOfSpeech: 'NNP' } }
{ type: 'WhiteSpaceNode', value: ' ' }
{ type: 'WordNode',
  children: [ { type: 'TextNode', value: 'Bob' } ],
  data: { partOfSpeech: 'NNP' } }
{ type: 'WhiteSpaceNode', value: ' ' }
{ type: 'WordNode',
  children: [ { type: 'TextNode', value: 'Fabry' } ],
  data: { partOfSpeech: 'NN' } }
{ type: 'PunctuationNode', value: ',' }
$ 

ようやく動いてくれました(笑)

品詞情報は 'WordNode' に .data.partOfSpeech として埋め込まれます。 英語の場合はトークン化と品詞情報の設定は個別の処理として扱われますが、 日本語の場合は(ご存知のとおり)形態素解析で両方を一気に解決します。


mecab コマンドの呼び出し

さて、その形態素解析ですが、 まずは安直に MeCab コマンドを呼び出すことにしました。 標準サポートのコマンド同期実行の API を使って、 次のようなインターフェースを作りました。

const execSync = require('child_process').execSync;

//const dic = "-d /usr/local/lib/mecab/dic/mecab-ipadic-neologd";
const fmt = "-F'%m\t%h\n'";
const eos = "-E'\n'";

function single_sync_exec(msg) {
    //var command = 'echo "'+msg+'" | mecab '+dic+' '+fmt+' '+eos;
    var command = 'echo "'+msg+'" | mecab '+fmt+' '+eos;
    const ret = execSync(command);
    var sentences = String(ret).split('\n\n');

    var tokens = sentences[0].split('\n');
    var tkn = [];
    for (var j = 0; j < tokens.length; j++) {
    var each = tokens[j].split('\t');
    var elm = [];
    elm[0] = each[0];
    elm[1] = Number(each[1]);
    tkn.push(elm);
    }
    return(tkn);
}

module.exports = single_sync_exec;

このインターフェースでは mecab コマンドの出力どおり トークンと品詞IDが配列に格納されて返ってきます。 ちなみに、品詞IDについては末尾のIPA品詞体系をご覧ください。 今回は MeCab 標準の IPA 辞書を使いますが、 もし NEologd 辞書が使いたい場合はコメントアウトを外してください。 このインターフェースを実行するとこうなります。

$ cat morphological.js
var MecabCall = require('./mecab_call');

var str = "これこそAT&T ベル研究所のケン・トンプソンとデニス・リッチーの二人がUNIXの論文を初めて発表したシンポジウムである。";

console.log(MecabCall(str));
$ node morphological.js
[ [ 'これ', 59 ],
  [ 'こそ', 16 ],
  [ 'AT&T', 45 ],
  [ 'ベル', 38 ],
  [ '研究所', 38 ],
  [ '', 24 ],
  [ 'ケン', 44 ],
  [ '', 4 ],
  [ 'トンプソン', 43 ],
  [ '', 23 ],
  [ 'デニス', 44 ],
  [ '', 4 ],
  [ 'リッチー', 38 ],
  [ '', 24 ],
  [ '', 48 ],
  [ '', 53 ],
  [ '', 13 ],
  [ 'UNIX', 38 ],
  [ '', 24 ],
  [ '論文', 38 ],
  [ '', 13 ],
  [ '初めて', 34 ],
  [ '発表', 36 ],
  [ '', 31 ],
  [ '', 25 ],
  [ 'シンポジウム', 38 ],
  [ '', 25 ],
  [ 'ある', 25 ],
  [ '', 7 ] ]
$ 

このインターフェースの弱点の1つは 解析対象となるメッセージをコマンドライン経由で MeCab コマンドに引き渡しているところです。 なので、形態素解析の邪魔しないように配慮して、 メッセージを小分けにしてインターフェースをコールする必要があります。 必然的に遅くなりますが「取り敢えず形態素解析ができれば良し」とします。


parse-japanese-mecab の作成

MeCab インターフェースを組み込んだ parse-japanese-mecab を作成しました。
コードを次に示します。

'use strict'

var ParseLatin = require('parse-latin');
var MeCabCall =  require('./mecab_call');
var inspect = require('unist-util-inspect');

// Inherit from `ParseLatin`.
ParseLatinPrototype.prototype = ParseLatin.prototype;

// Constructor to create a `ParseJapanese` prototype.
function ParseLatinPrototype() {
}

ParseJapanese.prototype = new ParseLatinPrototype();

// Transform Japanese natural language into an NLCST-tree.
function ParseJapanese(doc, file) {
  if (!(this instanceof ParseJapanese)) {
    return(new ParseJapanese(doc, file));
  }

  ParseLatin.apply(this, arguments)
  ParseLatin.prototype.position = false;
}

function tokenizeRoot(node) {
  var root = {};
  root.type = 'RootNode';
  root.children = [];
  var para = {};
  para.type = 'ParagraphNode';
  para.children = [];
  var sent = {};
  sent.type = 'SentenceNode';
  sent.children = [];

  for (var i = 0; i < node.length; i++) {
    if (node[i].type == 'PunctuationNode') {
      sent.children.push(node[i]);
      if (node[i].value == '.' || node[i].value == '。') {
    para.children.push(sent);
    sent = {};
    sent.type = 'SentenceNode';
    sent.children = [];
      }
    } else if (node[i].type == 'WhiteSpaceNode') {
      root.children.push(para);
      para = {};
      para.type = 'ParagraphNode';
      para.children = [];
    } else {
      sent.children.push(node[i]);
    }
  }
  root.children.push(para);

  return(root);
}

ParseJapanese.prototype.parse = function(value) {
  //return(ParseLatin.prototype.parse.call(this, value));
  return tokenizeRoot(value);
}

// 'WordNode'
function createWordNode(txt, pos) {
  var text = {};
  text.type  = 'TextNode';
  text.value = txt

  var data = {};
  data.partOfSpeech = pos;

  var word = {};
  word.type = 'WordNode';
  word.children = [];
  word.children.push(text);
  word.data = data;

  return(word);
}

// 'WhiteSpaceNode'
function createWhiteSpaceNode(txt) {
  var word = {};
  word.type  = 'WhiteSpaceNode';
  word.value = txt;
  return(word);
}

// 'PunctuationNode'
function createPunctuationNode(txt) {
  var word = {};
  word.type  = 'PunctuationNode';
  word.value = txt;
  return(word);
}

function tokenize(value) {
  if (value === null || value === undefined) {
    value = '';
  } else if (value instanceof String) {
    value = value.toString();
  }

  if (typeof value !== 'string') {
    // Return the given nodes
    // if this is either an empty array,
    // or an array with a node as a first child.
    if ('length' in value && (!value[0] || value[0].type)) {
      return(value);
    }

    throw new Error("Illegal invocation: '" + value +
            "' is not a valid argument for 'ParseLatin'");
  }

  var tokens = [];

  value = value.replace(/\n/g, "");
  if (value.indexOf('。') <= -1) {
    var ret = MeCabCall(value);
    for (var i = 0; i < ret.length; i++) {
      if (ret[i][1] == 8) {       // 記号  空白
    tokens.push(createWhiteSpaceNode(ret[i][0]));
      } else if (ret[i][1] == 7 ||  // 記号  句点
         ret[i][1] == 9) {  // 記号  読点
    tokens.push(createPunctuationNode(ret[i][0]));
      } else {
    tokens.push(createWordNode(ret[i][0], ret[i][1]));
      }
    }
  } else {
    var sentences = String(value).split('。');
    for (var j = 0; j < sentences.length-1; j++) {
      var ret = MeCabCall(sentences[j]+'。');
      for (var i = 0; i < ret.length; i++) {
    if (ret[i][1] == 8) {     // 記号  空白
      tokens.push(createWhiteSpaceNode(ret[i][0]));
    } else if (ret[i][1] == 7 ||    // 記号  句点
           ret[i][1] == 9) {    // 記号  読点
      tokens.push(createPunctuationNode(ret[i][0]));
    } else {
      tokens.push(createWordNode(ret[i][0], ret[i][1]));
    }
      }
    }
  }
  return(tokens);
}

ParseJapanese.prototype.tokenize = function(value) {
  //return(ParseLatin.prototype.tokenize.call(this, value));
  return tokenize(value);
}

module.exports = ParseJapanese;

パーサーは markdown ドキュメントのパラグラフ毎に呼び出されます。 (mdast 構文木がパラグラフ単位だからでしょう) 複数の文が含まれるパラグラフでは文単位で分割して形態素解析を行います。 また WordNode の間は強制的に WhiteSpaceNode を挿入しています(81行、99行)。 このようにしないとパーサーが誤認識して適切な nlcst 構文木を構成してくれません。


まとめ

ということで、ようやく日本語パーサーが(一応)形になりましたが…

いやぁ parse-latin の解析にはてこずりました(笑)

強いて理由を挙げれば pluggable です。 parse-latin には次のコメントがあります。

All these nodes are pluggable: they come with a use method which accepts a plugin (function(NLCSTNode)). Every time one of these methods are called, the plugin is invoked with the node, allowing for easy modification.

In fact, the internal transformation from tokenize (a list of words, white space, punctuation, and symbols) to tokenizeRoot (an NLCST tree), is also implemented through this mechanism.

これらのノードはすべて pluggable です: プラグインを受け入れる use メソッドが付属しています (function(NLCSTNode))。 これらのメソッドが呼ばれるたびにプラグインがノードとともに呼び出され、簡単に変更できるようになります。

実際、tokenize (単語、空白、句読点、記号のリスト) から tokenizeRoot (NLCSTツリー) への内部変換もこのメカニズムで実装されています。

どうやら構文解釈の機能をプラグインに小分けしているようです。 現時点では、この pluggable のメカニズムを完全に理解した訳ではありません。 なので、WhiteSpaceNode の強制挿入でお茶を濁してます。

ですが…

先を急ぎたいので、まずはこのバージョンで。すいません。

2020/09/13
形態素解析の誤認識を修正する作業をしていたら問題を発見。 ソースを入れ替えました。

以上


付録:IPA品詞体系

こちらから引用しました。

こちらも併用しています。

ID 品詞 細分類1
0 その他 間投 「あ」「ア」のみ
1 フィラー 「えーと」「なんか」など
2 感動詞 「うむ」「お疲れさま」「トホホ」
3 記号 アルファベット 「A-z」
4 記号 一般 「?」「!」「¥」
5 記号 括弧開 「(」「【」など
6 記号 括弧閉 「 )」「】」など
7 記号 句点 「。」「.」のみ
8 記号 空白 「 」のみ
9 記号 読点 「、」「,」のみ
10 形容詞 自立 「美しい」「楽しい」
11 形容詞 接尾 「ったらしい」「っぽい」
12 形容詞 非自立 「づらい」「がたい」「よい」
13 助詞 格助詞 一般 「の」「から」「を」
14 助詞 格助詞 引用 「と」のみ
15 助詞 格助詞 連語 「について」「とかいう」
16 助詞 係助詞 「は」「こそ」「も」「や」
17 助詞 終助詞 「かしら」「ぞ」「っけ」「わい」
18 助詞 接続助詞 「て」「つつ」「および」「ので」
19 助詞 特殊 「かな」「けむ」「にゃ」
20 助詞 副詞化 「と」「に」のみ
21 助詞 副助詞 「くらい」「なんか」「ばっかり」
22 助詞 副助詞/並立助詞/終助詞 「か」のみ
23 助詞 並立助詞 「とか」「だの」「やら」
24 助詞 連体化 「の」のみ
25 助動詞 「ます」「らしい」「です」
26 接続詞 「だから」「しかし」
27 接頭詞 形容詞接続 「お」「まっ」
28 接頭詞 数接続 「計」「毎分」
29 接頭詞 動詞接続 「ぶっ」「引き」
30 接頭詞 名詞接続 「最」「総」
31 動詞 自立 「投げる」
32 動詞 接尾 「しまう」「ちゃう」「願う」
33 動詞 非自立 「しまう」「ちゃう」「願う」
34 副詞 一般 「あいかわらず」「多分」
35 副詞 助詞類接続 「こんなに」「そんなに」
36 名詞 サ変接続 「インプット」「悪化」
37 名詞 ナイ形容詞語幹 「申し訳」「仕方」
38 名詞 一般 「テーブル」
39 名詞 引用文字列 「いわく」のみ
40 名詞 形容動詞語幹 「健康」「安易」「駄目」
41 名詞 固有名詞 一般 北穂高岳」、「電通銀座ビル」、「G1」
42 名詞 固有名詞 人名 一般 グッチ裕三」、「紫式部
43 名詞 固有名詞 人名 「山田」、「ビスコンティ
44 名詞 固有名詞 人名 「B作」、「アントニオ」、「右京太夫
45 名詞 固有名詞 組織 「株式会社◯◯」
46 名詞 固有名詞 地域 一般 「東京」
47 名詞 固有名詞 地域 「日本」
48 名詞 「0」「一」
49 名詞 接続詞的 「◯対◯」「◯兼◯」
50 名詞 接尾 サ変接続 「(可視)化」
51 名詞 接尾 一般 「感」「観」「性」
52 名詞 接尾 形容動詞語幹 「的」「げ」「がち」
53 名詞 接尾 助数詞 「個」「つ」「本」「冊」
54 名詞 接尾 助動詞語幹 「そ」、「そう」のみ
55 名詞 接尾 人名 「君」、「はん」
56 名詞 接尾 地域 「州」、「市内」、「港」
57 名詞 接尾 特殊 「ぶり」、「み」、「方」
58 名詞 接尾 副詞可能 「いっぱい」、「前後」、「次第」
59 名詞 代名詞 一般 「そこ」、「俺」、「こんちくしょう」
60 名詞 代名詞 縮約 「わたしゃ」、「そりゃあ」
61 名詞 動詞非自立的 「しまう」、「ちゃう」、「願う」
62 名詞 特殊 助動詞語幹 「そ」、「そう」のみ
63 名詞 非自立 一般 「こと」、「きらい」、「くせ」、「もの」
64 名詞 非自立 形容動詞語幹 「ふう」、「みたい」のみ
65 名詞 非自立 助動詞語幹 「よ」、「よう」のみ
66 名詞 非自立 副詞可能 「限り」、「さなか」、「うち」
67 名詞 副詞可能 「10月」、「せんだって」、「当分」
68 連体詞 「この」、「いろんな」、「おっきな」、「堂々たる」

Remark(4)mdast-util-to-nlcstとparse-latin

mdast-util-to-nlcst and parse-latin


2020/09/03
藤田昭人


前回 の調査ではmdast構文木からnlcst構文木への複製は mdast-util-to-nlcst で実装されていることがわかりました。 本稿ではmdast-util-to-nlcst と、その下位にある parse-englishparse-dutchparse-latin の関係について調べてみます。

実は日本語文のパーサーというと 「既存の形態素解析機をどのように組み込むか?」 という問題にばかり気を取られていたのですが、調べてみると 「nlcst 構文木をどのように(再)構築するか?」 の方が問題が多いことに気付かされました。 実際、英文と同様のnlcst 構文木が再現できないと、 remark としての使い勝手に影響がありますもんね。 本稿ではそのあたりの解決策を紹介します。


mdast-util-to-nlcst:mdast から nlcst に変換するためのユーティリティ

前回 説明したように mdast-util-to-nlcstremark-retext が提供する mdast 構文木からnlcst 構文木への複製を行う処理の核です。 API は次のエントリーだけ。

toNlcst(tree, file, Parser[, options])

mdast構文木 を対応する仮想ファイルを持つ nlcst に変換します。

パラメータ
tree

位置情報を持つmdast内の構文木MdastNode)。

file

仮想ファイル(VFile

parser

nlcst パーサー(Function)。 例えば、parse-englishparse-dutchparse-latin など。

options.ignore

無視する types のリスト(Array.<string>)。

`table'、'tableRow'、'tableCell' は常に無視されます。

options.source

ソースとしてマークするtypesのリスト(Array.<string>)。

`inlineCode' は常にソースとしてマークされます。

リターン

NlcstNode.

引数 tree として mdast 構文木を、 引数 file には元データ、 そして引数 Parser にパーサーコードを渡すと、 戻り値に nlcst 構文木が返ってきます。

ソースコード

mdast-util-to-nlcst のページには プレーン・テキストを unifiedプラグイン・チェインを 使わないでパースする次のサンプルコードが掲載されています (ちょこっとだけ修正しましたが)。

var toNLCST = require('mdast-util-to-nlcst')
var inspect = require('unist-util-inspect')
var English = require('parse-english')
var remark = require('remark')
var vfile = require('vfile')
var file = vfile('Someone said Hello.')
var tree = remark().parse(file)
console.log("\n# MDAST")
console.log(inspect(tree))
var nlcst = toNLCST(tree, file, English)
console.log("\n# NLCST")
console.log(inspect(nlcst))

このコードを実行すると次のようになります。

$ npm install mdast-util-to-nlcst unist-util-inspect parse-english remark vfile
 ・・・
$ node example.js

# MDAST
root[1] (1:1-1:20, 0-19)
└─0 paragraph[1] (1:1-1:20, 0-19)
    └─0 text "Someone said Hello." (1:1-1:20, 0-19)

# NLCST
RootNode[1] (1:1-1:20, 0-19)
└─0 ParagraphNode[1] (1:1-1:20, 0-19)
    └─0 SentenceNode[6] (1:1-1:20, 0-19)
        ├─0 WordNode[1] (1:1-1:8, 0-7)
        │   └─0 TextNode "Someone" (1:1-1:8, 0-7)
        ├─1 WhiteSpaceNode " " (1:8-1:9, 7-8)
        ├─2 WordNode[1] (1:9-1:13, 8-12)
        │   └─0 TextNode "said" (1:9-1:13, 8-12)
        ├─3 WhiteSpaceNode " " (1:13-1:14, 12-13)
        ├─4 WordNode[1] (1:14-1:19, 13-18)
        │   └─0 TextNode "Hello" (1:14-1:19, 13-18)
        └─5 PunctuationNode "." (1:19-1:20, 18-19)
$ 

Markdown 由来の MDAST とそれを変換した NLCST の違いに注目してください。
MDAST がパラグラフ単位の構文木であるのに対し、 NLCST はワード単位の構文木になっています。
これが Natural Language Concrete Syntax Tree と名付けられている理由なんでしょうね。


parse-latin:nlcst ノードを生成するretextのためのラテン文字言語パーサー

mdast-util-to-nlcstからコールされるパーサーは 引数で明示的に指定してやる必要があります。ドキュメントを見ると parse-englishparse-dutchparse-latin の3種類があるので、当初は何となく「我々は parse-english なのかな?」 などと安易に考えていたのですが…

実は3つとも javascript のクラスとして記述されています。 3者の関係は parse-latin が基底クラスで、 ラテン文字言語向けの汎用パーサーとして実装されています。 残る2つは parse-latin の派生クラスでして、 各々各言語固有の略号に対する例外的な処理以外は parse-latin に依存しているようです。

なので…

nlcst 構文木の(再)構築方法を調べる場合は、 parse-latin に集中して良さそうです。
API は次のとおりです。

ParseLatin#tokenize(value)

valuestring)を、文字と数字(単語)、空白、その他すべて(句読点)にトークン化します。 返されるノードは、段落や文章のないフラットなリストです。

リターン

Array.<Node> — Nodes.

ParseLatin#parse(value)

valuestring)をNLCST構文木トークン化します。 返されるノードは、段落と文を含むRootNodeです。

リターン

Node — Root node.

以上2つが公式APIなんですが…
mdast-util-to-nlcst では、 parse-latin の隠しAPIをもうひとつコールしています。

ParseLatin#tokenizeWhiteSpace(value)

valuestring)で渡される空白、空行("\n\n")などをノード化します。 返されるノードは、WhiteSpaceNodeです。

リターン

Node — WhiteSpace node.

このAPIは主に mdast で検知されたパラグラフ間に WhiteSpaceNode を差し込むためにコールされているように見えます。

どうやら 「ParseLatin#parseトークン化するテキストを渡すとNLCST構文木が返ってくる」 という単機能のパーサーではなさそうです。


parse-wrapper:パーサーの挙動を調べるためのラッパー

…という事で

次のようなパーサーのラッパーを書いて、パーサーの呼び出し順序と 引数、戻り値をチェックしてみることにしました。

まずは呼び出し順序の確認から…

var Latin = require('parse-latin');
var localParser;
var localTokenizeWhiteSpace;

var ParseWrapper = function() {
  console.log("\n### ParseWrapper()");
  localParser = new Latin();
  localTokenizeWhiteSpace = localParser.tokenizeWhiteSpace;
}

ParseWrapper.prototype.parse = function(value) {
  console.log("\n### ParseWrapper.prototype.parse");
  var ret = localParser.parse(value);
  return(ret);
}

ParseWrapper.prototype.tokenize = function(value) {
  console.log("\n### ParseWrapper.prototype.tokenize");
  var ret = localParser.tokenize(value);
  return(ret);
}

ParseWrapper.prototype.tokenizeWhiteSpace = function() {
  console.log("\n### ParseWrapper.prototype.tokenizeWhiteSpace");
  var ret = localTokenizeWhiteSpace.apply(this, arguments);
  return(ret);
}

module.exports = ParseWrapper;

テストプログラムはこんな感じ。

var toNLCST = require('mdast-util-to-nlcst');
var inspect = require('unist-util-inspect');
var English = require('./parse-wrapper');
var remark = require('remark');
var vfile = require('to-vfile');

var file = vfile.readSync('english.md',  'utf8');
var tree = remark().parse(file);
var nlcst = toNLCST(tree, file, English);

これを実行すると次のようになります*1

$ cat english.md
# Twenty Years of Berkeley Unix: Twenty Years of Berkeley Unix From AT&T-Owned to Freely Redistributable

Marshall Kirk McKusick

## Early History

Ken Thompson and Dennis Ritchie presented the first Unix paper at the Symposium on Operating Systems Principles at Purdue University in November 1973. Professor Bob Fabry, of the University of California at Berkeley, was in attendance and immediately became interested in obtaining a copy of the system to experiment with at Berkeley.
$ node example.js

### ParseWrapper()

### ParseWrapper.prototype.tokenize

### ParseWrapper.prototype.tokenizeWhiteSpace

### ParseWrapper.prototype.tokenize

### ParseWrapper.prototype.tokenizeWhiteSpace

### ParseWrapper.prototype.tokenize

### ParseWrapper.prototype.tokenizeWhiteSpace

### ParseWrapper.prototype.tokenize

### ParseWrapper.prototype.parse
$

なるほど…

なお、引数と戻り値の確認は長くなりそうなので 末尾 に掲載します。


parse-japanese に向けて

ラッパーを使って、パーサーの呼び出し順序と引数、戻り値をチェックしてみましたが、 整理すると parse-latin の振る舞いは次のようになりそうです。

  1. mdast 構文木を走査してトークン化を行う
    • ParseLatin#tokenize:プレーン・テキストをトークン列に分割
    • ParseLatin#tokenizeWhiteSpaceパラダイム間を検出したら WhiteSpaceNode を挿入
    • いずれもフラットな配列に順次格納していく
  2. 配列に格納したトークンデータをParseLatin#parse を渡しnlcst 構文木を(再)構成
    • RootNodeParagraphNodeSentenceNode を挿入

ここまで調べたところで、ふと閃いたのが 「ParseLatin#tokenizeの部分だけ、日本語の形態素解析に差し替えたら parse-japanese にならない?」 ってことでした。

そこで次回はこのアプローチで parse-japanese を試作してみます。

以上

$ node example.js
root[5] (1:1-15:1, 0-962)
├─0 heading[1] (1:1-1:105, 0-104)
│   │ depth: 1
│   └─0 text "Twenty Years of Berkeley Unix: Twenty Years of Berkeley Unix From AT&T-Owned to Freely Redistributable" (1:3-1:105, 2-104)
├─1 paragraph[1] (5:1-5:23, 108-130)
│   └─0 text "Marshall Kirk McKusick" (5:1-5:23, 108-130)
├─2 heading[1] (9:1-9:17, 134-150)
│   │ depth: 2
│   └─0 text "Early History" (9:4-9:17, 137-150)
├─3 paragraph[1] (11:1-11:335, 152-486)
│   └─0 text "Ken Thompson and Dennis Ritchie presented the first Unix paper at the Symposium on Operating Systems Principles at Purdue University in November 1973. Professor Bob Fabry, of the University of California at Berkeley, was in attendance and immediately became interested in obtaining a copy of the system to experiment with at Berkeley." (11:1-11:335, 152-486)
└─4 paragraph[1] (13:1-13:473, 488-960)
    └─0 text "At the time, Berkeley had only large mainframe computer systems doing batch processing, so the first order of business was to get a PDP-11/45 suitable for running with the then-current Version 4 of Unix. The Computer Science Department at Berkeley, together with the Mathematics Department and the Statistics Department, were able to jointly purchase a PDP-11/45. In January 1974, a Version 4 tape was delivered and Unix was installed by graduate student Keith Standiford." (13:1-13:473, 488-960)

### ParseWrapper.prototype.parse

#### in
├─0 WordNode[1] (1:3-1:9, 2-8)
│   └─0 TextNode "Twenty" (1:3-1:9, 2-8)
├─1 WhiteSpaceNode " " (1:9-1:10, 8-9)
├─2 WordNode[1] (1:10-1:15, 9-14)
│   └─0 TextNode "Years" (1:10-1:15, 9-14)
├─3 WhiteSpaceNode " " (1:15-1:16, 14-15)
├─4 WordNode[1] (1:16-1:18, 15-17)
│   └─0 TextNode "of" (1:16-1:18, 15-17)
├─5 WhiteSpaceNode " " (1:18-1:19, 17-18)
├─6 WordNode[1] (1:19-1:27, 18-26)
│   └─0 TextNode "Berkeley" (1:19-1:27, 18-26)
├─7 WhiteSpaceNode " " (1:27-1:28, 26-27)
├─8 WordNode[1] (1:28-1:32, 27-31)
│   └─0 TextNode "Unix" (1:28-1:32, 27-31)
├─9 PunctuationNode ":" (1:32-1:33, 31-32)
├─10 WhiteSpaceNode " " (1:33-1:34, 32-33)
├─11 WordNode[1] (1:34-1:40, 33-39)
│   └─0 TextNode "Twenty" (1:34-1:40, 33-39)
├─12 WhiteSpaceNode " " (1:40-1:41, 39-40)
├─13 WordNode[1] (1:41-1:46, 40-45)
│   └─0 TextNode "Years" (1:41-1:46, 40-45)
├─14 WhiteSpaceNode " " (1:46-1:47, 45-46)
├─15 WordNode[1] (1:47-1:49, 46-48)
│   └─0 TextNode "of" (1:47-1:49, 46-48)
├─16 WhiteSpaceNode " " (1:49-1:50, 48-49)
├─17 WordNode[1] (1:50-1:58, 49-57)
│   └─0 TextNode "Berkeley" (1:50-1:58, 49-57)
├─18 WhiteSpaceNode " " (1:58-1:59, 57-58)
├─19 WordNode[1] (1:59-1:63, 58-62)
│   └─0 TextNode "Unix" (1:59-1:63, 58-62)
├─20 WhiteSpaceNode " " (1:63-1:64, 62-63)
├─21 WordNode[1] (1:64-1:68, 63-67)
│   └─0 TextNode "From" (1:64-1:68, 63-67)
├─22 WhiteSpaceNode " " (1:68-1:69, 67-68)
├─23 WordNode[1] (1:69-1:71, 68-70)
│   └─0 TextNode "AT" (1:69-1:71, 68-70)
├─24 SymbolNode "&" (1:71-1:72, 70-71)
├─25 WordNode[1] (1:72-1:73, 71-72)
│   └─0 TextNode "T" (1:72-1:73, 71-72)
├─26 PunctuationNode "-" (1:73-1:74, 72-73)
├─27 WordNode[1] (1:74-1:79, 73-78)
│   └─0 TextNode "Owned" (1:74-1:79, 73-78)
├─28 WhiteSpaceNode " " (1:79-1:80, 78-79)
├─29 WordNode[1] (1:80-1:82, 79-81)
│   └─0 TextNode "to" (1:80-1:82, 79-81)
├─30 WhiteSpaceNode " " (1:82-1:83, 81-82)
├─31 WordNode[1] (1:83-1:89, 82-88)
│   └─0 TextNode "Freely" (1:83-1:89, 82-88)
├─32 WhiteSpaceNode " " (1:89-1:90, 88-89)
├─33 WordNode[1] (1:90-1:105, 89-104)
│   └─0 TextNode "Redistributable" (1:90-1:105, 89-104)
├─34 WhiteSpaceNode "\n\n\n\n" (1:105-5:1, 104-108)
├─35 WordNode[1] (5:1-5:9, 108-116)
│   └─0 TextNode "Marshall" (5:1-5:9, 108-116)
├─36 WhiteSpaceNode " " (5:9-5:10, 116-117)
├─37 WordNode[1] (5:10-5:14, 117-121)
│   └─0 TextNode "Kirk" (5:10-5:14, 117-121)
├─38 WhiteSpaceNode " " (5:14-5:15, 121-122)
├─39 WordNode[1] (5:15-5:23, 122-130)
│   └─0 TextNode "McKusick" (5:15-5:23, 122-130)
├─40 WhiteSpaceNode "\n\n\n\n" (5:23-9:1, 130-134)
├─41 WordNode[1] (9:4-9:9, 137-142)
│   └─0 TextNode "Early" (9:4-9:9, 137-142)
├─42 WhiteSpaceNode " " (9:9-9:10, 142-143)
├─43 WordNode[1] (9:10-9:17, 143-150)
│   └─0 TextNode "History" (9:10-9:17, 143-150)
├─44 WhiteSpaceNode "\n\n" (9:17-11:1, 150-152)
├─45 WordNode[1] (11:1-11:4, 152-155)
│   └─0 TextNode "Ken" (11:1-11:4, 152-155)
├─46 WhiteSpaceNode " " (11:4-11:5, 155-156)
├─47 WordNode[1] (11:5-11:13, 156-164)
│   └─0 TextNode "Thompson" (11:5-11:13, 156-164)
├─48 WhiteSpaceNode " " (11:13-11:14, 164-165)
├─49 WordNode[1] (11:14-11:17, 165-168)
│   └─0 TextNode "and" (11:14-11:17, 165-168)
├─50 WhiteSpaceNode " " (11:17-11:18, 168-169)
├─51 WordNode[1] (11:18-11:24, 169-175)
│   └─0 TextNode "Dennis" (11:18-11:24, 169-175)
├─52 WhiteSpaceNode " " (11:24-11:25, 175-176)
├─53 WordNode[1] (11:25-11:32, 176-183)
│   └─0 TextNode "Ritchie" (11:25-11:32, 176-183)
├─54 WhiteSpaceNode " " (11:32-11:33, 183-184)
├─55 WordNode[1] (11:33-11:42, 184-193)
│   └─0 TextNode "presented" (11:33-11:42, 184-193)
├─56 WhiteSpaceNode " " (11:42-11:43, 193-194)
├─57 WordNode[1] (11:43-11:46, 194-197)
│   └─0 TextNode "the" (11:43-11:46, 194-197)
├─58 WhiteSpaceNode " " (11:46-11:47, 197-198)
├─59 WordNode[1] (11:47-11:52, 198-203)
│   └─0 TextNode "first" (11:47-11:52, 198-203)
├─60 WhiteSpaceNode " " (11:52-11:53, 203-204)
├─61 WordNode[1] (11:53-11:57, 204-208)
│   └─0 TextNode "Unix" (11:53-11:57, 204-208)
├─62 WhiteSpaceNode " " (11:57-11:58, 208-209)
├─63 WordNode[1] (11:58-11:63, 209-214)
│   └─0 TextNode "paper" (11:58-11:63, 209-214)
├─64 WhiteSpaceNode " " (11:63-11:64, 214-215)
├─65 WordNode[1] (11:64-11:66, 215-217)
│   └─0 TextNode "at" (11:64-11:66, 215-217)
├─66 WhiteSpaceNode " " (11:66-11:67, 217-218)
├─67 WordNode[1] (11:67-11:70, 218-221)
│   └─0 TextNode "the" (11:67-11:70, 218-221)
├─68 WhiteSpaceNode " " (11:70-11:71, 221-222)
├─69 WordNode[1] (11:71-11:80, 222-231)
│   └─0 TextNode "Symposium" (11:71-11:80, 222-231)
├─70 WhiteSpaceNode " " (11:80-11:81, 231-232)
├─71 WordNode[1] (11:81-11:83, 232-234)
│   └─0 TextNode "on" (11:81-11:83, 232-234)
├─72 WhiteSpaceNode " " (11:83-11:84, 234-235)
├─73 WordNode[1] (11:84-11:93, 235-244)
│   └─0 TextNode "Operating" (11:84-11:93, 235-244)
├─74 WhiteSpaceNode " " (11:93-11:94, 244-245)
├─75 WordNode[1] (11:94-11:101, 245-252)
│   └─0 TextNode "Systems" (11:94-11:101, 245-252)
├─76 WhiteSpaceNode " " (11:101-11:102, 252-253)
├─77 WordNode[1] (11:102-11:112, 253-263)
│   └─0 TextNode "Principles" (11:102-11:112, 253-263)
├─78 WhiteSpaceNode " " (11:112-11:113, 263-264)
├─79 WordNode[1] (11:113-11:115, 264-266)
│   └─0 TextNode "at" (11:113-11:115, 264-266)
├─80 WhiteSpaceNode " " (11:115-11:116, 266-267)
├─81 WordNode[1] (11:116-11:122, 267-273)
│   └─0 TextNode "Purdue" (11:116-11:122, 267-273)
├─82 WhiteSpaceNode " " (11:122-11:123, 273-274)
├─83 WordNode[1] (11:123-11:133, 274-284)
│   └─0 TextNode "University" (11:123-11:133, 274-284)
├─84 WhiteSpaceNode " " (11:133-11:134, 284-285)
├─85 WordNode[1] (11:134-11:136, 285-287)
│   └─0 TextNode "in" (11:134-11:136, 285-287)
├─86 WhiteSpaceNode " " (11:136-11:137, 287-288)
├─87 WordNode[1] (11:137-11:145, 288-296)
│   └─0 TextNode "November" (11:137-11:145, 288-296)
├─88 WhiteSpaceNode " " (11:145-11:146, 296-297)
├─89 WordNode[1] (11:146-11:150, 297-301)
│   └─0 TextNode "1973" (11:146-11:150, 297-301)
├─90 PunctuationNode "." (11:150-11:151, 301-302)
├─91 WhiteSpaceNode " " (11:151-11:152, 302-303)
├─92 WordNode[1] (11:152-11:161, 303-312)
│   └─0 TextNode "Professor" (11:152-11:161, 303-312)
├─93 WhiteSpaceNode " " (11:161-11:162, 312-313)
├─94 WordNode[1] (11:162-11:165, 313-316)
│   └─0 TextNode "Bob" (11:162-11:165, 313-316)
├─95 WhiteSpaceNode " " (11:165-11:166, 316-317)
├─96 WordNode[1] (11:166-11:171, 317-322)
│   └─0 TextNode "Fabry" (11:166-11:171, 317-322)
├─97 PunctuationNode "," (11:171-11:172, 322-323)
├─98 WhiteSpaceNode " " (11:172-11:173, 323-324)
├─99 WordNode[1] (11:173-11:175, 324-326)
│   └─0 TextNode "of" (11:173-11:175, 324-326)
├─100 WhiteSpaceNode " " (11:175-11:176, 326-327)
├─101 WordNode[1] (11:176-11:179, 327-330)
│   └─0 TextNode "the" (11:176-11:179, 327-330)
├─102 WhiteSpaceNode " " (11:179-11:180, 330-331)
├─103 WordNode[1] (11:180-11:190, 331-341)
│   └─0 TextNode "University" (11:180-11:190, 331-341)
├─104 WhiteSpaceNode " " (11:190-11:191, 341-342)
├─105 WordNode[1] (11:191-11:193, 342-344)
│   └─0 TextNode "of" (11:191-11:193, 342-344)
├─106 WhiteSpaceNode " " (11:193-11:194, 344-345)
├─107 WordNode[1] (11:194-11:204, 345-355)
│   └─0 TextNode "California" (11:194-11:204, 345-355)
├─108 WhiteSpaceNode " " (11:204-11:205, 355-356)
├─109 WordNode[1] (11:205-11:207, 356-358)
│   └─0 TextNode "at" (11:205-11:207, 356-358)
├─110 WhiteSpaceNode " " (11:207-11:208, 358-359)
├─111 WordNode[1] (11:208-11:216, 359-367)
│   └─0 TextNode "Berkeley" (11:208-11:216, 359-367)
├─112 PunctuationNode "," (11:216-11:217, 367-368)
├─113 WhiteSpaceNode " " (11:217-11:218, 368-369)
├─114 WordNode[1] (11:218-11:221, 369-372)
│   └─0 TextNode "was" (11:218-11:221, 369-372)
├─115 WhiteSpaceNode " " (11:221-11:222, 372-373)
├─116 WordNode[1] (11:222-11:224, 373-375)
│   └─0 TextNode "in" (11:222-11:224, 373-375)
├─117 WhiteSpaceNode " " (11:224-11:225, 375-376)
├─118 WordNode[1] (11:225-11:235, 376-386)
│   └─0 TextNode "attendance" (11:225-11:235, 376-386)
├─119 WhiteSpaceNode " " (11:235-11:236, 386-387)
├─120 WordNode[1] (11:236-11:239, 387-390)
│   └─0 TextNode "and" (11:236-11:239, 387-390)
├─121 WhiteSpaceNode " " (11:239-11:240, 390-391)
├─122 WordNode[1] (11:240-11:251, 391-402)
│   └─0 TextNode "immediately" (11:240-11:251, 391-402)
├─123 WhiteSpaceNode " " (11:251-11:252, 402-403)
├─124 WordNode[1] (11:252-11:258, 403-409)
│   └─0 TextNode "became" (11:252-11:258, 403-409)
├─125 WhiteSpaceNode " " (11:258-11:259, 409-410)
├─126 WordNode[1] (11:259-11:269, 410-420)
│   └─0 TextNode "interested" (11:259-11:269, 410-420)
├─127 WhiteSpaceNode " " (11:269-11:270, 420-421)
├─128 WordNode[1] (11:270-11:272, 421-423)
│   └─0 TextNode "in" (11:270-11:272, 421-423)
├─129 WhiteSpaceNode " " (11:272-11:273, 423-424)
├─130 WordNode[1] (11:273-11:282, 424-433)
│   └─0 TextNode "obtaining" (11:273-11:282, 424-433)
├─131 WhiteSpaceNode " " (11:282-11:283, 433-434)
├─132 WordNode[1] (11:283-11:284, 434-435)
│   └─0 TextNode "a" (11:283-11:284, 434-435)
├─133 WhiteSpaceNode " " (11:284-11:285, 435-436)
├─134 WordNode[1] (11:285-11:289, 436-440)
│   └─0 TextNode "copy" (11:285-11:289, 436-440)
├─135 WhiteSpaceNode " " (11:289-11:290, 440-441)
├─136 WordNode[1] (11:290-11:292, 441-443)
│   └─0 TextNode "of" (11:290-11:292, 441-443)
├─137 WhiteSpaceNode " " (11:292-11:293, 443-444)
├─138 WordNode[1] (11:293-11:296, 444-447)
│   └─0 TextNode "the" (11:293-11:296, 444-447)
├─139 WhiteSpaceNode " " (11:296-11:297, 447-448)
├─140 WordNode[1] (11:297-11:303, 448-454)
│   └─0 TextNode "system" (11:297-11:303, 448-454)
├─141 WhiteSpaceNode " " (11:303-11:304, 454-455)
├─142 WordNode[1] (11:304-11:306, 455-457)
│   └─0 TextNode "to" (11:304-11:306, 455-457)
├─143 WhiteSpaceNode " " (11:306-11:307, 457-458)
├─144 WordNode[1] (11:307-11:317, 458-468)
│   └─0 TextNode "experiment" (11:307-11:317, 458-468)
├─145 WhiteSpaceNode " " (11:317-11:318, 468-469)
├─146 WordNode[1] (11:318-11:322, 469-473)
│   └─0 TextNode "with" (11:318-11:322, 469-473)
├─147 WhiteSpaceNode " " (11:322-11:323, 473-474)
├─148 WordNode[1] (11:323-11:325, 474-476)
│   └─0 TextNode "at" (11:323-11:325, 474-476)
├─149 WhiteSpaceNode " " (11:325-11:326, 476-477)
├─150 WordNode[1] (11:326-11:334, 477-485)
│   └─0 TextNode "Berkeley" (11:326-11:334, 477-485)
├─151 PunctuationNode "." (11:334-11:335, 485-486)
├─152 WhiteSpaceNode "\n\n" (11:335-13:1, 486-488)
├─153 WordNode[1] (13:1-13:3, 488-490)
│   └─0 TextNode "At" (13:1-13:3, 488-490)
├─154 WhiteSpaceNode " " (13:3-13:4, 490-491)
├─155 WordNode[1] (13:4-13:7, 491-494)
│   └─0 TextNode "the" (13:4-13:7, 491-494)
├─156 WhiteSpaceNode " " (13:7-13:8, 494-495)
├─157 WordNode[1] (13:8-13:12, 495-499)
│   └─0 TextNode "time" (13:8-13:12, 495-499)
├─158 PunctuationNode "," (13:12-13:13, 499-500)
├─159 WhiteSpaceNode " " (13:13-13:14, 500-501)
├─160 WordNode[1] (13:14-13:22, 501-509)
│   └─0 TextNode "Berkeley" (13:14-13:22, 501-509)
├─161 WhiteSpaceNode " " (13:22-13:23, 509-510)
├─162 WordNode[1] (13:23-13:26, 510-513)
│   └─0 TextNode "had" (13:23-13:26, 510-513)
├─163 WhiteSpaceNode " " (13:26-13:27, 513-514)
├─164 WordNode[1] (13:27-13:31, 514-518)
│   └─0 TextNode "only" (13:27-13:31, 514-518)
├─165 WhiteSpaceNode " " (13:31-13:32, 518-519)
├─166 WordNode[1] (13:32-13:37, 519-524)
│   └─0 TextNode "large" (13:32-13:37, 519-524)
├─167 WhiteSpaceNode " " (13:37-13:38, 524-525)
├─168 WordNode[1] (13:38-13:47, 525-534)
│   └─0 TextNode "mainframe" (13:38-13:47, 525-534)
├─169 WhiteSpaceNode " " (13:47-13:48, 534-535)
├─170 WordNode[1] (13:48-13:56, 535-543)
│   └─0 TextNode "computer" (13:48-13:56, 535-543)
├─171 WhiteSpaceNode " " (13:56-13:57, 543-544)
├─172 WordNode[1] (13:57-13:64, 544-551)
│   └─0 TextNode "systems" (13:57-13:64, 544-551)
├─173 WhiteSpaceNode " " (13:64-13:65, 551-552)
├─174 WordNode[1] (13:65-13:70, 552-557)
│   └─0 TextNode "doing" (13:65-13:70, 552-557)
├─175 WhiteSpaceNode " " (13:70-13:71, 557-558)
├─176 WordNode[1] (13:71-13:76, 558-563)
│   └─0 TextNode "batch" (13:71-13:76, 558-563)
├─177 WhiteSpaceNode " " (13:76-13:77, 563-564)
├─178 WordNode[1] (13:77-13:87, 564-574)
│   └─0 TextNode "processing" (13:77-13:87, 564-574)
├─179 PunctuationNode "," (13:87-13:88, 574-575)
├─180 WhiteSpaceNode " " (13:88-13:89, 575-576)
├─181 WordNode[1] (13:89-13:91, 576-578)
│   └─0 TextNode "so" (13:89-13:91, 576-578)
├─182 WhiteSpaceNode " " (13:91-13:92, 578-579)
├─183 WordNode[1] (13:92-13:95, 579-582)
│   └─0 TextNode "the" (13:92-13:95, 579-582)
├─184 WhiteSpaceNode " " (13:95-13:96, 582-583)
├─185 WordNode[1] (13:96-13:101, 583-588)
│   └─0 TextNode "first" (13:96-13:101, 583-588)
├─186 WhiteSpaceNode " " (13:101-13:102, 588-589)
├─187 WordNode[1] (13:102-13:107, 589-594)
│   └─0 TextNode "order" (13:102-13:107, 589-594)
├─188 WhiteSpaceNode " " (13:107-13:108, 594-595)
├─189 WordNode[1] (13:108-13:110, 595-597)
│   └─0 TextNode "of" (13:108-13:110, 595-597)
├─190 WhiteSpaceNode " " (13:110-13:111, 597-598)
├─191 WordNode[1] (13:111-13:119, 598-606)
│   └─0 TextNode "business" (13:111-13:119, 598-606)
├─192 WhiteSpaceNode " " (13:119-13:120, 606-607)
├─193 WordNode[1] (13:120-13:123, 607-610)
│   └─0 TextNode "was" (13:120-13:123, 607-610)
├─194 WhiteSpaceNode " " (13:123-13:124, 610-611)
├─195 WordNode[1] (13:124-13:126, 611-613)
│   └─0 TextNode "to" (13:124-13:126, 611-613)
├─196 WhiteSpaceNode " " (13:126-13:127, 613-614)
├─197 WordNode[1] (13:127-13:130, 614-617)
│   └─0 TextNode "get" (13:127-13:130, 614-617)
├─198 WhiteSpaceNode " " (13:130-13:131, 617-618)
├─199 WordNode[1] (13:131-13:132, 618-619)
│   └─0 TextNode "a" (13:131-13:132, 618-619)
├─200 WhiteSpaceNode " " (13:132-13:133, 619-620)
├─201 WordNode[1] (13:133-13:136, 620-623)
│   └─0 TextNode "PDP" (13:133-13:136, 620-623)
├─202 PunctuationNode "-" (13:136-13:137, 623-624)
├─203 WordNode[1] (13:137-13:139, 624-626)
│   └─0 TextNode "11" (13:137-13:139, 624-626)
├─204 PunctuationNode "/" (13:139-13:140, 626-627)
├─205 WordNode[1] (13:140-13:142, 627-629)
│   └─0 TextNode "45" (13:140-13:142, 627-629)
├─206 WhiteSpaceNode " " (13:142-13:143, 629-630)
├─207 WordNode[1] (13:143-13:151, 630-638)
│   └─0 TextNode "suitable" (13:143-13:151, 630-638)
├─208 WhiteSpaceNode " " (13:151-13:152, 638-639)
├─209 WordNode[1] (13:152-13:155, 639-642)
│   └─0 TextNode "for" (13:152-13:155, 639-642)
├─210 WhiteSpaceNode " " (13:155-13:156, 642-643)
├─211 WordNode[1] (13:156-13:163, 643-650)
│   └─0 TextNode "running" (13:156-13:163, 643-650)
├─212 WhiteSpaceNode " " (13:163-13:164, 650-651)
├─213 WordNode[1] (13:164-13:168, 651-655)
│   └─0 TextNode "with" (13:164-13:168, 651-655)
├─214 WhiteSpaceNode " " (13:168-13:169, 655-656)
├─215 WordNode[1] (13:169-13:172, 656-659)
│   └─0 TextNode "the" (13:169-13:172, 656-659)
├─216 WhiteSpaceNode " " (13:172-13:173, 659-660)
├─217 WordNode[1] (13:173-13:177, 660-664)
│   └─0 TextNode "then" (13:173-13:177, 660-664)
├─218 PunctuationNode "-" (13:177-13:178, 664-665)
├─219 WordNode[1] (13:178-13:185, 665-672)
│   └─0 TextNode "current" (13:178-13:185, 665-672)
├─220 WhiteSpaceNode " " (13:185-13:186, 672-673)
├─221 WordNode[1] (13:186-13:193, 673-680)
│   └─0 TextNode "Version" (13:186-13:193, 673-680)
├─222 WhiteSpaceNode " " (13:193-13:194, 680-681)
├─223 WordNode[1] (13:194-13:195, 681-682)
│   └─0 TextNode "4" (13:194-13:195, 681-682)
├─224 WhiteSpaceNode " " (13:195-13:196, 682-683)
├─225 WordNode[1] (13:196-13:198, 683-685)
│   └─0 TextNode "of" (13:196-13:198, 683-685)
├─226 WhiteSpaceNode " " (13:198-13:199, 685-686)
├─227 WordNode[1] (13:199-13:203, 686-690)
│   └─0 TextNode "Unix" (13:199-13:203, 686-690)
├─228 PunctuationNode "." (13:203-13:204, 690-691)
├─229 WhiteSpaceNode " " (13:204-13:205, 691-692)
├─230 WordNode[1] (13:205-13:208, 692-695)
│   └─0 TextNode "The" (13:205-13:208, 692-695)
├─231 WhiteSpaceNode " " (13:208-13:209, 695-696)
├─232 WordNode[1] (13:209-13:217, 696-704)
│   └─0 TextNode "Computer" (13:209-13:217, 696-704)
├─233 WhiteSpaceNode " " (13:217-13:218, 704-705)
├─234 WordNode[1] (13:218-13:225, 705-712)
│   └─0 TextNode "Science" (13:218-13:225, 705-712)
├─235 WhiteSpaceNode " " (13:225-13:226, 712-713)
├─236 WordNode[1] (13:226-13:236, 713-723)
│   └─0 TextNode "Department" (13:226-13:236, 713-723)
├─237 WhiteSpaceNode " " (13:236-13:237, 723-724)
├─238 WordNode[1] (13:237-13:239, 724-726)
│   └─0 TextNode "at" (13:237-13:239, 724-726)
├─239 WhiteSpaceNode " " (13:239-13:240, 726-727)
├─240 WordNode[1] (13:240-13:248, 727-735)
│   └─0 TextNode "Berkeley" (13:240-13:248, 727-735)
├─241 PunctuationNode "," (13:248-13:249, 735-736)
├─242 WhiteSpaceNode " " (13:249-13:250, 736-737)
├─243 WordNode[1] (13:250-13:258, 737-745)
│   └─0 TextNode "together" (13:250-13:258, 737-745)
├─244 WhiteSpaceNode " " (13:258-13:259, 745-746)
├─245 WordNode[1] (13:259-13:263, 746-750)
│   └─0 TextNode "with" (13:259-13:263, 746-750)
├─246 WhiteSpaceNode " " (13:263-13:264, 750-751)
├─247 WordNode[1] (13:264-13:267, 751-754)
│   └─0 TextNode "the" (13:264-13:267, 751-754)
├─248 WhiteSpaceNode " " (13:267-13:268, 754-755)
├─249 WordNode[1] (13:268-13:279, 755-766)
│   └─0 TextNode "Mathematics" (13:268-13:279, 755-766)
├─250 WhiteSpaceNode " " (13:279-13:280, 766-767)
├─251 WordNode[1] (13:280-13:290, 767-777)
│   └─0 TextNode "Department" (13:280-13:290, 767-777)
├─252 WhiteSpaceNode " " (13:290-13:291, 777-778)
├─253 WordNode[1] (13:291-13:294, 778-781)
│   └─0 TextNode "and" (13:291-13:294, 778-781)
├─254 WhiteSpaceNode " " (13:294-13:295, 781-782)
├─255 WordNode[1] (13:295-13:298, 782-785)
│   └─0 TextNode "the" (13:295-13:298, 782-785)
├─256 WhiteSpaceNode " " (13:298-13:299, 785-786)
├─257 WordNode[1] (13:299-13:309, 786-796)
│   └─0 TextNode "Statistics" (13:299-13:309, 786-796)
├─258 WhiteSpaceNode " " (13:309-13:310, 796-797)
├─259 WordNode[1] (13:310-13:320, 797-807)
│   └─0 TextNode "Department" (13:310-13:320, 797-807)
├─260 PunctuationNode "," (13:320-13:321, 807-808)
├─261 WhiteSpaceNode " " (13:321-13:322, 808-809)
├─262 WordNode[1] (13:322-13:326, 809-813)
│   └─0 TextNode "were" (13:322-13:326, 809-813)
├─263 WhiteSpaceNode " " (13:326-13:327, 813-814)
├─264 WordNode[1] (13:327-13:331, 814-818)
│   └─0 TextNode "able" (13:327-13:331, 814-818)
├─265 WhiteSpaceNode " " (13:331-13:332, 818-819)
├─266 WordNode[1] (13:332-13:334, 819-821)
│   └─0 TextNode "to" (13:332-13:334, 819-821)
├─267 WhiteSpaceNode " " (13:334-13:335, 821-822)
├─268 WordNode[1] (13:335-13:342, 822-829)
│   └─0 TextNode "jointly" (13:335-13:342, 822-829)
├─269 WhiteSpaceNode " " (13:342-13:343, 829-830)
├─270 WordNode[1] (13:343-13:351, 830-838)
│   └─0 TextNode "purchase" (13:343-13:351, 830-838)
├─271 WhiteSpaceNode " " (13:351-13:352, 838-839)
├─272 WordNode[1] (13:352-13:353, 839-840)
│   └─0 TextNode "a" (13:352-13:353, 839-840)
├─273 WhiteSpaceNode " " (13:353-13:354, 840-841)
├─274 WordNode[1] (13:354-13:357, 841-844)
│   └─0 TextNode "PDP" (13:354-13:357, 841-844)
├─275 PunctuationNode "-" (13:357-13:358, 844-845)
├─276 WordNode[1] (13:358-13:360, 845-847)
│   └─0 TextNode "11" (13:358-13:360, 845-847)
├─277 PunctuationNode "/" (13:360-13:361, 847-848)
├─278 WordNode[1] (13:361-13:363, 848-850)
│   └─0 TextNode "45" (13:361-13:363, 848-850)
├─279 PunctuationNode "." (13:363-13:364, 850-851)
├─280 WhiteSpaceNode " " (13:364-13:365, 851-852)
├─281 WordNode[1] (13:365-13:367, 852-854)
│   └─0 TextNode "In" (13:365-13:367, 852-854)
├─282 WhiteSpaceNode " " (13:367-13:368, 854-855)
├─283 WordNode[1] (13:368-13:375, 855-862)
│   └─0 TextNode "January" (13:368-13:375, 855-862)
├─284 WhiteSpaceNode " " (13:375-13:376, 862-863)
├─285 WordNode[1] (13:376-13:380, 863-867)
│   └─0 TextNode "1974" (13:376-13:380, 863-867)
├─286 PunctuationNode "," (13:380-13:381, 867-868)
├─287 WhiteSpaceNode " " (13:381-13:382, 868-869)
├─288 WordNode[1] (13:382-13:383, 869-870)
│   └─0 TextNode "a" (13:382-13:383, 869-870)
├─289 WhiteSpaceNode " " (13:383-13:384, 870-871)
├─290 WordNode[1] (13:384-13:391, 871-878)
│   └─0 TextNode "Version" (13:384-13:391, 871-878)
├─291 WhiteSpaceNode " " (13:391-13:392, 878-879)
├─292 WordNode[1] (13:392-13:393, 879-880)
│   └─0 TextNode "4" (13:392-13:393, 879-880)
├─293 WhiteSpaceNode " " (13:393-13:394, 880-881)
├─294 WordNode[1] (13:394-13:398, 881-885)
│   └─0 TextNode "tape" (13:394-13:398, 881-885)
├─295 WhiteSpaceNode " " (13:398-13:399, 885-886)
├─296 WordNode[1] (13:399-13:402, 886-889)
│   └─0 TextNode "was" (13:399-13:402, 886-889)
├─297 WhiteSpaceNode " " (13:402-13:403, 889-890)
├─298 WordNode[1] (13:403-13:412, 890-899)
│   └─0 TextNode "delivered" (13:403-13:412, 890-899)
├─299 WhiteSpaceNode " " (13:412-13:413, 899-900)
├─300 WordNode[1] (13:413-13:416, 900-903)
│   └─0 TextNode "and" (13:413-13:416, 900-903)
├─301 WhiteSpaceNode " " (13:416-13:417, 903-904)
├─302 WordNode[1] (13:417-13:421, 904-908)
│   └─0 TextNode "Unix" (13:417-13:421, 904-908)
├─303 WhiteSpaceNode " " (13:421-13:422, 908-909)
├─304 WordNode[1] (13:422-13:425, 909-912)
│   └─0 TextNode "was" (13:422-13:425, 909-912)
├─305 WhiteSpaceNode " " (13:425-13:426, 912-913)
├─306 WordNode[1] (13:426-13:435, 913-922)
│   └─0 TextNode "installed" (13:426-13:435, 913-922)
├─307 WhiteSpaceNode " " (13:435-13:436, 922-923)
├─308 WordNode[1] (13:436-13:438, 923-925)
│   └─0 TextNode "by" (13:436-13:438, 923-925)
├─309 WhiteSpaceNode " " (13:438-13:439, 925-926)
├─310 WordNode[1] (13:439-13:447, 926-934)
│   └─0 TextNode "graduate" (13:439-13:447, 926-934)
├─311 WhiteSpaceNode " " (13:447-13:448, 934-935)
├─312 WordNode[1] (13:448-13:455, 935-942)
│   └─0 TextNode "student" (13:448-13:455, 935-942)
├─313 WhiteSpaceNode " " (13:455-13:456, 942-943)
├─314 WordNode[1] (13:456-13:461, 943-948)
│   └─0 TextNode "Keith" (13:456-13:461, 943-948)
├─315 WhiteSpaceNode " " (13:461-13:462, 948-949)
├─316 WordNode[1] (13:462-13:472, 949-959)
│   └─0 TextNode "Standiford" (13:462-13:472, 949-959)
└─317 PunctuationNode "." (13:472-13:473, 959-960)

#### out
RootNode[9] (1:3-13:473, 2-960)
├─0 ParagraphNode[1] (1:3-1:105, 2-104)
│   └─0 SentenceNode[30] (1:3-1:105, 2-104)
│       ├─0 WordNode[1] (1:3-1:9, 2-8)
│       │   └─0 TextNode "Twenty" (1:3-1:9, 2-8)
│       ├─1 WhiteSpaceNode " " (1:9-1:10, 8-9)
│       ├─2 WordNode[1] (1:10-1:15, 9-14)
│       │   └─0 TextNode "Years" (1:10-1:15, 9-14)
│       ├─3 WhiteSpaceNode " " (1:15-1:16, 14-15)
│       ├─4 WordNode[1] (1:16-1:18, 15-17)
│       │   └─0 TextNode "of" (1:16-1:18, 15-17)
│       ├─5 WhiteSpaceNode " " (1:18-1:19, 17-18)
│       ├─6 WordNode[1] (1:19-1:27, 18-26)
│       │   └─0 TextNode "Berkeley" (1:19-1:27, 18-26)
│       ├─7 WhiteSpaceNode " " (1:27-1:28, 26-27)
│       ├─8 WordNode[1] (1:28-1:32, 27-31)
│       │   └─0 TextNode "Unix" (1:28-1:32, 27-31)
│       ├─9 PunctuationNode ":" (1:32-1:33, 31-32)
│       ├─10 WhiteSpaceNode " " (1:33-1:34, 32-33)
│       ├─11 WordNode[1] (1:34-1:40, 33-39)
│       │   └─0 TextNode "Twenty" (1:34-1:40, 33-39)
│       ├─12 WhiteSpaceNode " " (1:40-1:41, 39-40)
│       ├─13 WordNode[1] (1:41-1:46, 40-45)
│       │   └─0 TextNode "Years" (1:41-1:46, 40-45)
│       ├─14 WhiteSpaceNode " " (1:46-1:47, 45-46)
│       ├─15 WordNode[1] (1:47-1:49, 46-48)
│       │   └─0 TextNode "of" (1:47-1:49, 46-48)
│       ├─16 WhiteSpaceNode " " (1:49-1:50, 48-49)
│       ├─17 WordNode[1] (1:50-1:58, 49-57)
│       │   └─0 TextNode "Berkeley" (1:50-1:58, 49-57)
│       ├─18 WhiteSpaceNode " " (1:58-1:59, 57-58)
│       ├─19 WordNode[1] (1:59-1:63, 58-62)
│       │   └─0 TextNode "Unix" (1:59-1:63, 58-62)
│       ├─20 WhiteSpaceNode " " (1:63-1:64, 62-63)
│       ├─21 WordNode[1] (1:64-1:68, 63-67)
│       │   └─0 TextNode "From" (1:64-1:68, 63-67)
│       ├─22 WhiteSpaceNode " " (1:68-1:69, 67-68)
│       ├─23 WordNode[5] (1:69-1:79, 68-78)
│       │   ├─0 TextNode "AT" (1:69-1:71, 68-70)
│       │   ├─1 SymbolNode "&" (1:71-1:72, 70-71)
│       │   ├─2 TextNode "T" (1:72-1:73, 71-72)
│       │   ├─3 PunctuationNode "-" (1:73-1:74, 72-73)
│       │   └─4 TextNode "Owned" (1:74-1:79, 73-78)
│       ├─24 WhiteSpaceNode " " (1:79-1:80, 78-79)
│       ├─25 WordNode[1] (1:80-1:82, 79-81)
│       │   └─0 TextNode "to" (1:80-1:82, 79-81)
│       ├─26 WhiteSpaceNode " " (1:82-1:83, 81-82)
│       ├─27 WordNode[1] (1:83-1:89, 82-88)
│       │   └─0 TextNode "Freely" (1:83-1:89, 82-88)
│       ├─28 WhiteSpaceNode " " (1:89-1:90, 88-89)
│       └─29 WordNode[1] (1:90-1:105, 89-104)
│           └─0 TextNode "Redistributable" (1:90-1:105, 89-104)
├─1 WhiteSpaceNode "\n\n\n\n" (1:105-5:1, 104-108)
├─2 ParagraphNode[1] (5:1-5:23, 108-130)
│   └─0 SentenceNode[5] (5:1-5:23, 108-130)
│       ├─0 WordNode[1] (5:1-5:9, 108-116)
│       │   └─0 TextNode "Marshall" (5:1-5:9, 108-116)
│       ├─1 WhiteSpaceNode " " (5:9-5:10, 116-117)
│       ├─2 WordNode[1] (5:10-5:14, 117-121)
│       │   └─0 TextNode "Kirk" (5:10-5:14, 117-121)
│       ├─3 WhiteSpaceNode " " (5:14-5:15, 121-122)
│       └─4 WordNode[1] (5:15-5:23, 122-130)
│           └─0 TextNode "McKusick" (5:15-5:23, 122-130)
├─3 WhiteSpaceNode "\n\n\n\n" (5:23-9:1, 130-134)
├─4 ParagraphNode[1] (9:4-9:17, 137-150)
│   └─0 SentenceNode[3] (9:4-9:17, 137-150)
│       ├─0 WordNode[1] (9:4-9:9, 137-142)
│       │   └─0 TextNode "Early" (9:4-9:9, 137-142)
│       ├─1 WhiteSpaceNode " " (9:9-9:10, 142-143)
│       └─2 WordNode[1] (9:10-9:17, 143-150)
│           └─0 TextNode "History" (9:10-9:17, 143-150)
├─5 WhiteSpaceNode "\n\n" (9:17-11:1, 150-152)
├─6 ParagraphNode[3] (11:1-11:335, 152-486)
│   ├─0 SentenceNode[46] (11:1-11:151, 152-302)
│   │   ├─0 WordNode[1] (11:1-11:4, 152-155)
│   │   │   └─0 TextNode "Ken" (11:1-11:4, 152-155)
│   │   ├─1 WhiteSpaceNode " " (11:4-11:5, 155-156)
│   │   ├─2 WordNode[1] (11:5-11:13, 156-164)
│   │   │   └─0 TextNode "Thompson" (11:5-11:13, 156-164)
│   │   ├─3 WhiteSpaceNode " " (11:13-11:14, 164-165)
│   │   ├─4 WordNode[1] (11:14-11:17, 165-168)
│   │   │   └─0 TextNode "and" (11:14-11:17, 165-168)
│   │   ├─5 WhiteSpaceNode " " (11:17-11:18, 168-169)
│   │   ├─6 WordNode[1] (11:18-11:24, 169-175)
│   │   │   └─0 TextNode "Dennis" (11:18-11:24, 169-175)
│   │   ├─7 WhiteSpaceNode " " (11:24-11:25, 175-176)
│   │   ├─8 WordNode[1] (11:25-11:32, 176-183)
│   │   │   └─0 TextNode "Ritchie" (11:25-11:32, 176-183)
│   │   ├─9 WhiteSpaceNode " " (11:32-11:33, 183-184)
│   │   ├─10 WordNode[1] (11:33-11:42, 184-193)
│   │   │   └─0 TextNode "presented" (11:33-11:42, 184-193)
│   │   ├─11 WhiteSpaceNode " " (11:42-11:43, 193-194)
│   │   ├─12 WordNode[1] (11:43-11:46, 194-197)
│   │   │   └─0 TextNode "the" (11:43-11:46, 194-197)
│   │   ├─13 WhiteSpaceNode " " (11:46-11:47, 197-198)
│   │   ├─14 WordNode[1] (11:47-11:52, 198-203)
│   │   │   └─0 TextNode "first" (11:47-11:52, 198-203)
│   │   ├─15 WhiteSpaceNode " " (11:52-11:53, 203-204)
│   │   ├─16 WordNode[1] (11:53-11:57, 204-208)
│   │   │   └─0 TextNode "Unix" (11:53-11:57, 204-208)
│   │   ├─17 WhiteSpaceNode " " (11:57-11:58, 208-209)
│   │   ├─18 WordNode[1] (11:58-11:63, 209-214)
│   │   │   └─0 TextNode "paper" (11:58-11:63, 209-214)
│   │   ├─19 WhiteSpaceNode " " (11:63-11:64, 214-215)
│   │   ├─20 WordNode[1] (11:64-11:66, 215-217)
│   │   │   └─0 TextNode "at" (11:64-11:66, 215-217)
│   │   ├─21 WhiteSpaceNode " " (11:66-11:67, 217-218)
│   │   ├─22 WordNode[1] (11:67-11:70, 218-221)
│   │   │   └─0 TextNode "the" (11:67-11:70, 218-221)
│   │   ├─23 WhiteSpaceNode " " (11:70-11:71, 221-222)
│   │   ├─24 WordNode[1] (11:71-11:80, 222-231)
│   │   │   └─0 TextNode "Symposium" (11:71-11:80, 222-231)
│   │   ├─25 WhiteSpaceNode " " (11:80-11:81, 231-232)
│   │   ├─26 WordNode[1] (11:81-11:83, 232-234)
│   │   │   └─0 TextNode "on" (11:81-11:83, 232-234)
│   │   ├─27 WhiteSpaceNode " " (11:83-11:84, 234-235)
│   │   ├─28 WordNode[1] (11:84-11:93, 235-244)
│   │   │   └─0 TextNode "Operating" (11:84-11:93, 235-244)
│   │   ├─29 WhiteSpaceNode " " (11:93-11:94, 244-245)
│   │   ├─30 WordNode[1] (11:94-11:101, 245-252)
│   │   │   └─0 TextNode "Systems" (11:94-11:101, 245-252)
│   │   ├─31 WhiteSpaceNode " " (11:101-11:102, 252-253)
│   │   ├─32 WordNode[1] (11:102-11:112, 253-263)
│   │   │   └─0 TextNode "Principles" (11:102-11:112, 253-263)
│   │   ├─33 WhiteSpaceNode " " (11:112-11:113, 263-264)
│   │   ├─34 WordNode[1] (11:113-11:115, 264-266)
│   │   │   └─0 TextNode "at" (11:113-11:115, 264-266)
│   │   ├─35 WhiteSpaceNode " " (11:115-11:116, 266-267)
│   │   ├─36 WordNode[1] (11:116-11:122, 267-273)
│   │   │   └─0 TextNode "Purdue" (11:116-11:122, 267-273)
│   │   ├─37 WhiteSpaceNode " " (11:122-11:123, 273-274)
│   │   ├─38 WordNode[1] (11:123-11:133, 274-284)
│   │   │   └─0 TextNode "University" (11:123-11:133, 274-284)
│   │   ├─39 WhiteSpaceNode " " (11:133-11:134, 284-285)
│   │   ├─40 WordNode[1] (11:134-11:136, 285-287)
│   │   │   └─0 TextNode "in" (11:134-11:136, 285-287)
│   │   ├─41 WhiteSpaceNode " " (11:136-11:137, 287-288)
│   │   ├─42 WordNode[1] (11:137-11:145, 288-296)
│   │   │   └─0 TextNode "November" (11:137-11:145, 288-296)
│   │   ├─43 WhiteSpaceNode " " (11:145-11:146, 296-297)
│   │   ├─44 WordNode[1] (11:146-11:150, 297-301)
│   │   │   └─0 TextNode "1973" (11:146-11:150, 297-301)
│   │   └─45 PunctuationNode "." (11:150-11:151, 301-302)
│   ├─1 WhiteSpaceNode " " (11:151-11:152, 302-303)
│   └─2 SentenceNode[60] (11:152-11:335, 303-486)
│       ├─0 WordNode[1] (11:152-11:161, 303-312)
│       │   └─0 TextNode "Professor" (11:152-11:161, 303-312)
│       ├─1 WhiteSpaceNode " " (11:161-11:162, 312-313)
│       ├─2 WordNode[1] (11:162-11:165, 313-316)
│       │   └─0 TextNode "Bob" (11:162-11:165, 313-316)
│       ├─3 WhiteSpaceNode " " (11:165-11:166, 316-317)
│       ├─4 WordNode[1] (11:166-11:171, 317-322)
│       │   └─0 TextNode "Fabry" (11:166-11:171, 317-322)
│       ├─5 PunctuationNode "," (11:171-11:172, 322-323)
│       ├─6 WhiteSpaceNode " " (11:172-11:173, 323-324)
│       ├─7 WordNode[1] (11:173-11:175, 324-326)
│       │   └─0 TextNode "of" (11:173-11:175, 324-326)
│       ├─8 WhiteSpaceNode " " (11:175-11:176, 326-327)
│       ├─9 WordNode[1] (11:176-11:179, 327-330)
│       │   └─0 TextNode "the" (11:176-11:179, 327-330)
│       ├─10 WhiteSpaceNode " " (11:179-11:180, 330-331)
│       ├─11 WordNode[1] (11:180-11:190, 331-341)
│       │   └─0 TextNode "University" (11:180-11:190, 331-341)
│       ├─12 WhiteSpaceNode " " (11:190-11:191, 341-342)
│       ├─13 WordNode[1] (11:191-11:193, 342-344)
│       │   └─0 TextNode "of" (11:191-11:193, 342-344)
│       ├─14 WhiteSpaceNode " " (11:193-11:194, 344-345)
│       ├─15 WordNode[1] (11:194-11:204, 345-355)
│       │   └─0 TextNode "California" (11:194-11:204, 345-355)
│       ├─16 WhiteSpaceNode " " (11:204-11:205, 355-356)
│       ├─17 WordNode[1] (11:205-11:207, 356-358)
│       │   └─0 TextNode "at" (11:205-11:207, 356-358)
│       ├─18 WhiteSpaceNode " " (11:207-11:208, 358-359)
│       ├─19 WordNode[1] (11:208-11:216, 359-367)
│       │   └─0 TextNode "Berkeley" (11:208-11:216, 359-367)
│       ├─20 PunctuationNode "," (11:216-11:217, 367-368)
│       ├─21 WhiteSpaceNode " " (11:217-11:218, 368-369)
│       ├─22 WordNode[1] (11:218-11:221, 369-372)
│       │   └─0 TextNode "was" (11:218-11:221, 369-372)
│       ├─23 WhiteSpaceNode " " (11:221-11:222, 372-373)
│       ├─24 WordNode[1] (11:222-11:224, 373-375)
│       │   └─0 TextNode "in" (11:222-11:224, 373-375)
│       ├─25 WhiteSpaceNode " " (11:224-11:225, 375-376)
│       ├─26 WordNode[1] (11:225-11:235, 376-386)
│       │   └─0 TextNode "attendance" (11:225-11:235, 376-386)
│       ├─27 WhiteSpaceNode " " (11:235-11:236, 386-387)
│       ├─28 WordNode[1] (11:236-11:239, 387-390)
│       │   └─0 TextNode "and" (11:236-11:239, 387-390)
│       ├─29 WhiteSpaceNode " " (11:239-11:240, 390-391)
│       ├─30 WordNode[1] (11:240-11:251, 391-402)
│       │   └─0 TextNode "immediately" (11:240-11:251, 391-402)
│       ├─31 WhiteSpaceNode " " (11:251-11:252, 402-403)
│       ├─32 WordNode[1] (11:252-11:258, 403-409)
│       │   └─0 TextNode "became" (11:252-11:258, 403-409)
│       ├─33 WhiteSpaceNode " " (11:258-11:259, 409-410)
│       ├─34 WordNode[1] (11:259-11:269, 410-420)
│       │   └─0 TextNode "interested" (11:259-11:269, 410-420)
│       ├─35 WhiteSpaceNode " " (11:269-11:270, 420-421)
│       ├─36 WordNode[1] (11:270-11:272, 421-423)
│       │   └─0 TextNode "in" (11:270-11:272, 421-423)
│       ├─37 WhiteSpaceNode " " (11:272-11:273, 423-424)
│       ├─38 WordNode[1] (11:273-11:282, 424-433)
│       │   └─0 TextNode "obtaining" (11:273-11:282, 424-433)
│       ├─39 WhiteSpaceNode " " (11:282-11:283, 433-434)
│       ├─40 WordNode[1] (11:283-11:284, 434-435)
│       │   └─0 TextNode "a" (11:283-11:284, 434-435)
│       ├─41 WhiteSpaceNode " " (11:284-11:285, 435-436)
│       ├─42 WordNode[1] (11:285-11:289, 436-440)
│       │   └─0 TextNode "copy" (11:285-11:289, 436-440)
│       ├─43 WhiteSpaceNode " " (11:289-11:290, 440-441)
│       ├─44 WordNode[1] (11:290-11:292, 441-443)
│       │   └─0 TextNode "of" (11:290-11:292, 441-443)
│       ├─45 WhiteSpaceNode " " (11:292-11:293, 443-444)
│       ├─46 WordNode[1] (11:293-11:296, 444-447)
│       │   └─0 TextNode "the" (11:293-11:296, 444-447)
│       ├─47 WhiteSpaceNode " " (11:296-11:297, 447-448)
│       ├─48 WordNode[1] (11:297-11:303, 448-454)
│       │   └─0 TextNode "system" (11:297-11:303, 448-454)
│       ├─49 WhiteSpaceNode " " (11:303-11:304, 454-455)
│       ├─50 WordNode[1] (11:304-11:306, 455-457)
│       │   └─0 TextNode "to" (11:304-11:306, 455-457)
│       ├─51 WhiteSpaceNode " " (11:306-11:307, 457-458)
│       ├─52 WordNode[1] (11:307-11:317, 458-468)
│       │   └─0 TextNode "experiment" (11:307-11:317, 458-468)
│       ├─53 WhiteSpaceNode " " (11:317-11:318, 468-469)
│       ├─54 WordNode[1] (11:318-11:322, 469-473)
│       │   └─0 TextNode "with" (11:318-11:322, 469-473)
│       ├─55 WhiteSpaceNode " " (11:322-11:323, 473-474)
│       ├─56 WordNode[1] (11:323-11:325, 474-476)
│       │   └─0 TextNode "at" (11:323-11:325, 474-476)
│       ├─57 WhiteSpaceNode " " (11:325-11:326, 476-477)
│       ├─58 WordNode[1] (11:326-11:334, 477-485)
│       │   └─0 TextNode "Berkeley" (11:326-11:334, 477-485)
│       └─59 PunctuationNode "." (11:334-11:335, 485-486)
├─7 WhiteSpaceNode "\n\n" (11:335-13:1, 486-488)
└─8 ParagraphNode[3] (13:1-13:473, 488-960)
    ├─0 SentenceNode[72] (13:1-13:204, 488-691)
    │   ├─0 WordNode[1] (13:1-13:3, 488-490)
    │   │   └─0 TextNode "At" (13:1-13:3, 488-490)
    │   ├─1 WhiteSpaceNode " " (13:3-13:4, 490-491)
    │   ├─2 WordNode[1] (13:4-13:7, 491-494)
    │   │   └─0 TextNode "the" (13:4-13:7, 491-494)
    │   ├─3 WhiteSpaceNode " " (13:7-13:8, 494-495)
    │   ├─4 WordNode[1] (13:8-13:12, 495-499)
    │   │   └─0 TextNode "time" (13:8-13:12, 495-499)
    │   ├─5 PunctuationNode "," (13:12-13:13, 499-500)
    │   ├─6 WhiteSpaceNode " " (13:13-13:14, 500-501)
    │   ├─7 WordNode[1] (13:14-13:22, 501-509)
    │   │   └─0 TextNode "Berkeley" (13:14-13:22, 501-509)
    │   ├─8 WhiteSpaceNode " " (13:22-13:23, 509-510)
    │   ├─9 WordNode[1] (13:23-13:26, 510-513)
    │   │   └─0 TextNode "had" (13:23-13:26, 510-513)
    │   ├─10 WhiteSpaceNode " " (13:26-13:27, 513-514)
    │   ├─11 WordNode[1] (13:27-13:31, 514-518)
    │   │   └─0 TextNode "only" (13:27-13:31, 514-518)
    │   ├─12 WhiteSpaceNode " " (13:31-13:32, 518-519)
    │   ├─13 WordNode[1] (13:32-13:37, 519-524)
    │   │   └─0 TextNode "large" (13:32-13:37, 519-524)
    │   ├─14 WhiteSpaceNode " " (13:37-13:38, 524-525)
    │   ├─15 WordNode[1] (13:38-13:47, 525-534)
    │   │   └─0 TextNode "mainframe" (13:38-13:47, 525-534)
    │   ├─16 WhiteSpaceNode " " (13:47-13:48, 534-535)
    │   ├─17 WordNode[1] (13:48-13:56, 535-543)
    │   │   └─0 TextNode "computer" (13:48-13:56, 535-543)
    │   ├─18 WhiteSpaceNode " " (13:56-13:57, 543-544)
    │   ├─19 WordNode[1] (13:57-13:64, 544-551)
    │   │   └─0 TextNode "systems" (13:57-13:64, 544-551)
    │   ├─20 WhiteSpaceNode " " (13:64-13:65, 551-552)
    │   ├─21 WordNode[1] (13:65-13:70, 552-557)
    │   │   └─0 TextNode "doing" (13:65-13:70, 552-557)
    │   ├─22 WhiteSpaceNode " " (13:70-13:71, 557-558)
    │   ├─23 WordNode[1] (13:71-13:76, 558-563)
    │   │   └─0 TextNode "batch" (13:71-13:76, 558-563)
    │   ├─24 WhiteSpaceNode " " (13:76-13:77, 563-564)
    │   ├─25 WordNode[1] (13:77-13:87, 564-574)
    │   │   └─0 TextNode "processing" (13:77-13:87, 564-574)
    │   ├─26 PunctuationNode "," (13:87-13:88, 574-575)
    │   ├─27 WhiteSpaceNode " " (13:88-13:89, 575-576)
    │   ├─28 WordNode[1] (13:89-13:91, 576-578)
    │   │   └─0 TextNode "so" (13:89-13:91, 576-578)
    │   ├─29 WhiteSpaceNode " " (13:91-13:92, 578-579)
    │   ├─30 WordNode[1] (13:92-13:95, 579-582)
    │   │   └─0 TextNode "the" (13:92-13:95, 579-582)
    │   ├─31 WhiteSpaceNode " " (13:95-13:96, 582-583)
    │   ├─32 WordNode[1] (13:96-13:101, 583-588)
    │   │   └─0 TextNode "first" (13:96-13:101, 583-588)
    │   ├─33 WhiteSpaceNode " " (13:101-13:102, 588-589)
    │   ├─34 WordNode[1] (13:102-13:107, 589-594)
    │   │   └─0 TextNode "order" (13:102-13:107, 589-594)
    │   ├─35 WhiteSpaceNode " " (13:107-13:108, 594-595)
    │   ├─36 WordNode[1] (13:108-13:110, 595-597)
    │   │   └─0 TextNode "of" (13:108-13:110, 595-597)
    │   ├─37 WhiteSpaceNode " " (13:110-13:111, 597-598)
    │   ├─38 WordNode[1] (13:111-13:119, 598-606)
    │   │   └─0 TextNode "business" (13:111-13:119, 598-606)
    │   ├─39 WhiteSpaceNode " " (13:119-13:120, 606-607)
    │   ├─40 WordNode[1] (13:120-13:123, 607-610)
    │   │   └─0 TextNode "was" (13:120-13:123, 607-610)
    │   ├─41 WhiteSpaceNode " " (13:123-13:124, 610-611)
    │   ├─42 WordNode[1] (13:124-13:126, 611-613)
    │   │   └─0 TextNode "to" (13:124-13:126, 611-613)
    │   ├─43 WhiteSpaceNode " " (13:126-13:127, 613-614)
    │   ├─44 WordNode[1] (13:127-13:130, 614-617)
    │   │   └─0 TextNode "get" (13:127-13:130, 614-617)
    │   ├─45 WhiteSpaceNode " " (13:130-13:131, 617-618)
    │   ├─46 WordNode[1] (13:131-13:132, 618-619)
    │   │   └─0 TextNode "a" (13:131-13:132, 618-619)
    │   ├─47 WhiteSpaceNode " " (13:132-13:133, 619-620)
    │   ├─48 WordNode[3] (13:133-13:139, 620-626)
    │   │   ├─0 TextNode "PDP" (13:133-13:136, 620-623)
    │   │   ├─1 PunctuationNode "-" (13:136-13:137, 623-624)
    │   │   └─2 TextNode "11" (13:137-13:139, 624-626)
    │   ├─49 PunctuationNode "/" (13:139-13:140, 626-627)
    │   ├─50 WordNode[1] (13:140-13:142, 627-629)
    │   │   └─0 TextNode "45" (13:140-13:142, 627-629)
    │   ├─51 WhiteSpaceNode " " (13:142-13:143, 629-630)
    │   ├─52 WordNode[1] (13:143-13:151, 630-638)
    │   │   └─0 TextNode "suitable" (13:143-13:151, 630-638)
    │   ├─53 WhiteSpaceNode " " (13:151-13:152, 638-639)
    │   ├─54 WordNode[1] (13:152-13:155, 639-642)
    │   │   └─0 TextNode "for" (13:152-13:155, 639-642)
    │   ├─55 WhiteSpaceNode " " (13:155-13:156, 642-643)
    │   ├─56 WordNode[1] (13:156-13:163, 643-650)
    │   │   └─0 TextNode "running" (13:156-13:163, 643-650)
    │   ├─57 WhiteSpaceNode " " (13:163-13:164, 650-651)
    │   ├─58 WordNode[1] (13:164-13:168, 651-655)
    │   │   └─0 TextNode "with" (13:164-13:168, 651-655)
    │   ├─59 WhiteSpaceNode " " (13:168-13:169, 655-656)
    │   ├─60 WordNode[1] (13:169-13:172, 656-659)
    │   │   └─0 TextNode "the" (13:169-13:172, 656-659)
    │   ├─61 WhiteSpaceNode " " (13:172-13:173, 659-660)
    │   ├─62 WordNode[3] (13:173-13:185, 660-672)
    │   │   ├─0 TextNode "then" (13:173-13:177, 660-664)
    │   │   ├─1 PunctuationNode "-" (13:177-13:178, 664-665)
    │   │   └─2 TextNode "current" (13:178-13:185, 665-672)
    │   ├─63 WhiteSpaceNode " " (13:185-13:186, 672-673)
    │   ├─64 WordNode[1] (13:186-13:193, 673-680)
    │   │   └─0 TextNode "Version" (13:186-13:193, 673-680)
    │   ├─65 WhiteSpaceNode " " (13:193-13:194, 680-681)
    │   ├─66 WordNode[1] (13:194-13:195, 681-682)
    │   │   └─0 TextNode "4" (13:194-13:195, 681-682)
    │   ├─67 WhiteSpaceNode " " (13:195-13:196, 682-683)
    │   ├─68 WordNode[1] (13:196-13:198, 683-685)
    │   │   └─0 TextNode "of" (13:196-13:198, 683-685)
    │   ├─69 WhiteSpaceNode " " (13:198-13:199, 685-686)
    │   ├─70 WordNode[1] (13:199-13:203, 686-690)
    │   │   └─0 TextNode "Unix" (13:199-13:203, 686-690)
    │   └─71 PunctuationNode "." (13:203-13:204, 690-691)
    ├─1 WhiteSpaceNode " " (13:204-13:205, 691-692)
    └─2 SentenceNode[85] (13:205-13:473, 692-960)
        ├─0 WordNode[1] (13:205-13:208, 692-695)
        │   └─0 TextNode "The" (13:205-13:208, 692-695)
        ├─1 WhiteSpaceNode " " (13:208-13:209, 695-696)
        ├─2 WordNode[1] (13:209-13:217, 696-704)
        │   └─0 TextNode "Computer" (13:209-13:217, 696-704)
        ├─3 WhiteSpaceNode " " (13:217-13:218, 704-705)
        ├─4 WordNode[1] (13:218-13:225, 705-712)
        │   └─0 TextNode "Science" (13:218-13:225, 705-712)
        ├─5 WhiteSpaceNode " " (13:225-13:226, 712-713)
        ├─6 WordNode[1] (13:226-13:236, 713-723)
        │   └─0 TextNode "Department" (13:226-13:236, 713-723)
        ├─7 WhiteSpaceNode " " (13:236-13:237, 723-724)
        ├─8 WordNode[1] (13:237-13:239, 724-726)
        │   └─0 TextNode "at" (13:237-13:239, 724-726)
        ├─9 WhiteSpaceNode " " (13:239-13:240, 726-727)
        ├─10 WordNode[1] (13:240-13:248, 727-735)
        │   └─0 TextNode "Berkeley" (13:240-13:248, 727-735)
        ├─11 PunctuationNode "," (13:248-13:249, 735-736)
        ├─12 WhiteSpaceNode " " (13:249-13:250, 736-737)
        ├─13 WordNode[1] (13:250-13:258, 737-745)
        │   └─0 TextNode "together" (13:250-13:258, 737-745)
        ├─14 WhiteSpaceNode " " (13:258-13:259, 745-746)
        ├─15 WordNode[1] (13:259-13:263, 746-750)
        │   └─0 TextNode "with" (13:259-13:263, 746-750)
        ├─16 WhiteSpaceNode " " (13:263-13:264, 750-751)
        ├─17 WordNode[1] (13:264-13:267, 751-754)
        │   └─0 TextNode "the" (13:264-13:267, 751-754)
        ├─18 WhiteSpaceNode " " (13:267-13:268, 754-755)
        ├─19 WordNode[1] (13:268-13:279, 755-766)
        │   └─0 TextNode "Mathematics" (13:268-13:279, 755-766)
        ├─20 WhiteSpaceNode " " (13:279-13:280, 766-767)
        ├─21 WordNode[1] (13:280-13:290, 767-777)
        │   └─0 TextNode "Department" (13:280-13:290, 767-777)
        ├─22 WhiteSpaceNode " " (13:290-13:291, 777-778)
        ├─23 WordNode[1] (13:291-13:294, 778-781)
        │   └─0 TextNode "and" (13:291-13:294, 778-781)
        ├─24 WhiteSpaceNode " " (13:294-13:295, 781-782)
        ├─25 WordNode[1] (13:295-13:298, 782-785)
        │   └─0 TextNode "the" (13:295-13:298, 782-785)
        ├─26 WhiteSpaceNode " " (13:298-13:299, 785-786)
        ├─27 WordNode[1] (13:299-13:309, 786-796)
        │   └─0 TextNode "Statistics" (13:299-13:309, 786-796)
        ├─28 WhiteSpaceNode " " (13:309-13:310, 796-797)
        ├─29 WordNode[1] (13:310-13:320, 797-807)
        │   └─0 TextNode "Department" (13:310-13:320, 797-807)
        ├─30 PunctuationNode "," (13:320-13:321, 807-808)
        ├─31 WhiteSpaceNode " " (13:321-13:322, 808-809)
        ├─32 WordNode[1] (13:322-13:326, 809-813)
        │   └─0 TextNode "were" (13:322-13:326, 809-813)
        ├─33 WhiteSpaceNode " " (13:326-13:327, 813-814)
        ├─34 WordNode[1] (13:327-13:331, 814-818)
        │   └─0 TextNode "able" (13:327-13:331, 814-818)
        ├─35 WhiteSpaceNode " " (13:331-13:332, 818-819)
        ├─36 WordNode[1] (13:332-13:334, 819-821)
        │   └─0 TextNode "to" (13:332-13:334, 819-821)
        ├─37 WhiteSpaceNode " " (13:334-13:335, 821-822)
        ├─38 WordNode[1] (13:335-13:342, 822-829)
        │   └─0 TextNode "jointly" (13:335-13:342, 822-829)
        ├─39 WhiteSpaceNode " " (13:342-13:343, 829-830)
        ├─40 WordNode[1] (13:343-13:351, 830-838)
        │   └─0 TextNode "purchase" (13:343-13:351, 830-838)
        ├─41 WhiteSpaceNode " " (13:351-13:352, 838-839)
        ├─42 WordNode[1] (13:352-13:353, 839-840)
        │   └─0 TextNode "a" (13:352-13:353, 839-840)
        ├─43 WhiteSpaceNode " " (13:353-13:354, 840-841)
        ├─44 WordNode[3] (13:354-13:360, 841-847)
        │   ├─0 TextNode "PDP" (13:354-13:357, 841-844)
        │   ├─1 PunctuationNode "-" (13:357-13:358, 844-845)
        │   └─2 TextNode "11" (13:358-13:360, 845-847)
        ├─45 PunctuationNode "/" (13:360-13:361, 847-848)
        ├─46 WordNode[2] (13:361-13:364, 848-851)
        │   ├─0 TextNode "45" (13:361-13:363, 848-850)
        │   └─1 PunctuationNode "." (13:363-13:364, 850-851)
        ├─47 WhiteSpaceNode " " (13:364-13:365, 851-852)
        ├─48 WordNode[1] (13:365-13:367, 852-854)
        │   └─0 TextNode "In" (13:365-13:367, 852-854)
        ├─49 WhiteSpaceNode " " (13:367-13:368, 854-855)
        ├─50 WordNode[1] (13:368-13:375, 855-862)
        │   └─0 TextNode "January" (13:368-13:375, 855-862)
        ├─51 WhiteSpaceNode " " (13:375-13:376, 862-863)
        ├─52 WordNode[1] (13:376-13:380, 863-867)
        │   └─0 TextNode "1974" (13:376-13:380, 863-867)
        ├─53 PunctuationNode "," (13:380-13:381, 867-868)
        ├─54 WhiteSpaceNode " " (13:381-13:382, 868-869)
        ├─55 WordNode[1] (13:382-13:383, 869-870)
        │   └─0 TextNode "a" (13:382-13:383, 869-870)
        ├─56 WhiteSpaceNode " " (13:383-13:384, 870-871)
        ├─57 WordNode[1] (13:384-13:391, 871-878)
        │   └─0 TextNode "Version" (13:384-13:391, 871-878)
        ├─58 WhiteSpaceNode " " (13:391-13:392, 878-879)
        ├─59 WordNode[1] (13:392-13:393, 879-880)
        │   └─0 TextNode "4" (13:392-13:393, 879-880)
        ├─60 WhiteSpaceNode " " (13:393-13:394, 880-881)
        ├─61 WordNode[1] (13:394-13:398, 881-885)
        │   └─0 TextNode "tape" (13:394-13:398, 881-885)
        ├─62 WhiteSpaceNode " " (13:398-13:399, 885-886)
        ├─63 WordNode[1] (13:399-13:402, 886-889)
        │   └─0 TextNode "was" (13:399-13:402, 886-889)
        ├─64 WhiteSpaceNode " " (13:402-13:403, 889-890)
        ├─65 WordNode[1] (13:403-13:412, 890-899)
        │   └─0 TextNode "delivered" (13:403-13:412, 890-899)
        ├─66 WhiteSpaceNode " " (13:412-13:413, 899-900)
        ├─67 WordNode[1] (13:413-13:416, 900-903)
        │   └─0 TextNode "and" (13:413-13:416, 900-903)
        ├─68 WhiteSpaceNode " " (13:416-13:417, 903-904)
        ├─69 WordNode[1] (13:417-13:421, 904-908)
        │   └─0 TextNode "Unix" (13:417-13:421, 904-908)
        ├─70 WhiteSpaceNode " " (13:421-13:422, 908-909)
        ├─71 WordNode[1] (13:422-13:425, 909-912)
        │   └─0 TextNode "was" (13:422-13:425, 909-912)
        ├─72 WhiteSpaceNode " " (13:425-13:426, 912-913)
        ├─73 WordNode[1] (13:426-13:435, 913-922)
        │   └─0 TextNode "installed" (13:426-13:435, 913-922)
        ├─74 WhiteSpaceNode " " (13:435-13:436, 922-923)
        ├─75 WordNode[1] (13:436-13:438, 923-925)
        │   └─0 TextNode "by" (13:436-13:438, 923-925)
        ├─76 WhiteSpaceNode " " (13:438-13:439, 925-926)
        ├─77 WordNode[1] (13:439-13:447, 926-934)
        │   └─0 TextNode "graduate" (13:439-13:447, 926-934)
        ├─78 WhiteSpaceNode " " (13:447-13:448, 934-935)
        ├─79 WordNode[1] (13:448-13:455, 935-942)
        │   └─0 TextNode "student" (13:448-13:455, 935-942)
        ├─80 WhiteSpaceNode " " (13:455-13:456, 942-943)
        ├─81 WordNode[1] (13:456-13:461, 943-948)
        │   └─0 TextNode "Keith" (13:456-13:461, 943-948)
        ├─82 WhiteSpaceNode " " (13:461-13:462, 948-949)
        ├─83 WordNode[1] (13:462-13:472, 949-959)
        │   └─0 TextNode "Standiford" (13:462-13:472, 949-959)
        └─84 PunctuationNode "." (13:472-13:473, 959-960)
$

*1:お気づきの方もいらっしゃるでしょうが、 このテキスト・データはオライリーの "Open Sources: Voices from the Open Source Revolution" の第3章 "Twenty Years of Berkeley Unix: From AT&T-Owned to Freely Redistributable" の冒頭部分です(笑)

Remark(3)remark-retext: プラグイン・チェインの接続点

remark-retext: connection points for plugin chains


2020/08/30
藤田昭人


前回、 ファイルから Markdown データを読み込み(remark-parse)、 Text データへと変換したのち(remark-retext)、 文字列化する(retext-stringify)プログラムを紹介しました。 各々が生成する構文木mdastnlcst)を表示するプラグインを作って データ構造を確認してみましたが、 Markdown データの構文木はパラグラフ単位であるのに対し、 Text データの構文木トークン単位で管理していることがわかりました。 本稿では mdastからnlcst、 すなわち Markdown から Text への変換を行っている remark-retext について深堀りしてみます。

というのも、この remark-retext のどこかに、 パラグラフ・トークン変換を行うトークナイザーが組み込まれているはずだからです。 ご存知のとおり、単語間に空白が存在する英文のトークナイザーは単純に実装できますが、 日本文の場合には形態素解析が必要となります。 形態素解析機そのものは MeCab などのオープンソース・ソフトウェアが流通してますが、 それを Remark に組み込んで日本文を含むドキュメントでも Remark を齟齬なく動作させるには、 Remark でのパラグラフ -- トークン変換の仕組みを理解しておく必要があります。

つまり、本稿は Remark 日本語化の第1段階ということになります。


unist-util-inspect:構文木をコンパクトに

本論に入るまでに Tips をひとつ。
構文木デバッグ・プリントは非常に冗長な表示になってしましますが、 unist-util-inspectを使うと多少マシになります。 例えばこんな感じ…

var inspect = require('unist-util-inspect');

function attacher0() {
  return transformer;
  function transformer(tree, file) {
    console.log("\n### remark");
    console.log(inspect(tree));
  }
}

function attacher1() {
  return transformer;
  function transformer(tree, file) {
    console.log("\n### retext");
    console.log(inspect(tree));
  }
}

function attacher2() {
  return transformer;
  function transformer(tree, file) {
    console.log("\n### after retext-pos");
    console.log(inspect(tree));
  }
}

var unified = require('unified');
var parse = require('remark-parse');
var remark2retext = require('remark-retext');
var english = require('parse-english');
var stringify = require('retext-stringify');
var vfile = require('to-vfile');
var report = require('vfile-reporter');
var pos = require('retext-pos');

var processor = unified()
    .use(parse)
    .use(attacher0)
    .use(remark2retext, english)
    .use(attacher1)
    .use(pos)
    .use(attacher2)
    .use(stringify);

processor.process(vfile.readSync('english.md'), done);

function done(err, file) {
  //console.error(report(err || file));
  //console.log(String(file));
}

このソースを動かすには必要なパッケージをインストールしてもらって…

$ npm install unist-util-inspect unified remark-parse remark-retext parse-english retext-stringify to-vfile vfile-reporter retext-pos

 ・・・

$ node example.js

…このソースの実行例は長いので 末尾 に掲載します。

ソース自体は 前回 紹介したものと基本的には同じですが、プラグイン・チェインに 品詞タグを付与するretext-posを追加しています。詳細は 末尾 の実行例を見てもらうとわかると思います。


remark-retext:Markdown から Text への変換

それでは本題のremark-retextを紹介しましょう。

このプラグインが用意しているAPIは次に示すエントリーだけです。

origin.use(remark2retext, destination[, options])

remarkmdastプラグインbridgeするか、 あるいはretextnlcst)にmutate します。

destination

destination はパーサまたはプロセッサのいずれかです。

Unified プロセッサが与えられた場合、 新しい nlcst ツリーで destination プロセッサを実行し、 実行後にそのツリーを破棄して元のツリーで元のプロセッサを実行し続けます (bridge モード)。

パーサー (parse-latinparse-englishparse-dutch など)が指定されている場合、 構文木を他のプラグインに渡します (mutate モード)。

この「destination はパーサーまたはプロセッサのいずれかです」が分かりにくいのですが…

つまり remark-retext は、単純な構文木の変換を行うmutate モードと、 プロセッサーを新たに生成しプラグイン・チェインを分岐させる bridge モードの 2つのモードをサポートしているということのようです。

実は上記の3種類のパーサー parse-latinparse-englishparse-dutch には各々、 プラグイン形式(すなわち use を使ってプラグイン・チェインに登録できる形式) retext-latinretext-englishretext-dutch も用意されています(前回を参照)。

mutate モードで使用する場合には第2引数の destination にパーサーそのものを渡して構文木の変換のみを行いますが、 bridge モードで使用する場合には新たなプロセッサーを生成して 第2引数の destination に渡し新たなプラグイン・チェインを生成します。 その場合は processor.useプラグイン形式のパーサーを設定します。

例えば、前述のソースはmutate モードを使ってますが、 これをbridge モードに書き直すと…

var unified = require('unified')
var markdown = require('remark-parse')
var remark2retext = require('remark-retext')
var english = require('retext-english')
var stringify = require('retext-stringify');

var inspect = require('unist-util-inspect');

function attacher() {
  return transformer;
  function transformer(tree, file) {
    console.log(inspect(tree));
  }
}

var processor = unified()
  .use(markdown)
  .use(remark2retext, 
    unified()
    .use(english)
    .use(attacher))
  .use(stringify);

processor.process(vfile.readSync('example.md'), function (error, file) {
});

…といった感じになります。

ソースコード

幾つかテスト・プログラムを書いて上記の仕様を確認したのですが、 結局プラグインの間で引き渡される構文木の扱い方がよくわからなかったので remark-retextソースコードをあたってみました。 このプラグインの実装は非常にコンパクトなので、 以下に全文を引用します。

'use strict'

var mdast2nlcst = require('mdast-util-to-nlcst')

module.exports = remark2retext

// Attacher.
// If a destination processor is given, runs the destination 
// with the new nlcst tree (bridge mode).
// If a parser is given, returns the nlcst tree: 
// further plugins run on that tree (mutate mode).
function remark2retext(destination, options) {
  var fn = destination && destination.run ? bridge : mutate
  return fn(destination, options)
}

// Mutate mode.
// Further transformers run on the nlcst tree.
function mutate(parser, options) {
  return transformer
  function transformer(node, file) {
    return mdast2nlcst(node, file, parser, options)
  }
}

// Bridge mode.
// Runs the destination with the new nlcst tree.
function bridge(destination, options) {
  return transformer
  function transformer(node, file, next) {
    var Parser = destination.freeze().Parser
    var tree = mdast2nlcst(node, file, Parser, options)
    destination.run(tree, file, done)
    function done(err) {
      next(err)
    }
  }
}

ソースコード から分かることは以下の3点です。


どうやら remark-retextmdast 構文木からnlcst 構文木への複製を行う mdast-util-to-nlcst を unified フレームワークに組み入れるインターフェースのようです。

次は、構文木の複製を実装している mdast-util-to-nlcst を追っかけてみます。

以上



$ node example.js

### remark
root[2] (1:1-4:1, 0-140)
├─0 heading[1] (1:1-1:24, 0-23)
│   │ depth: 1
│   └─0 text "in the United States" (1:3-1:23, 2-22)
└─1 paragraph[1] (3:1-3:115, 25-139)
    └─0 text "The project will cost between $5,000 and $30,000 dollars, depending on how fancy you want the final product to be." (3:1-3:115, 25-139)

### retext
RootNode[3] (1:3-3:115, 2-139)
├─0 ParagraphNode[1] (1:3-1:23, 2-22)
│   └─0 SentenceNode[7] (1:3-1:23, 2-22)
│       ├─0 WordNode[1] (1:3-1:5, 2-4)
│       │   └─0 TextNode "in" (1:3-1:5, 2-4)
│       ├─1 WhiteSpaceNode " " (1:5-1:6, 4-5)
│       ├─2 WordNode[1] (1:6-1:9, 5-8)
│       │   └─0 TextNode "the" (1:6-1:9, 5-8)
│       ├─3 WhiteSpaceNode " " (1:9-1:10, 8-9)
│       ├─4 WordNode[1] (1:10-1:16, 9-15)
│       │   └─0 TextNode "United" (1:10-1:16, 9-15)
│       ├─5 WhiteSpaceNode " " (1:16-1:17, 15-16)
│       └─6 WordNode[1] (1:17-1:23, 16-22)
│           └─0 TextNode "States" (1:17-1:23, 16-22)
├─1 WhiteSpaceNode "\n\n" (1:24-3:1, 23-25)
└─2 ParagraphNode[1] (3:1-3:115, 25-139)
    └─0 SentenceNode[47] (3:1-3:115, 25-139)
        ├─0 WordNode[1] (3:1-3:4, 25-28)
        │   └─0 TextNode "The" (3:1-3:4, 25-28)
        ├─1 WhiteSpaceNode " " (3:4-3:5, 28-29)
        ├─2 WordNode[1] (3:5-3:12, 29-36)
        │   └─0 TextNode "project" (3:5-3:12, 29-36)
        ├─3 WhiteSpaceNode " " (3:12-3:13, 36-37)
        ├─4 WordNode[1] (3:13-3:17, 37-41)
        │   └─0 TextNode "will" (3:13-3:17, 37-41)
        ├─5 WhiteSpaceNode " " (3:17-3:18, 41-42)
        ├─6 WordNode[1] (3:18-3:22, 42-46)
        │   └─0 TextNode "cost" (3:18-3:22, 42-46)
        ├─7 WhiteSpaceNode " " (3:22-3:23, 46-47)
        ├─8 WordNode[1] (3:23-3:30, 47-54)
        │   └─0 TextNode "between" (3:23-3:30, 47-54)
        ├─9 WhiteSpaceNode " " (3:30-3:31, 54-55)
        ├─10 SymbolNode "$" (3:31-3:32, 55-56)
        ├─11 WordNode[1] (3:32-3:33, 56-57)
        │   └─0 TextNode "5" (3:32-3:33, 56-57)
        ├─12 PunctuationNode "," (3:33-3:34, 57-58)
        ├─13 WordNode[1] (3:34-3:37, 58-61)
        │   └─0 TextNode "000" (3:34-3:37, 58-61)
        ├─14 WhiteSpaceNode " " (3:37-3:38, 61-62)
        ├─15 WordNode[1] (3:38-3:41, 62-65)
        │   └─0 TextNode "and" (3:38-3:41, 62-65)
        ├─16 WhiteSpaceNode " " (3:41-3:42, 65-66)
        ├─17 SymbolNode "$" (3:42-3:43, 66-67)
        ├─18 WordNode[1] (3:43-3:45, 67-69)
        │   └─0 TextNode "30" (3:43-3:45, 67-69)
        ├─19 PunctuationNode "," (3:45-3:46, 69-70)
        ├─20 WordNode[1] (3:46-3:49, 70-73)
        │   └─0 TextNode "000" (3:46-3:49, 70-73)
        ├─21 WhiteSpaceNode " " (3:49-3:50, 73-74)
        ├─22 WordNode[1] (3:50-3:57, 74-81)
        │   └─0 TextNode "dollars" (3:50-3:57, 74-81)
        ├─23 PunctuationNode "," (3:57-3:58, 81-82)
        ├─24 WhiteSpaceNode " " (3:58-3:59, 82-83)
        ├─25 WordNode[1] (3:59-3:68, 83-92)
        │   └─0 TextNode "depending" (3:59-3:68, 83-92)
        ├─26 WhiteSpaceNode " " (3:68-3:69, 92-93)
        ├─27 WordNode[1] (3:69-3:71, 93-95)
        │   └─0 TextNode "on" (3:69-3:71, 93-95)
        ├─28 WhiteSpaceNode " " (3:71-3:72, 95-96)
        ├─29 WordNode[1] (3:72-3:75, 96-99)
        │   └─0 TextNode "how" (3:72-3:75, 96-99)
        ├─30 WhiteSpaceNode " " (3:75-3:76, 99-100)
        ├─31 WordNode[1] (3:76-3:81, 100-105)
        │   └─0 TextNode "fancy" (3:76-3:81, 100-105)
        ├─32 WhiteSpaceNode " " (3:81-3:82, 105-106)
        ├─33 WordNode[1] (3:82-3:85, 106-109)
        │   └─0 TextNode "you" (3:82-3:85, 106-109)
        ├─34 WhiteSpaceNode " " (3:85-3:86, 109-110)
        ├─35 WordNode[1] (3:86-3:90, 110-114)
        │   └─0 TextNode "want" (3:86-3:90, 110-114)
        ├─36 WhiteSpaceNode " " (3:90-3:91, 114-115)
        ├─37 WordNode[1] (3:91-3:94, 115-118)
        │   └─0 TextNode "the" (3:91-3:94, 115-118)
        ├─38 WhiteSpaceNode " " (3:94-3:95, 118-119)
        ├─39 WordNode[1] (3:95-3:100, 119-124)
        │   └─0 TextNode "final" (3:95-3:100, 119-124)
        ├─40 WhiteSpaceNode " " (3:100-3:101, 124-125)
        ├─41 WordNode[1] (3:101-3:108, 125-132)
        │   └─0 TextNode "product" (3:101-3:108, 125-132)
        ├─42 WhiteSpaceNode " " (3:108-3:109, 132-133)
        ├─43 WordNode[1] (3:109-3:111, 133-135)
        │   └─0 TextNode "to" (3:109-3:111, 133-135)
        ├─44 WhiteSpaceNode " " (3:111-3:112, 135-136)
        ├─45 WordNode[1] (3:112-3:114, 136-138)
        │   └─0 TextNode "be" (3:112-3:114, 136-138)
        └─46 PunctuationNode "." (3:114-3:115, 138-139)

### after retext-pos
RootNode[3] (1:3-3:115, 2-139)
├─0 ParagraphNode[1] (1:3-1:23, 2-22)
│   └─0 SentenceNode[7] (1:3-1:23, 2-22)
│       ├─0 WordNode[1] (1:3-1:5, 2-4)
│       │   │ data: {"partOfSpeech":"IN"}
│       │   └─0 TextNode "in" (1:3-1:5, 2-4)
│       ├─1 WhiteSpaceNode " " (1:5-1:6, 4-5)
│       ├─2 WordNode[1] (1:6-1:9, 5-8)
│       │   │ data: {"partOfSpeech":"DT"}
│       │   └─0 TextNode "the" (1:6-1:9, 5-8)
│       ├─3 WhiteSpaceNode " " (1:9-1:10, 8-9)
│       ├─4 WordNode[1] (1:10-1:16, 9-15)
│       │   │ data: {"partOfSpeech":"VBN"}
│       │   └─0 TextNode "United" (1:10-1:16, 9-15)
│       ├─5 WhiteSpaceNode " " (1:16-1:17, 15-16)
│       └─6 WordNode[1] (1:17-1:23, 16-22)
│           │ data: {"partOfSpeech":"NNPS"}
│           └─0 TextNode "States" (1:17-1:23, 16-22)
├─1 WhiteSpaceNode "\n\n" (1:24-3:1, 23-25)
└─2 ParagraphNode[1] (3:1-3:115, 25-139)
    └─0 SentenceNode[47] (3:1-3:115, 25-139)
        ├─0 WordNode[1] (3:1-3:4, 25-28)
        │   │ data: {"partOfSpeech":"DT"}
        │   └─0 TextNode "The" (3:1-3:4, 25-28)
        ├─1 WhiteSpaceNode " " (3:4-3:5, 28-29)
        ├─2 WordNode[1] (3:5-3:12, 29-36)
        │   │ data: {"partOfSpeech":"NN"}
        │   └─0 TextNode "project" (3:5-3:12, 29-36)
        ├─3 WhiteSpaceNode " " (3:12-3:13, 36-37)
        ├─4 WordNode[1] (3:13-3:17, 37-41)
        │   │ data: {"partOfSpeech":"MD"}
        │   └─0 TextNode "will" (3:13-3:17, 37-41)
        ├─5 WhiteSpaceNode " " (3:17-3:18, 41-42)
        ├─6 WordNode[1] (3:18-3:22, 42-46)
        │   │ data: {"partOfSpeech":"NN"}
        │   └─0 TextNode "cost" (3:18-3:22, 42-46)
        ├─7 WhiteSpaceNode " " (3:22-3:23, 46-47)
        ├─8 WordNode[1] (3:23-3:30, 47-54)
        │   │ data: {"partOfSpeech":"IN"}
        │   └─0 TextNode "between" (3:23-3:30, 47-54)
        ├─9 WhiteSpaceNode " " (3:30-3:31, 54-55)
        ├─10 SymbolNode "$" (3:31-3:32, 55-56)
        ├─11 WordNode[1] (3:32-3:33, 56-57)
        │   │ data: {"partOfSpeech":"CD"}
        │   └─0 TextNode "5" (3:32-3:33, 56-57)
        ├─12 PunctuationNode "," (3:33-3:34, 57-58)
        ├─13 WordNode[1] (3:34-3:37, 58-61)
        │   │ data: {"partOfSpeech":"CD"}
        │   └─0 TextNode "000" (3:34-3:37, 58-61)
        ├─14 WhiteSpaceNode " " (3:37-3:38, 61-62)
        ├─15 WordNode[1] (3:38-3:41, 62-65)
        │   │ data: {"partOfSpeech":"CC"}
        │   └─0 TextNode "and" (3:38-3:41, 62-65)
        ├─16 WhiteSpaceNode " " (3:41-3:42, 65-66)
        ├─17 SymbolNode "$" (3:42-3:43, 66-67)
        ├─18 WordNode[1] (3:43-3:45, 67-69)
        │   │ data: {"partOfSpeech":"CD"}
        │   └─0 TextNode "30" (3:43-3:45, 67-69)
        ├─19 PunctuationNode "," (3:45-3:46, 69-70)
        ├─20 WordNode[1] (3:46-3:49, 70-73)
        │   │ data: {"partOfSpeech":"CD"}
        │   └─0 TextNode "000" (3:46-3:49, 70-73)
        ├─21 WhiteSpaceNode " " (3:49-3:50, 73-74)
        ├─22 WordNode[1] (3:50-3:57, 74-81)
        │   │ data: {"partOfSpeech":"NNS"}
        │   └─0 TextNode "dollars" (3:50-3:57, 74-81)
        ├─23 PunctuationNode "," (3:57-3:58, 81-82)
        ├─24 WhiteSpaceNode " " (3:58-3:59, 82-83)
        ├─25 WordNode[1] (3:59-3:68, 83-92)
        │   │ data: {"partOfSpeech":"VBG"}
        │   └─0 TextNode "depending" (3:59-3:68, 83-92)
        ├─26 WhiteSpaceNode " " (3:68-3:69, 92-93)
        ├─27 WordNode[1] (3:69-3:71, 93-95)
        │   │ data: {"partOfSpeech":"IN"}
        │   └─0 TextNode "on" (3:69-3:71, 93-95)
        ├─28 WhiteSpaceNode " " (3:71-3:72, 95-96)
        ├─29 WordNode[1] (3:72-3:75, 96-99)
        │   │ data: {"partOfSpeech":"WRB"}
        │   └─0 TextNode "how" (3:72-3:75, 96-99)
        ├─30 WhiteSpaceNode " " (3:75-3:76, 99-100)
        ├─31 WordNode[1] (3:76-3:81, 100-105)
        │   │ data: {"partOfSpeech":"JJ"}
        │   └─0 TextNode "fancy" (3:76-3:81, 100-105)
        ├─32 WhiteSpaceNode " " (3:81-3:82, 105-106)
        ├─33 WordNode[1] (3:82-3:85, 106-109)
        │   │ data: {"partOfSpeech":"PRP"}
        │   └─0 TextNode "you" (3:82-3:85, 106-109)
        ├─34 WhiteSpaceNode " " (3:85-3:86, 109-110)
        ├─35 WordNode[1] (3:86-3:90, 110-114)
        │   │ data: {"partOfSpeech":"VBP"}
        │   └─0 TextNode "want" (3:86-3:90, 110-114)
        ├─36 WhiteSpaceNode " " (3:90-3:91, 114-115)
        ├─37 WordNode[1] (3:91-3:94, 115-118)
        │   │ data: {"partOfSpeech":"DT"}
        │   └─0 TextNode "the" (3:91-3:94, 115-118)
        ├─38 WhiteSpaceNode " " (3:94-3:95, 118-119)
        ├─39 WordNode[1] (3:95-3:100, 119-124)
        │   │ data: {"partOfSpeech":"JJ"}
        │   └─0 TextNode "final" (3:95-3:100, 119-124)
        ├─40 WhiteSpaceNode " " (3:100-3:101, 124-125)
        ├─41 WordNode[1] (3:101-3:108, 125-132)
        │   │ data: {"partOfSpeech":"NN"}
        │   └─0 TextNode "product" (3:101-3:108, 125-132)
        ├─42 WhiteSpaceNode " " (3:108-3:109, 132-133)
        ├─43 WordNode[1] (3:109-3:111, 133-135)
        │   │ data: {"partOfSpeech":"TO"}
        │   └─0 TextNode "to" (3:109-3:111, 133-135)
        ├─44 WhiteSpaceNode " " (3:111-3:112, 135-136)
        ├─45 WordNode[1] (3:112-3:114, 136-138)
        │   │ data: {"partOfSpeech":"VB"}
        │   └─0 TextNode "be" (3:112-3:114, 136-138)
        └─46 PunctuationNode "." (3:114-3:115, 138-139)
$

Remark(2)プラグイン の作り方

Creating a plugin with unified


2020/08/27
藤田昭人


Remark の話を続けます。

ビルディング・ブロックを積み上げて所望の機能を得る Remark では、 やはりプラグインを作らなければ、 その有り難みを本格的に感じることはできないのだろうなぁ…と思います。

ということで、本稿ではプラグインの作り方を調べてみました。


Creating a plugin with unified

Remark のプラグインの作り方は Creating a plugin with unified で紹介されています。

が、このチュートリアルも、 ステップ・バイ・ステップで進行するので少々まどろっこしいところがありますので、 その要点を網羅して1本のソースにまとめました。 見ての通り、前半がプラグイン、後半がそれを呼び出す本体のコードです。

/* 
 * 以降がプラグイン
 */
var visit = require('unist-util-visit');
var is = require('unist-util-is');

function attacher() {
    return transformer;

  function transformer(tree, file) {
    visit(tree, 'ParagraphNode', visitor);

    function visitor(node) {
      var children = node.children;

      children.forEach(function(child, index) {
        if (is(children[index - 1], 'SentenceNode') &&
            is(child, 'WhiteSpaceNode') &&
            is(children[index + 1], 'SentenceNode')) {
          if (child.value.length !== 1) {
              file.message('Expected 1 space between sentences, not '
                           +child.value.length, child);
          }
        }
      });
    }
  }
}

var spacing = attacher;

/* 
 * 以降が本体
 */

var fs = require('fs');
var retext = require('retext');
var report = require('vfile-reporter');

var doc = fs.readFileSync('example.md');

retext()
  .use(spacing)
  .process(doc, function(err, file) {
    console.error(report(err || file));
  });

実行すると…

$ node example.js
3:14-3:16  warning  Expected 1 space between sentences, not 21 warning

このプログラムは「文と文の間の空白をチェックし、空白1文字でなければワーニングメッセージを表示」します。プラグインのデモンストレーション以上の意味は無さそうです。


プラグインのインターフェースについて

JavaScriptは引数の記述の自由度プラグインのインターフェース仕様がきになったので、調べてみました。Remark のプラグインのインターフェースはUnifiedのプラグインの項目で確認することができます。

プラグインのインターフェースに関連することだけを次に抜粋しておきます。

プラグイン は、以下の方法で適用されるプロセッサを設定します。

プラグインはコンセプトです。 それらはattacherとして具現化します。


function attacher([options])

Attacher は実体化されたプラグインです。 attacher は、オプションを受け取り、プロセッサを設定することができる関数です。

Attacherは、パーサコンパイラ、設定データ構文木ファイルの処理方法を指定することで、 プロセッサを変更します。

コンテキスト

コンテキスト・オブジェクト(this)は、attacher が適用されているプロセッサに設定されます。

パラメーター
  • options (*, optional) - コンフィグレーション
リターン

transformer - オプション

ノート

Attachers are called when the processor is frozen, not when they are applied.

Attacherは、プロセッサがフリーズしたときに呼び出されるのであって、 適用されたときに呼び出されるのではありません。


function transformer(node, file[, next])

Transformersは、構文木ファイルを処理します。 transformer は、構文木とファイルが実行フェーズに渡されるたびに呼び出される関数です。 エラーが発生した場合(thrown、returned、rejected、passed to next、のいずれかの理由で) プロセスは停止します。

trough による 実行フェーズ は処理されます。 これらの関数の正確なセマンティクスについては、そのドキュメントを参照してください。

パラメーター
リターン
  • void — 何も返されない場合,次の変換器は同じ木を使い続けます.
  • Error — 致命的なエラーでプロセスを停止します。
  • node (Node) — 新しい構文木。 返された場合、次の変換器にはこの新しい木が与えられます。
  • Promise — 非同期操作を実行するために返されます。 promiseは(オプションでNodeを使って)解決されるか、 (オプションでErrorを使って)拒否されなければなりません


function next(err[, tree[, file]])

transformerシグネチャnext (第三引数) が含まれている場合、 transformer は非同期操作を行うことができます。 その場合は next() を呼び出す必要があります

パラメーター
  • err (Error, optional) — プロセスを停止するための致命的なエラー。
  • node (Node, optional) — 新しい構文木。 与えられた場合,次の変換器にはこの新しいツリーが与えられます。
  • file (VFile, optional) — 新しい[ファイル]file]。 与えられた場合,次のtransformerにはこの新しいファイルが与えられます。

どうやら構文木ファイルがセットで引き渡って来るようです。動的に確保され自由度の高いJavaScriptの配列を使った構文木は、もう少し調べてみないと…


構文木についてもう少し深掘り

という事で次のようなデバッグプリントみたいなプラグインを作ってみました。

var visit = require('unist-util-visit')

function attacher() {
    return transformer;
    function transformer(tree, file) {
    visit(tree, visitor);
    function visitor(node) {
        console.log(node);
    }
    }
}

var unified = require('unified');
var parse = require('remark-parse');
var remark2retext = require('remark-retext');
var english = require('parse-english');
var stringify = require('retext-stringify');
var vfile = require('to-vfile');
var report = require('vfile-reporter');

var processor = unified()
    .use(parse)
    .use(attacher)
    .use(remark2retext, english)
    .use(stringify);

processor.process(vfile.readSync('english.md'), done);

function done(err, file) {
    //console.error(report(err || file));
    //console.log(String(file));
}

入力ファイルenglish.mdも用意して…

# in the United States

The project will cost between $5,000 and $30,000 dollars, depending on how fancy you want the final product to be.

…実行してみました。

$ node example.js
{ type: 'root',
  children:
   [ { type: 'heading',
       depth: 1,
       children: [Array],
       position: [Position] },
     { type: 'paragraph', children: [Array], position: [Position] } ],
  position:
   { start: { line: 1, column: 1, offset: 0 },
     end: { line: 4, column: 1, offset: 140 } } }
{ type: 'heading',
  depth: 1,
  children:
   [ { type: 'text',
       value: 'in the United States',
       position: [Position] } ],
  position:
   Position {
     start: { line: 1, column: 1, offset: 0 },
     end: { line: 1, column: 24, offset: 23 },
     indent: [] } }
{ type: 'text',
  value: 'in the United States',
  position:
   Position {
     start: { line: 1, column: 3, offset: 2 },
     end: { line: 1, column: 23, offset: 22 },
     indent: [] } }
{ type: 'paragraph',
  children:
   [ { type: 'text',
       value:
        'The project will cost between $5,000 and $30,000 dollars, depending on how fancy you want the final product to be.',
       position: [Position] } ],
  position:
   Position {
     start: { line: 3, column: 1, offset: 25 },
     end: { line: 3, column: 115, offset: 139 },
     indent: [] } }
{ type: 'text',
  value:
   'The project will cost between $5,000 and $30,000 dollars, depending on how fancy you want the final product to be.',
  position:
   Position {
     start: { line: 3, column: 1, offset: 25 },
     end: { line: 3, column: 115, offset: 139 },
     indent: [] } }
$

Markdownの記法に従って、テキストを切り出してくれるようです。プラグインを挿入するポイントをズラしてみます。

--- example.js-  2020-08-28 08:24:40.000000000 +0900
+++ example.js    2020-08-28 08:24:50.000000000 +0900
@@ -20,8 +20,8 @@

 var processor = unified()
     .use(parse)
-    .use(attacher)
     .use(remark2retext, english)
+    .use(attacher)
     .use(stringify);

 processor.process(vfile.readSync('english.md'), done);

このプログラムを実行すると retext に変換された構文木をダンプすることができます (トークン化された長い構文木が出力されるので末尾に回します)。 テキスト部分がトークン化された長大なツリーが表示されました。が、もちろん、これは英文の場合。 日本文の場合は形態素解析を組み込まないといけませんねぇ。

以上



いや、いきなりトークン化された構文木が出力されたので、 思わず「長げ〜よ」ってツッコンでしまった。

$ node example.js
{ type: 'RootNode',
  children:
   [ { type: 'ParagraphNode', children: [Array], position: [Object] },
     { type: 'WhiteSpaceNode', value: '\n\n', position: [Object] },
     { type: 'ParagraphNode', children: [Array], position: [Object] } ],
  position:
   { start: { line: 1, column: 3, offset: 2 },
     end: { line: 3, column: 115, offset: 139 } } }
{ type: 'ParagraphNode',
  children:
   [ { type: 'SentenceNode', children: [Array], position: [Object] } ],
  position:
   { start: { line: 1, column: 3, offset: 2 },
     end: { line: 1, column: 23, offset: 22 } } }
{ type: 'SentenceNode',
  children:
   [ { type: 'WordNode', children: [Array], position: [Object] },
     { type: 'WhiteSpaceNode', value: ' ', position: [Object] },
     { type: 'WordNode', children: [Array], position: [Object] },
     { type: 'WhiteSpaceNode', value: ' ', position: [Object] },
     { type: 'WordNode', children: [Array], position: [Object] },
     { type: 'WhiteSpaceNode', value: ' ', position: [Object] },
     { type: 'WordNode', children: [Array], position: [Object] } ],
  position:
   { start: { line: 1, column: 3, offset: 2 },
     end: { line: 1, column: 23, offset: 22 } } }
{ type: 'WordNode',
  children: [ { type: 'TextNode', value: 'in', position: [Object] } ],
  position:
   { start: { line: 1, column: 3, offset: 2 },
     end: { line: 1, column: 5, offset: 4 } } }
{ type: 'TextNode',
  value: 'in',
  position:
   { start: { line: 1, column: 3, offset: 2 },
     end: { line: 1, column: 5, offset: 4 } } }
{ type: 'WhiteSpaceNode',
  value: ' ',
  position:
   { start: { line: 1, column: 5, offset: 4 },
     end: { line: 1, column: 6, offset: 5 } } }
{ type: 'WordNode',
  children: [ { type: 'TextNode', value: 'the', position: [Object] } ],
  position:
   { start: { line: 1, column: 6, offset: 5 },
     end: { line: 1, column: 9, offset: 8 } } }
{ type: 'TextNode',
  value: 'the',
  position:
   { start: { line: 1, column: 6, offset: 5 },
     end: { line: 1, column: 9, offset: 8 } } }
{ type: 'WhiteSpaceNode',
  value: ' ',
  position:
   { start: { line: 1, column: 9, offset: 8 },
     end: { line: 1, column: 10, offset: 9 } } }
{ type: 'WordNode',
  children:
   [ { type: 'TextNode', value: 'United', position: [Object] } ],
  position:
   { start: { line: 1, column: 10, offset: 9 },
     end: { line: 1, column: 16, offset: 15 } } }
{ type: 'TextNode',
  value: 'United',
  position:
   { start: { line: 1, column: 10, offset: 9 },
     end: { line: 1, column: 16, offset: 15 } } }
{ type: 'WhiteSpaceNode',
  value: ' ',
  position:
   { start: { line: 1, column: 16, offset: 15 },
     end: { line: 1, column: 17, offset: 16 } } }
{ type: 'WordNode',
  children:
   [ { type: 'TextNode', value: 'States', position: [Object] } ],
  position:
   { start: { line: 1, column: 17, offset: 16 },
     end: { line: 1, column: 23, offset: 22 } } }
{ type: 'TextNode',
  value: 'States',
  position:
   { start: { line: 1, column: 17, offset: 16 },
     end: { line: 1, column: 23, offset: 22 } } }
{ type: 'WhiteSpaceNode',
  value: '\n\n',
  position:
   { start: { line: 1, column: 24, offset: 23 },
     end: { line: 3, column: 1, offset: 25 } } }
{ type: 'ParagraphNode',
  children:
   [ { type: 'SentenceNode', children: [Array], position: [Object] } ],
  position:
   { start: { line: 3, column: 1, offset: 25 },
     end: { line: 3, column: 115, offset: 139 } } }
{ type: 'SentenceNode',
  children:
   [ { type: 'WordNode', children: [Array], position: [Object] },
     { type: 'WhiteSpaceNode', value: ' ', position: [Object] },
     { type: 'WordNode', children: [Array], position: [Object] },
     { type: 'WhiteSpaceNode', value: ' ', position: [Object] },
     { type: 'WordNode', children: [Array], position: [Object] },
     { type: 'WhiteSpaceNode', value: ' ', position: [Object] },
     { type: 'WordNode', children: [Array], position: [Object] },
     { type: 'WhiteSpaceNode', value: ' ', position: [Object] },
     { type: 'WordNode', children: [Array], position: [Object] },
     { type: 'WhiteSpaceNode', value: ' ', position: [Object] },
     { type: 'SymbolNode', value: '$', position: [Object] },
     { type: 'WordNode', children: [Array], position: [Object] },
     { type: 'PunctuationNode', value: ',', position: [Object] },
     { type: 'WordNode', children: [Array], position: [Object] },
     { type: 'WhiteSpaceNode', value: ' ', position: [Object] },
     { type: 'WordNode', children: [Array], position: [Object] },
     { type: 'WhiteSpaceNode', value: ' ', position: [Object] },
     { type: 'SymbolNode', value: '$', position: [Object] },
     { type: 'WordNode', children: [Array], position: [Object] },
     { type: 'PunctuationNode', value: ',', position: [Object] },
     { type: 'WordNode', children: [Array], position: [Object] },
     { type: 'WhiteSpaceNode', value: ' ', position: [Object] },
     { type: 'WordNode', children: [Array], position: [Object] },
     { type: 'PunctuationNode', value: ',', position: [Object] },
     { type: 'WhiteSpaceNode', value: ' ', position: [Object] },
     { type: 'WordNode', children: [Array], position: [Object] },
     { type: 'WhiteSpaceNode', value: ' ', position: [Object] },
     { type: 'WordNode', children: [Array], position: [Object] },
     { type: 'WhiteSpaceNode', value: ' ', position: [Object] },
     { type: 'WordNode', children: [Array], position: [Object] },
     { type: 'WhiteSpaceNode', value: ' ', position: [Object] },
     { type: 'WordNode', children: [Array], position: [Object] },
     { type: 'WhiteSpaceNode', value: ' ', position: [Object] },
     { type: 'WordNode', children: [Array], position: [Object] },
     { type: 'WhiteSpaceNode', value: ' ', position: [Object] },
     { type: 'WordNode', children: [Array], position: [Object] },
     { type: 'WhiteSpaceNode', value: ' ', position: [Object] },
     { type: 'WordNode', children: [Array], position: [Object] },
     { type: 'WhiteSpaceNode', value: ' ', position: [Object] },
     { type: 'WordNode', children: [Array], position: [Object] },
     { type: 'WhiteSpaceNode', value: ' ', position: [Object] },
     { type: 'WordNode', children: [Array], position: [Object] },
     { type: 'WhiteSpaceNode', value: ' ', position: [Object] },
     { type: 'WordNode', children: [Array], position: [Object] },
     { type: 'WhiteSpaceNode', value: ' ', position: [Object] },
     { type: 'WordNode', children: [Array], position: [Object] },
     { type: 'PunctuationNode', value: '.', position: [Object] } ],
  position:
   { start: { line: 3, column: 1, offset: 25 },
     end: { line: 3, column: 115, offset: 139 } } }
{ type: 'WordNode',
  children: [ { type: 'TextNode', value: 'The', position: [Object] } ],
  position:
   { start: { line: 3, column: 1, offset: 25 },
     end: { line: 3, column: 4, offset: 28 } } }
{ type: 'TextNode',
  value: 'The',
  position:
   { start: { line: 3, column: 1, offset: 25 },
     end: { line: 3, column: 4, offset: 28 } } }
{ type: 'WhiteSpaceNode',
  value: ' ',
  position:
   { start: { line: 3, column: 4, offset: 28 },
     end: { line: 3, column: 5, offset: 29 } } }
{ type: 'WordNode',
  children:
   [ { type: 'TextNode', value: 'project', position: [Object] } ],
  position:
   { start: { line: 3, column: 5, offset: 29 },
     end: { line: 3, column: 12, offset: 36 } } }
{ type: 'TextNode',
  value: 'project',
  position:
   { start: { line: 3, column: 5, offset: 29 },
     end: { line: 3, column: 12, offset: 36 } } }
{ type: 'WhiteSpaceNode',
  value: ' ',
  position:
   { start: { line: 3, column: 12, offset: 36 },
     end: { line: 3, column: 13, offset: 37 } } }
{ type: 'WordNode',
  children: [ { type: 'TextNode', value: 'will', position: [Object] } ],
  position:
   { start: { line: 3, column: 13, offset: 37 },
     end: { line: 3, column: 17, offset: 41 } } }
{ type: 'TextNode',
  value: 'will',
  position:
   { start: { line: 3, column: 13, offset: 37 },
     end: { line: 3, column: 17, offset: 41 } } }
{ type: 'WhiteSpaceNode',
  value: ' ',
  position:
   { start: { line: 3, column: 17, offset: 41 },
     end: { line: 3, column: 18, offset: 42 } } }
{ type: 'WordNode',
  children: [ { type: 'TextNode', value: 'cost', position: [Object] } ],
  position:
   { start: { line: 3, column: 18, offset: 42 },
     end: { line: 3, column: 22, offset: 46 } } }
{ type: 'TextNode',
  value: 'cost',
  position:
   { start: { line: 3, column: 18, offset: 42 },
     end: { line: 3, column: 22, offset: 46 } } }
{ type: 'WhiteSpaceNode',
  value: ' ',
  position:
   { start: { line: 3, column: 22, offset: 46 },
     end: { line: 3, column: 23, offset: 47 } } }
{ type: 'WordNode',
  children:
   [ { type: 'TextNode', value: 'between', position: [Object] } ],
  position:
   { start: { line: 3, column: 23, offset: 47 },
     end: { line: 3, column: 30, offset: 54 } } }
{ type: 'TextNode',
  value: 'between',
  position:
   { start: { line: 3, column: 23, offset: 47 },
     end: { line: 3, column: 30, offset: 54 } } }
{ type: 'WhiteSpaceNode',
  value: ' ',
  position:
   { start: { line: 3, column: 30, offset: 54 },
     end: { line: 3, column: 31, offset: 55 } } }
{ type: 'SymbolNode',
  value: '$',
  position:
   { start: { line: 3, column: 31, offset: 55 },
     end: { line: 3, column: 32, offset: 56 } } }
{ type: 'WordNode',
  children: [ { type: 'TextNode', value: '5', position: [Object] } ],
  position:
   { start: { line: 3, column: 32, offset: 56 },
     end: { line: 3, column: 33, offset: 57 } } }
{ type: 'TextNode',
  value: '5',
  position:
   { start: { line: 3, column: 32, offset: 56 },
     end: { line: 3, column: 33, offset: 57 } } }
{ type: 'PunctuationNode',
  value: ',',
  position:
   { start: { line: 3, column: 33, offset: 57 },
     end: { line: 3, column: 34, offset: 58 } } }
{ type: 'WordNode',
  children: [ { type: 'TextNode', value: '000', position: [Object] } ],
  position:
   { start: { line: 3, column: 34, offset: 58 },
     end: { line: 3, column: 37, offset: 61 } } }
{ type: 'TextNode',
  value: '000',
  position:
   { start: { line: 3, column: 34, offset: 58 },
     end: { line: 3, column: 37, offset: 61 } } }
{ type: 'WhiteSpaceNode',
  value: ' ',
  position:
   { start: { line: 3, column: 37, offset: 61 },
     end: { line: 3, column: 38, offset: 62 } } }
{ type: 'WordNode',
  children: [ { type: 'TextNode', value: 'and', position: [Object] } ],
  position:
   { start: { line: 3, column: 38, offset: 62 },
     end: { line: 3, column: 41, offset: 65 } } }
{ type: 'TextNode',
  value: 'and',
  position:
   { start: { line: 3, column: 38, offset: 62 },
     end: { line: 3, column: 41, offset: 65 } } }
{ type: 'WhiteSpaceNode',
  value: ' ',
  position:
   { start: { line: 3, column: 41, offset: 65 },
     end: { line: 3, column: 42, offset: 66 } } }
{ type: 'SymbolNode',
  value: '$',
  position:
   { start: { line: 3, column: 42, offset: 66 },
     end: { line: 3, column: 43, offset: 67 } } }
{ type: 'WordNode',
  children: [ { type: 'TextNode', value: '30', position: [Object] } ],
  position:
   { start: { line: 3, column: 43, offset: 67 },
     end: { line: 3, column: 45, offset: 69 } } }
{ type: 'TextNode',
  value: '30',
  position:
   { start: { line: 3, column: 43, offset: 67 },
     end: { line: 3, column: 45, offset: 69 } } }
{ type: 'PunctuationNode',
  value: ',',
  position:
   { start: { line: 3, column: 45, offset: 69 },
     end: { line: 3, column: 46, offset: 70 } } }
{ type: 'WordNode',
  children: [ { type: 'TextNode', value: '000', position: [Object] } ],
  position:
   { start: { line: 3, column: 46, offset: 70 },
     end: { line: 3, column: 49, offset: 73 } } }
{ type: 'TextNode',
  value: '000',
  position:
   { start: { line: 3, column: 46, offset: 70 },
     end: { line: 3, column: 49, offset: 73 } } }
{ type: 'WhiteSpaceNode',
  value: ' ',
  position:
   { start: { line: 3, column: 49, offset: 73 },
     end: { line: 3, column: 50, offset: 74 } } }
{ type: 'WordNode',
  children:
   [ { type: 'TextNode', value: 'dollars', position: [Object] } ],
  position:
   { start: { line: 3, column: 50, offset: 74 },
     end: { line: 3, column: 57, offset: 81 } } }
{ type: 'TextNode',
  value: 'dollars',
  position:
   { start: { line: 3, column: 50, offset: 74 },
     end: { line: 3, column: 57, offset: 81 } } }
{ type: 'PunctuationNode',
  value: ',',
  position:
   { start: { line: 3, column: 57, offset: 81 },
     end: { line: 3, column: 58, offset: 82 } } }
{ type: 'WhiteSpaceNode',
  value: ' ',
  position:
   { start: { line: 3, column: 58, offset: 82 },
     end: { line: 3, column: 59, offset: 83 } } }
{ type: 'WordNode',
  children:
   [ { type: 'TextNode', value: 'depending', position: [Object] } ],
  position:
   { start: { line: 3, column: 59, offset: 83 },
     end: { line: 3, column: 68, offset: 92 } } }
{ type: 'TextNode',
  value: 'depending',
  position:
   { start: { line: 3, column: 59, offset: 83 },
     end: { line: 3, column: 68, offset: 92 } } }
{ type: 'WhiteSpaceNode',
  value: ' ',
  position:
   { start: { line: 3, column: 68, offset: 92 },
     end: { line: 3, column: 69, offset: 93 } } }
{ type: 'WordNode',
  children: [ { type: 'TextNode', value: 'on', position: [Object] } ],
  position:
   { start: { line: 3, column: 69, offset: 93 },
     end: { line: 3, column: 71, offset: 95 } } }
{ type: 'TextNode',
  value: 'on',
  position:
   { start: { line: 3, column: 69, offset: 93 },
     end: { line: 3, column: 71, offset: 95 } } }
{ type: 'WhiteSpaceNode',
  value: ' ',
  position:
   { start: { line: 3, column: 71, offset: 95 },
     end: { line: 3, column: 72, offset: 96 } } }
{ type: 'WordNode',
  children: [ { type: 'TextNode', value: 'how', position: [Object] } ],
  position:
   { start: { line: 3, column: 72, offset: 96 },
     end: { line: 3, column: 75, offset: 99 } } }
{ type: 'TextNode',
  value: 'how',
  position:
   { start: { line: 3, column: 72, offset: 96 },
     end: { line: 3, column: 75, offset: 99 } } }
{ type: 'WhiteSpaceNode',
  value: ' ',
  position:
   { start: { line: 3, column: 75, offset: 99 },
     end: { line: 3, column: 76, offset: 100 } } }
{ type: 'WordNode',
  children: [ { type: 'TextNode', value: 'fancy', position: [Object] } ],
  position:
   { start: { line: 3, column: 76, offset: 100 },
     end: { line: 3, column: 81, offset: 105 } } }
{ type: 'TextNode',
  value: 'fancy',
  position:
   { start: { line: 3, column: 76, offset: 100 },
     end: { line: 3, column: 81, offset: 105 } } }
{ type: 'WhiteSpaceNode',
  value: ' ',
  position:
   { start: { line: 3, column: 81, offset: 105 },
     end: { line: 3, column: 82, offset: 106 } } }
{ type: 'WordNode',
  children: [ { type: 'TextNode', value: 'you', position: [Object] } ],
  position:
   { start: { line: 3, column: 82, offset: 106 },
     end: { line: 3, column: 85, offset: 109 } } }
{ type: 'TextNode',
  value: 'you',
  position:
   { start: { line: 3, column: 82, offset: 106 },
     end: { line: 3, column: 85, offset: 109 } } }
{ type: 'WhiteSpaceNode',
  value: ' ',
  position:
   { start: { line: 3, column: 85, offset: 109 },
     end: { line: 3, column: 86, offset: 110 } } }
{ type: 'WordNode',
  children: [ { type: 'TextNode', value: 'want', position: [Object] } ],
  position:
   { start: { line: 3, column: 86, offset: 110 },
     end: { line: 3, column: 90, offset: 114 } } }
{ type: 'TextNode',
  value: 'want',
  position:
   { start: { line: 3, column: 86, offset: 110 },
     end: { line: 3, column: 90, offset: 114 } } }
{ type: 'WhiteSpaceNode',
  value: ' ',
  position:
   { start: { line: 3, column: 90, offset: 114 },
     end: { line: 3, column: 91, offset: 115 } } }
{ type: 'WordNode',
  children: [ { type: 'TextNode', value: 'the', position: [Object] } ],
  position:
   { start: { line: 3, column: 91, offset: 115 },
     end: { line: 3, column: 94, offset: 118 } } }
{ type: 'TextNode',
  value: 'the',
  position:
   { start: { line: 3, column: 91, offset: 115 },
     end: { line: 3, column: 94, offset: 118 } } }
{ type: 'WhiteSpaceNode',
  value: ' ',
  position:
   { start: { line: 3, column: 94, offset: 118 },
     end: { line: 3, column: 95, offset: 119 } } }
{ type: 'WordNode',
  children: [ { type: 'TextNode', value: 'final', position: [Object] } ],
  position:
   { start: { line: 3, column: 95, offset: 119 },
     end: { line: 3, column: 100, offset: 124 } } }
{ type: 'TextNode',
  value: 'final',
  position:
   { start: { line: 3, column: 95, offset: 119 },
     end: { line: 3, column: 100, offset: 124 } } }
{ type: 'WhiteSpaceNode',
  value: ' ',
  position:
   { start: { line: 3, column: 100, offset: 124 },
     end: { line: 3, column: 101, offset: 125 } } }
{ type: 'WordNode',
  children:
   [ { type: 'TextNode', value: 'product', position: [Object] } ],
  position:
   { start: { line: 3, column: 101, offset: 125 },
     end: { line: 3, column: 108, offset: 132 } } }
{ type: 'TextNode',
  value: 'product',
  position:
   { start: { line: 3, column: 101, offset: 125 },
     end: { line: 3, column: 108, offset: 132 } } }
{ type: 'WhiteSpaceNode',
  value: ' ',
  position:
   { start: { line: 3, column: 108, offset: 132 },
     end: { line: 3, column: 109, offset: 133 } } }
{ type: 'WordNode',
  children: [ { type: 'TextNode', value: 'to', position: [Object] } ],
  position:
   { start: { line: 3, column: 109, offset: 133 },
     end: { line: 3, column: 111, offset: 135 } } }
{ type: 'TextNode',
  value: 'to',
  position:
   { start: { line: 3, column: 109, offset: 133 },
     end: { line: 3, column: 111, offset: 135 } } }
{ type: 'WhiteSpaceNode',
  value: ' ',
  position:
   { start: { line: 3, column: 111, offset: 135 },
     end: { line: 3, column: 112, offset: 136 } } }
{ type: 'WordNode',
  children: [ { type: 'TextNode', value: 'be', position: [Object] } ],
  position:
   { start: { line: 3, column: 112, offset: 136 },
     end: { line: 3, column: 114, offset: 138 } } }
{ type: 'TextNode',
  value: 'be',
  position:
   { start: { line: 3, column: 112, offset: 136 },
     end: { line: 3, column: 114, offset: 138 } } }
{ type: 'PunctuationNode',
  value: '.',
  position:
   { start: { line: 3, column: 114, offset: 138 },
     end: { line: 3, column: 115, offset: 139 } } }
$