Your first run¶
By the end of this you will have printed a sauce derivation tree, built from a cookbook revised in 1909, with nothing installed but Python.
Before you start¶
You need Python 3.12 or newer and uv. You do not need a GPU, an API key, a database, or a network connection — the source text is committed to the repository.
Get the code¶
Read the book¶
You should see something like:
escoffier-1909 New and Revised Edition, January 1909 (impression: January 1920)
transcription of Project Gutenberg 71395
mothers: bechamel, espagnole, hollandaise, tomato, veloute
151 sauces, 57 derived, 94 unresolved
escoffier-1907 no edition stated, copyright 1907
ocr of Internet Archive cu31924000610117
mothers: bechamel, espagnole, hollandaise, tomato, veloute
140 sauces, 51 derived, 89 unresolved
Four things happened.
Each source named itself. escoffier-1909 is not the filename. The
parser read the printing history off the title page and built the id from
it. You can read the same block:
$ sed -n '119,123p' corpus/escoffier-1909.txt
_First Printed, May 1907
Second Impression, December 1907
New and Revised Edition, January 1909
New Impressions, August 1911, May 1913,
March 1916, January 1920._
The five mothers were not supplied by us. Escoffier lists his own base sauces, the parser found that sentence, and read them out of it.
151 sauces were extracted from roughly 3,000 numbered entries. The rest are stocks, garnishes, soups, and dishes. An entry gets in when its heading says "sauce", or when Escoffier filed it in a chapter he titles as sauces.
94 unresolved is the honest score, and it is the most interesting number on the screen. A parser cannot read a derivation the author never wrote down, and it will not choose between two he wrote.
The second block is a different book. escoffier-1907 is a scan of the 1907
first printing, machine-read rather than proofread. Its numbers are lower,
and every record it produces says it came through OCR.
Print a family tree¶
$ uv run saucier tree espagnole
BROWN SAUCE OR ESPAGNOLE [espagnole] derives from brown-roux
├── HALF GLAZE (en)
│ ├── SAUCE BORDELAISE (fr)
│ │ └── MARROW SAUCE (en)
│ ├── BROWN CHAUD-FROID SAUCE (en)
│ ├── DEVILLED SAUCE (en)
│ ├── ITALIAN SAUCE (en)
│ ├── LYONNAISE SAUCE (en)
│ ├── MADEIRA SAUCE (en)
│ ├── PIQUANTE SAUCE (en)
│ └── ROBERT SAUCE (en)
├── LENTEN ESPAGNOLE (fr)
│ └── GENEVOISE SAUCE (en)
├── ORDINARY POIVRADE SAUCE (en)
└── POIVRADE SAUCE FOR VENISON (en)
The tag after each name is the language its title is written in. The tree has
depth because a parent may be any catalogued preparation, not only a mother:
Robert states half glaze, and half glaze states Espagnole. Espagnole is a
mother, and it states brown roux, which the heading line says. Try
brown-roux to see the whole chain, or hollandaise and veloute.
Look one up¶
$ uv run saucier show bordelaise
SAUCE BORDELAISE
entry 32, line 1680, transcription of escoffier-1909
term SAUCE BORDELAISE [fr] sauce-bordelaise
parent half-glaze
procedure (unrecorded)
You asked for bordelaise. Escoffier writes SAUCE BORDELAISE, French word
order, and the lookup handled it.
Bordelaise states half glaze, and Marrow Sauce states Bordelaise:
$ uv run saucier tree bordelaise
SAUCE BORDELAISE [bordelaise] derives from half-glaze
└── MARROW SAUCE (en)
Now look at one the parser refused:
$ uv run saucier show cardinal-sauce
CARDINAL SAUCE
entry 69, line 2192, transcription of escoffier-1909
term CARDINAL SAUCE [en] cardinal-sauce
parent (unresolved)
stated bechamel, lobster-butter
procedure (unrecorded)
Cardinal says "Boil one pint of Béchamel", and later "finish the sauce ... with three oz. of very red lobster butter". One is the base and one is the finish. The parser reads names and cannot tell which is which, so it records nothing and prints both. The record keeps the claims apart: what the source said, and what the parser could not decide.
Now check the parser's work. line 1680 is a line in the file on disk:
Every claim in the output is traceable that way.
Read a procedure¶
A parent says what a sauce is built from, and not what is done with it. One preparation now says that too:
$ uv run saucier show mornay
MORNAY SAUCE
entry 91, line 2437, transcription of escoffier-1909
term MORNAY SAUCE [en] mornay-sauce
parent bechamel
procedure 6 operations, recorded by hand
Boil Béchamel Sauce [fr] 1 pint, fumet [fr] 1/4 pint
Reduce criterion: by a good quarter (unresolved)
add Gruyère [fr] 2 oz., Parmesan [en] 2 oz.
Put duration: a few minutes (unresolved), on the fire again
stirring instrument: small whisk, criterion: the melting of the cheese (unresolved)
Finish butter [en] 2 oz., away from the fire, added by degrees
Six operations, in the order the body states them. The first one boils
one pint of Béchamel, which is the parent, so the derivation finally has
a verb. Every word on those lines is quoted from the entry, and the
command checks each one against the body before it prints. a few
minutes is a duration the text gives no number for, so its number is
unresolved. That is the same absence a parent records when the source
states none.
The scan states the same procedure in its own words. Its heading reads
MORN AY SAUCE, so look it up by that name:
$ uv run saucier show morn-ay-sauce --source escoffier-1907
MORN AY SAUCE
entry 91, line 2864, ocr of escoffier-1907
term MORN AY SAUCE [en] morn-ay-sauce
parent (unresolved)
stated no candidate
procedure 6 operations, recorded by hand
Boil Bdchamel Sauce [fr] 1 pint, fumet [fr] 1/4 pint
Reduce criterion: by a good quarter (unresolved)
add Gruy^re [fr] 2 oz., Parmesan [en] 2 oz.
Put duration: a few minutes (unresolved), on the fire again
stirring instrument: small whisk, criterion: the melting of the cheese (unresolved)
Finish butter [en] 2 oz., away from the fire, added by degrees
The scanner turned Béchamel into Bdchamel, so the scan's Mornay has
no parent it can name. The verb survived, and so did every quantity. Only
one preparation is recorded in this release, and a hand read it.
Everything else prints (unrecorded).
ADR-0017 says why one is
enough to settle the shape, and what the two witnesses disagree on.
What you have¶
Two JSON catalogues under data/, one per witness, reproducible from the
committed corpus at any time. data/ is not tracked, because anything in it
can be regenerated by running parse again.
Carry it out of the process¶
The JSON files are a snapshot for this program. To hand the catalogue to anything else, write it as one record per line:
$ uv run saucier export > escoffier.jsonl
$ head -n 2 escoffier.jsonl
{"schema":"saucier/1","type":"catalogue","id":"escoffier-1909","work":"escoffier","edition":{"statement":"New and Revised Edition, January 1909","stated_year":1909,"impression":"January 1920","copyright_year":1907},"origin":"Project Gutenberg 71395","fidelity":"transcription","mothers":["bechamel","espagnole","hollandaise","tomato","veloute"],"preparations":151,"entries_read":2963}
{"schema":"saucier/1","type":"catalogue","id":"escoffier-1907","work":"escoffier","edition":{"statement":null,"stated_year":null,"impression":null,"copyright_year":1907},"origin":"Internet Archive cu31924000610117","fidelity":"ocr","mothers":["bechamel","espagnole","hollandaise","tomato","veloute"],"preparations":140,"entries_read":2679}
Every line says what it is, which schema shaped it, and which catalogue it
belongs to. The two catalogue records come first, each carrying the witness
it was read from, then one record per preparation. A preparation is
addressed by its catalogue and its heading line, so
escoffier-1909:line:1680 is Bordelaise, and 1680 is the line you opened
with sed above.
$ grep '"id":"escoffier-1909:line:1680"' escoffier.jsonl | cut -c1-160
{"schema":"saucier/1","type":"preparation","id":"escoffier-1909:line:1680","catalogue":"escoffier-1909","title":"SAUCE BORDELAISE","terms":[{"surface":"SAUCE BO
Prove the stream rebuilds both catalogues without writing anything:
$ uv run saucier import --check < escoffier.jsonl
escoffier-1909 151 sauces, 57 derived, 94 unresolved
escoffier-1907 140 sauces, 51 derived, 89 unresolved
2 catalogues and 291 preparations rebuilt. Nothing written.
The same four numbers. Run export twice and compare the hashes. They are
identical, because the writer emits records in one order, with one key
order, and no timestamp. Feed the check an empty stream and it fails with
exit code 2, because an empty stream is what a failed export leaves behind
and a check must not call that a success. A serialization and a database are different
decisions, and
ADR-0016 says why
this release makes only the first.
Compare the two printings¶
Every row carries a cause. ocr-suspected means one witness is a scan and
the difference may be the scanner rather than Escoffier. Nothing in the
output decides which. Read
ADR-0010 for why the
record has to carry that distinction.
Next¶
Read why there is no model in this yet. The 94 unresolved preparations are the reason there will be one.